/* Copyright 2022 the SumatraPDF project authors (see AUTHORS file).
   License: GPLv3 */

#include "utils/BaseUtil.h"
#include "utils/StrFormat.h"
#include <shlobj.h>
#include "utils/ScopedWin.h"
#include "utils/WinDynCalls.h"
#include "utils/CryptoUtil.h"
#include "utils/DirIter.h"
#include "utils/Dpi.h"
#include "utils/FileUtil.h"
#include "utils/FileWatcher.h"
#include "utils/GuessFileType.h"
#include "utils/HtmlParserLookup.h"
#include "utils/HttpUtil.h"
#include "utils/SquareTreeParser.h"
#include "utils/ThreadUtil.h"
#include "utils/UITask.h"
#include "utils/WinUtil.h"
#include "utils/GdiPlusUtil.h"
#include "utils/Archive.h"
#include "utils/Timer.h"
#include "utils/LzmaSimpleArchive.h"

#include "wingui/UIModels.h"
#include "wingui/Layout.h"
#include "wingui/WinGui.h"
#include "wingui/WebView.h"

#include "wingui/LabelWithCloseWnd.h"
#include "wingui/FrameRateWnd.h"

#include "SimpleBrowserWindow.h"

#include "Settings.h"
#include "DisplayMode.h"
#include "DocProperties.h"
#include "DocController.h"
#include "EngineBase.h"
#include "EngineAll.h"
#include "Annotation.h"
#include "PdfCreator.h"
#include "PdfTools.h"
#include "GlobalPrefs.h"
#include "ChmModel.h"
#include "PalmDbReader.h"
#include "EbookBase.h"
#include "EbookDoc.h"
#include "MobiDoc.h"
#include "DisplayModel.h"
#include "FileHistory.h"
#include "PdfSync.h"
#include "RenderCache.h"
#include "ProgressUpdateUI.h"
#include "TextSelection.h"
#include "TextSearch.h"
#include "SumatraPDF.h"
#include "Notifications.h"
#include "MainWindow.h"
#include "WindowTab.h"
#include "UpdateCheck.h"
#include "resource.h"
#include "Commands.h"
#include "Flags.h"
#include "AppSettings.h"
#include "AppTools.h"
#include "Canvas.h"
#include "CrashHandler.h"
#include "ExternalViewers.h"
#include "Favorites.h"
#include "FileThumbnails.h"
#include "Menu.h"
#include "Print.h"
#include "SearchAndDDE.h"
#include "Selection.h"
#include "SelectionToolbar.h"
#include "Screenshot.h"
#include "ImageSaveCropResize.h"
#include "StressTesting.h"
#include "HomePage.h"
#include "OverlayScrollbar.h"
#include "SumatraDialogs.h"
#include "SumatraProperties.h"
#include "TabGroupsManage.h"
#include "TableOfContents.h"
#include "Tabs.h"
#include "Toolbar.h"
#include "FindBar.h"
#include "FindWindow.h"
#include "WordLookup.h"
#include "Translations.h"
#include "uia/Provider.h"
#include "Version.h"
#include "SumatraConfig.h"
#include "EditAnnotations.h"
#include "EbookAnnotations.h"
#include "EditEbookAnnotations.h"
#include "CommandPalette.h"
#include "Installer.h"
#include "RegistryPreview.h"
#include "RegistrySearchFilter.h"
#include "Theme.h"
#include "CaptionGlyphs.h"
#include "PdfDarkMode.h"
#include "DarkModeSubclass.h"
#include "TextToSpeech.h"
#include "ReadAloudHighlight.h"

#include "utils/Log.h"

using Gdiplus::Color;
using Gdiplus::Graphics;
using Gdiplus::Pen;
using Gdiplus::SolidBrush;

constexpr const char* kRestrictionsFileName = "sumatrapdfrestrict.ini";

#define kSumatraWindowTitle kAppName

// used to show it in debug, but is not very useful,
// so always disable
bool gShowFrameRate = false;

// in plugin mode, the window's frame isn't drawn and closing and
// fullscreen are disabled, so that SumatraPDF can be displayed
// embedded (e.g. in a web browser)
const char* gPluginURL = nullptr; // owned by Flags in WinMain
bool gMyWindowWasEmbedded = false;

bool NeedsWindowEmbeddingHacks() {
    return gMyWindowWasEmbedded || gPluginMode;
}

bool SettingsUseTabs() {
    return gGlobalPrefs->useTabs && !gMyWindowWasEmbedded;
}

bool SettingsRestoreSession() {
    return gGlobalPrefs->restoreSession && !gMyWindowWasEmbedded;
}

bool SettingsRememberOpenedFiles() {
    return gGlobalPrefs->rememberOpenedFiles && !gMyWindowWasEmbedded;
}

static Kind kNotifPersistentWarning = "persistentWarning";
static Kind kNotifZoom = "zoom";

HBITMAP gBitmapReloadingCue;
RenderCache* gRenderCache;
HCURSOR gCursorDrag;

// set after mouse shortcuts involving the Alt key (so that the menu bar isn't activated)
bool gSupressNextAltMenuTrigger = false;

bool gCrashOnOpen = false;
bool gRedrawLog = false;

static void RelayoutFrame(MainWindow* win, bool updateToolbars = true, int sidebarDx = -1);
static void UpdateOverlayScrollbarPositions(MainWindow* win);
static void SyncCanvasScrollBarTheme(MainWindow* win);
static void BeginFrameRedrawSuppression(MainWindow* win);
static void EndFrameRedrawSuppression(MainWindow* win);
static void ResyncReadAloudAfterLayoutChange(WindowTab* tab, MainWindow* win);
static void ScheduleReadAloudResyncAfterLayoutChange(MainWindow* win, WindowTab* tab);

static const char* HwndName(HWND hwnd) {
    WCHAR cls[64]{};
    GetClassNameW(hwnd, cls, dimof(cls));
    if (str::Eq(cls, FRAME_CLASS_NAME)) {
        return "frame";
    }
    if (str::Eq(cls, CANVAS_CLASS_NAME)) {
        return "canvas";
    }
    // TODO: could identify more windows (rebar, toc, etc.)
    return "other";
}

static void LogRedraw(const char* what, HWND hwnd, const RECT* rc = nullptr) {
    if (!gRedrawLog) {
        return;
    }
    if (rc) {
        logf("redraw: %s hwnd=0x%p (%s) rc=(%d,%d,%d,%d)\n", what, hwnd, HwndName(hwnd), rc->left, rc->top, rc->right,
             rc->bottom);
    } else {
        logf("redraw: %s hwnd=0x%p (%s)\n", what, hwnd, HwndName(hwnd));
    }
}

// in restricted mode, some features can be disabled (such as
// opening files, printing, following URLs), so that SumatraPDF
// can be used as a PDF reader on locked down systems
static Perm gPolicyRestrictions = Perm::All;
// only the listed protocols will be passed to the OS for
// opening in e.g. a browser or an email client (ignored,
// if gPolicyRestrictions doesn't contain Perm::DiskAccess)
static StrVec gAllowedLinkProtocols;
// only files of the listed perceived types will be opened
// externally by LinkHandler::LaunchFile (i.e. when clicking
// on an in-document link); examples: "audio", "video", ...
static StrVec gAllowedFileTypes;

static const char* gNextPrevDir = nullptr;
static StrVec gNextPrevDirCache; // cached files in gNextPrevDir

static void CloseDocumentInCurrentTab(MainWindow*, bool keepUIEnabled, bool deleteModel);
static void ReadAloudClearSourceTab();
static void ReadAloudFromViewportTopInTab(WindowTab* tab);
static void ReadAloudFromCursorInTab(WindowTab* tab, Point screenPt);
static void ReadAloudInTab(WindowTab* tab);
static void ReadAloudSelectionInTab(WindowTab* tab);
static bool ReadAloudResolveReadFromCursorPoint(MainWindow* win, Point* ptOut);
static void ReadAloudStopRememberPos();
static void ReadAloudPlaybackStop();
static void ReadAloudPrepareRestart(WindowTab* tab, MainWindow* win);
static void ResetReadAloudStateForTab(WindowTab* tab);
static void StopReadAloudIfSourceTab(WindowTab* tab);
static void StopReadAloudIfSourceWindow(MainWindow* win);
static void SyncNativeMenuBar(MainWindow* win);
static void UpdateMainWindowNativeChrome(MainWindow* win);
void DeleteManualBrowserWindow();
static void OnSidebarSplitterMove(Splitter::MoveEvent*);
static void OnFavSplitterMove(Splitter::MoveEvent*);
static void CloseAuxiliaryTopLevelWindows();
static void ExitProcessAfterShutdown();
static void TerminateApplication();
static void RestartApplication();

EBookUI* GetEBookUI() {
    if (!gGlobalPrefs) return nullptr;
    return &gGlobalPrefs->eBookUI;
}

LoadArgs::LoadArgs(const char* origPath, MainWindow* win) {
    this->fileArgs = ParseFileArgs(origPath);
    const char* cleanPath = origPath;
    if (fileArgs) {
        cleanPath = fileArgs->cleanPath;
        logf("LoadArgs: origPath='%s', cleanPath='%s'\n", origPath, cleanPath);
    }
    char* path = path::NormalizeTemp(cleanPath);
    if (!str::EqI(path, cleanPath)) {
        logf("LoadArgs: cleanPath='%s', path='%s'\n", cleanPath, path);
    }
    this->fileName.SetCopy(path);
    this->win = win;
}

LoadArgs::~LoadArgs() {
    delete fileArgs;
}

const char* LoadArgs::FilePath() const {
    return fileName.Get();
}

void LoadArgs::SetFilePath(const char* path) {
    fileName.SetCopy(path);
}

LoadArgs* LoadArgs::Clone() {
    LoadArgs* res = new LoadArgs(fileName, win);
    res->engine = engine;
    res->showWin = showWin;
    res->forceReuse = forceReuse;
    res->noPlaceWindow = noPlaceWindow;
    res->isNewWindow = isNewWindow;
    res->placeWindow = placeWindow;
    res->noSavePrefs = noSavePrefs;
    res->lazyLoad = lazyLoad;
    res->async = async;
    res->syncLoad = syncLoad;
    res->activateExisting = activateExisting;
    res->tabState = tabState;
    return res;
}

void SetCurrentLang(const char* langCode) {
    if (!langCode) {
        return;
    }
    const char* canonical = trans::ValidateLangCode(langCode);
    if (!canonical) {
        logf("SetCurrentLang: unknown lang code: '%s'\n", langCode);
        return;
    }
    str::ReplaceWithCopy(&gGlobalPrefs->uiLanguage, canonical);
    trans::SetCurrentLangByCode(canonical);
}

#define DEFAULT_FILE_PERCEIVED_TYPES "audio,video,webpage"
#define DEFAULT_LINK_PROTOCOLS "http,https,mailto,file"

void InitializePolicies(bool restrict) {
    // default configuration should be to restrict everything
    ReportIf(gPolicyRestrictions != Perm::All);
    ReportIf(gAllowedLinkProtocols.Size() != 0 || gAllowedFileTypes.Size() != 0);

    // the -restrict command line flag overrides any sumatrapdfrestrict.ini configuration
    if (restrict) {
        gPolicyRestrictions = Perm::RestrictedUse;
        return;
    }

    // allow to restrict SumatraPDF's functionality from an INI file in the
    // same directory as SumatraPDF.exe (see ../docs/sumatrapdfrestrict.ini)
    // (if the file isn't there, everything is allowed)
    TempStr restrictPath = GetPathInExeDirTemp(kRestrictionsFileName);
    if (!file::Exists(restrictPath)) {
        Split(&gAllowedLinkProtocols, DEFAULT_LINK_PROTOCOLS, ",");
        Split(&gAllowedFileTypes, DEFAULT_FILE_PERCEIVED_TYPES, ",");
        return;
    }

    ByteSlice restrictData = file::ReadFile(restrictPath);
    SquareTreeNode* root = ParseSquareTree(restrictData);
    AutoDelete delRoot(root);
    SquareTreeNode* polsec = root ? root->GetChild("Policies") : nullptr;
    // if the restriction file is broken, err on the side of full restriction
    if (!polsec) {
        return;
    }

    static Perm perms[] = {Perm::InternetAccess, Perm::DiskAccess,    Perm::SavePreferences, Perm::RegistryAccess,
                           Perm::PrinterAccess,  Perm::CopySelection, Perm::FullscreenAccess};
    static SeqStrings permNames =
        "InternetAccess\0DiskAccess\0SavePreferences\0RegistryAccess\0PrinterAccess\0CopySelection\0FullscreenAccess\0";

    // enable policies as indicated in sumatrapdfrestrict.ini
    for (int i = 0; i < dimofi(perms); i++) {
        const char* name = seqstrings::IdxToStr(permNames, i);
        const char* val = polsec->GetValue(name);
        if (val && atoi(val) != 0) {
            gPolicyRestrictions = gPolicyRestrictions | perms[i];
        }
    }

    // determine the list of allowed link protocols and perceived file types
    if ((gPolicyRestrictions & Perm::DiskAccess) != (Perm)0) {
        const char* value = polsec->GetValue("LinkProtocols");
        if (value != nullptr) {
            char* protocols = str::DupTemp(value);
            str::ToLowerInPlace(protocols);
            str::TransCharsInPlace(protocols, " :;", ",,,");
            Split(&gAllowedLinkProtocols, protocols, ",", true);
        }
        value = polsec->GetValue("SafeFileTypes");
        if (value != nullptr) {
            char* protocols = str::DupTemp(value);
            str::ToLowerInPlace(protocols);
            str::TransCharsInPlace(protocols, " :;", ",,,");
            Split(&gAllowedFileTypes, protocols, ",", true);
        }
    }
}

void RestrictPolicies(Perm revokePermission) {
    gPolicyRestrictions = (gPolicyRestrictions | Perm::RestrictedUse) & ~revokePermission;
}

bool HasPermission(Perm permission) {
    return (permission & gPolicyRestrictions) == permission;
}

bool CanAccessDisk() {
    return HasPermission(Perm::DiskAccess);
}

// TODO: could add a setting
bool AnnotationsAreDisabled() {
    if (!CanAccessDisk()) {
        // annotations must be saved back to a file so lack of disk access
        // implies no ability to edit annotations
        return true;
    }
    return false;
}

// lets the shell open a URI for any supported scheme in
// the appropriate application (web browser, mail client, etc.)
bool SumatraLaunchBrowser(const char* url) {
    if (gPluginMode) {
        // pass the URI back to the browser
        ReportIf(gWindows.empty());
        if (gWindows.empty()) {
            return false;
        }
        HWND plugin = gWindows.at(0)->hwndFrame;
        HWND parent = GetAncestor(plugin, GA_PARENT);
        int urlLen = str::Leni(url);
        if (!parent || !url || (urlLen > 4096)) {
            return false;
        }
        COPYDATASTRUCT cds = {0x4C5255 /* URL */, (DWORD)urlLen + 1, (char*)url};
        return SendMessageW(parent, WM_COPYDATA, (WPARAM)plugin, (LPARAM)&cds);
    }

    if (!CanAccessDisk()) {
        return false;
    }

    // check if this URL's protocol is allowed
    AutoFreeStr protocol;
    if (!str::Parse(url, "%S:", &protocol)) {
        return false;
    }
    str::ToLowerInPlace(protocol);
    if (!gAllowedLinkProtocols.Contains(protocol)) {
        return false;
    }

    return LaunchFileShell(url, nullptr, "open");
}

bool DocIsSupportedFileType(Kind kind) {
    if (EpubDoc::IsSupportedFileType(kind)) {
        return true;
    }
    if (Fb2Doc::IsSupportedFileType(kind)) {
        return true;
    }
    if (MobiDoc::IsSupportedFileType(kind)) {
        return true;
    }
    if (PalmDoc::IsSupportedFileType(kind)) {
        return true;
    }
    return false;
}

// lets the shell open a file of any supported perceived type
// in the default application for opening such files
bool OpenFileExternally(const char* path) {
    if (!CanAccessDisk() || gPluginMode) {
        return false;
    }

    // check if this file's perceived type is allowed
    char* ext = path::GetExtTemp(path);
    char* perceivedType = ReadRegStrTemp(HKEY_CLASSES_ROOT, ext, "PerceivedType");
    // since we allow following hyperlinks, also allow opening local webpages
    if (str::EndsWithI(path, ".htm") || str::EndsWithI(path, ".html") || str::EndsWithI(path, ".xhtml")) {
        perceivedType = str::DupTemp("webpage");
    }
    str::ToLowerInPlace(perceivedType);
    if (gAllowedFileTypes.Contains("*")) {
        /* allow all file types (not recommended) */;
    } else if (!perceivedType || !gAllowedFileTypes.Contains(perceivedType)) {
        return false;
    }

    // TODO: only do this for trusted files (cf. IsUntrustedFile)?
    return LaunchFileShell(path);
}

void SwitchToDisplayMode(MainWindow* win, DisplayMode displayMode, bool keepContinuous) {
    if (!win->IsDocLoaded()) {
        return;
    }

    win->ctrl->SetDisplayMode(displayMode, keepContinuous);
    UpdateToolbarState(win);
}

static WindowTab* FindTabByController(DocController* ctrl) {
    for (MainWindow* win : gWindows) {
        for (WindowTab* tab : win->Tabs()) {
            if (tab->ctrl == ctrl) {
                return tab;
            }
        }
    }
    return nullptr;
}

WindowTab* FindTabByFile(const char* file) {
    char* normFile = path::NormalizeTemp(file);

    for (MainWindow* win : gWindows) {
        for (WindowTab* tab : win->Tabs()) {
            const char* fp = tab->filePath;
            if (!fp || !path::IsSame(fp, normFile)) {
                continue;
            }
            return tab;
        }
    }
    return nullptr;
}

static void DeferredLoadTocTree(MainWindow* win);
static bool RefreshSidebarDpiFonts(MainWindow* win);
static bool DefaultShowTocForPath(const char* path);

struct EbookPagesProgressTask {
    char* path = nullptr;
    bool reloadToc = false;
};

// True only when tab is the selected tab and win->ctrl matches (same as CtrlMatchesCurrentTab).
static bool TabIsForegroundForUi(WindowTab* tab) {
    if (!tab || !tab->win || !tab->ctrl) {
        return false;
    }
    MainWindow* win = tab->win;
    WindowTab* curr = win->CurrentTab();
    return curr == tab && win->ctrl == tab->ctrl;
}

static DWORD gLastEbookProgressRepaintMs = 0;
static const DWORD kEbookProgressRepaintIntervalMs = 200;
static const DWORD kEbookProgressToolbarIntervalMs = 800;

static EbookPagesProgressTask* gPendingEbookProgress = nullptr;
static bool gEbookProgressScheduled = false;

static void EbookPagesProgressUI(EbookPagesProgressTask* task);

static void PostPendingEbookProgress() {
    if (!gPendingEbookProgress) {
        return;
    }
    EbookPagesProgressTask* next = gPendingEbookProgress;
    gPendingEbookProgress = nullptr;
    gEbookProgressScheduled = true;
    uitask::Post(MkFunc0<EbookPagesProgressTask>(EbookPagesProgressUI, next), "EbookPagesProgress");
}

static void EbookPagesProgressUI(EbookPagesProgressTask* task) {
    gEbookProgressScheduled = false;
    defer {
        PostPendingEbookProgress();
    };
    if (!task || !task->path) {
        delete task;
        return;
    }
    bool reloadToc = task->reloadToc;
    AutoFreeStr path(task->path);
    task->path = nullptr;
    delete task;
    WindowTab* tab = FindTabByFile(path);
    if (!tab || !IsWindowTabValid(tab) || !tab->win || !tab->ctrl || !IsMainWindowValid(tab->win) ||
        tab->win->isBeingClosed) {
        return;
    }
    if (tab->win->GetTabIdx(tab) < 0) {
        return;
    }
    DisplayModel* dm = tab->ctrl->AsFixed();
    if (!dm || !dm->pagesInfo) {
        return;
    }
    bool isForeground = TabIsForegroundForUi(tab);
    dm->OnMorePagesAvailable(isForeground);
    if (!isForeground) {
        return;
    }
    dm->TryApplyPendingRestoreScroll();
    MainWindow* win = tab->win;
    EngineBase* engine = dm->GetEngine();
    if (engine && win->linkHandler) {
        EngineMupdfTryCompletePendingReflowNav(engine, win->linkHandler);
    }
    bool progressiveLoad = EngineIsProgressiveEbookLoading(engine);
    static DWORD gLastEbookProgressToolbarMs = 0;
    DWORD now = GetTickCount();
    if (!progressiveLoad || gLastEbookProgressToolbarMs == 0 ||
        now - gLastEbookProgressToolbarMs >= kEbookProgressToolbarIntervalMs) {
        gLastEbookProgressToolbarMs = now;
        int pageCount = engine ? engine->PageCount() : dm->PageCount();
        UpdateToolbarPageText(win, pageCount);
        if (!progressiveLoad) {
            ToolbarUpdateStateForWindow(win, false);
        }
    }
    // reloadToc fires when background chapter counting finishes (InvalidateTocTree);
    // it must not wait for layout batches, otherwise TOC never loads because
    // reloadToc is only true on the first notify while layout is still batched.
    if (reloadToc && !EngineIsProgressiveEbookLoading(engine) && isForeground) {
        int pageCount = engine ? engine->PageCount() : dm->PageCount();
        UpdateToolbarPageText(win, pageCount);
        bool wantToc = tab->showToc || DefaultShowTocForPath(path);
        if (wantToc && win->ctrl && win->ctrl->HasToc()) {
            if (!win->tocVisible) {
                tab->showToc = true;
                SetSidebarVisibility(win, true, gGlobalPrefs->showFavorites);
            } else if (!tab->showToc) {
                tab->showToc = true;
            }
            if (win->tocLoaded) {
                ClearTocBox(win);
            }
            auto fn = MkFunc0<MainWindow>(DeferredLoadTocTree, win);
            uitask::Post(fn, "DeferredLoadToc");
        } else if (win->tocVisible) {
            InvalidateTocTree(win);
        }
    }
    if (isForeground && EngineIsProgressiveEbookLoading(engine)) {
        if (gLastEbookProgressRepaintMs == 0 || now - gLastEbookProgressRepaintMs >= kEbookProgressRepaintIntervalMs) {
            gLastEbookProgressRepaintMs = now;
            ScheduleRepaint(win, 0);
            InvalidateTocTree(win);
        }
    }
    if (isForeground && engine) {
        OnEbookPageCountChanged(win);
    }
    if (!progressiveLoad && IsFindUIVisible(win)) {
        RefreshFindSearchBlockedStatus(win);
    }
}

void NotifyEbookPagesLoadingProgress(const char* filePath, bool reloadToc) {
    if (!filePath) {
        return;
    }

    if (gPendingEbookProgress) {
        gPendingEbookProgress->reloadToc |= reloadToc;
        return;
    }
    if (gEbookProgressScheduled) {
        gPendingEbookProgress = new EbookPagesProgressTask;
        gPendingEbookProgress->path = str::Dup(filePath);
        gPendingEbookProgress->reloadToc = reloadToc;
        return;
    }

    EbookPagesProgressTask* task = new EbookPagesProgressTask;
    task->path = str::Dup(filePath);
    task->reloadToc = reloadToc;
    gEbookProgressScheduled = true;
    auto fn = MkFunc0<EbookPagesProgressTask>(EbookPagesProgressUI, task);
    uitask::Post(fn, "EbookPagesProgress");
}

// ok for tab to be null
void SelectTabInWindow(WindowTab* tab) {
    if (!tab || !tab->win) {
        return;
    }
    auto win = tab->win;
    if (tab == win->CurrentTab()) {
        return;
    }
    TabsSelect(win, win->GetTabIdx(tab));
}

// Find the first window showing a given PDF file
MainWindow* FindMainWindowByFile(const char* file, bool focusTab) {
    WindowTab* tab = nullptr;
    if (!file) {
        return nullptr;
    }
    if (gMostRecentlyOpenedDoc != nullptr) {
        tab = FindTabByController(gMostRecentlyOpenedDoc);
        if (tab) {
            auto lastPath = gMostRecentlyOpenedDoc->GetFilePath();
            if (!path::IsSame(lastPath, file)) {
                tab = nullptr;
            }
        } else {
            gMostRecentlyOpenedDoc = nullptr;
        }
    }
    if (!tab) {
        tab = FindTabByFile(file);
    }
    if (!tab) {
        return nullptr;
    }
    if (focusTab) {
        SelectTabInWindow(tab);
    }
    return tab->win;
}

void SetUserOpenActivateExisting(LoadArgs& args) {
    args.activateExisting = !IsCtrlPressed();
}

// Find the first window that has been produced from <file>
MainWindow* FindMainWindowBySyncFile(const char* path, bool focusTab) {
    for (MainWindow* win : gWindows) {
        Vec<Rect> rects;
        int page;
        auto dm = win->AsFixed();
        if (dm && dm->pdfSync && dm->pdfSync->SourceToDoc(path, 0, 0, &page, rects) != PDFSYNCERR_UNKNOWN_SOURCEFILE) {
            return win;
        }
        bool bringFore = focusTab && win->TabCount() > 1;
        if (!bringFore) {
            continue;
        }
        // bring a background tab to the foreground
        for (WindowTab* tab : win->Tabs()) {
            if (tab != win->CurrentTab() && tab->AsFixed() && tab->AsFixed()->pdfSync &&
                tab->AsFixed()->pdfSync->SourceToDoc(path, 0, 0, &page, rects) != PDFSYNCERR_UNKNOWN_SOURCEFILE) {
                TabsSelect(win, win->GetTabIdx(tab));
                return win;
            }
        }
    }
    return nullptr;
}

bool gShowPassword = false;

class HwndPasswordUI : public PasswordUI {
    HWND hwnd;
    size_t pwdIdx;

  public:
    explicit HwndPasswordUI(HWND hwnd) : hwnd(hwnd), pwdIdx(0) {}

    char* GetPassword(const char* fileName, u8* fileDigest, u8 decryptionKeyOut[32], bool* saveKey) override;
};

/* Get password for a given 'fileName', can be nullptr if user cancelled the
   dialog box or if the encryption key has been filled in instead.
   Caller needs to free() the result. */
char* HwndPasswordUI::GetPassword(const char* path, u8* fileDigest, u8 decryptionKeyOut[32], bool* saveKey) {
    FileState* fileFromHistory = gFileHistory.FindByName(path, nullptr);
    if (fileFromHistory && fileFromHistory->decryptionKey && fileDigest && decryptionKeyOut) {
        AutoFreeStr fingerprint = str::MemToHex(fileDigest, 16);
        *saveKey = str::StartsWith(fileFromHistory->decryptionKey, fingerprint.Get());
        if (*saveKey && str::HexToMem(fileFromHistory->decryptionKey + 32, decryptionKeyOut, 32)) {
            return nullptr;
        }
    }

    *saveKey = false;

    // try the list of default passwords before asking the user
    if (pwdIdx < gGlobalPrefs->defaultPasswords->size()) {
        char* pwd = gGlobalPrefs->defaultPasswords->at(pwdIdx++);
        return str::Dup(pwd);
    }

    if (IsStressTesting()) {
        return nullptr;
    }

    // can't show a dialog (e.g. thumbnail generation thread)
    if (!hwnd) {
        return nullptr;
    }

    // extract the filename from the URL in plugin mode instead
    // of using the more confusing temporary filename
    if (gPluginMode) {
        TempStr urlName = url::GetFileNameTemp(gPluginURL);
        if (urlName) {
            path = urlName;
        }
    }
    path = path::GetBaseNameTemp(path);

    // check if the window is still valid as it might have been closed by now
    if (!IsWindow(hwnd)) {
        ReportIf(true);
        hwnd = GetForegroundWindow();
    }
    // make sure that the password dialog is visible
    HwndToForeground(hwnd);

    bool* rememberPwd = SettingsRememberOpenedFiles() ? saveKey : nullptr;
    return Dialog_GetPassword(hwnd, path, rememberPwd, &gShowPassword);
}

// update global windowState for next default launch when either
// no pdf is opened or a document without window dimension information
void RememberDefaultWindowPosition(MainWindow* win) {
    // ignore spurious WM_SIZE and WM_MOVE messages happening during initialization
    if (!IsWindowVisible(win->hwndFrame)) {
        return;
    }

    if (win->presentation) {
        gGlobalPrefs->windowState = win->windowStateBeforePresentation;
    } else if (win->isFullScreen) {
        gGlobalPrefs->windowState = WIN_STATE_FULLSCREEN;
    } else if (IsZoomed(win->hwndFrame)) {
        gGlobalPrefs->windowState = WIN_STATE_MAXIMIZED;
    } else if (!IsIconic(win->hwndFrame)) {
        gGlobalPrefs->windowState = WIN_STATE_NORMAL;
    }

    gGlobalPrefs->sidebarDx = WindowRect(win->hwndTocBox).dx;

    if (IsIconic(win->hwndFrame) || win->presentation) {
        return;
    }

    if (WIN_STATE_NORMAL == gGlobalPrefs->windowState) {
        gGlobalPrefs->windowPos = WindowRect(win->hwndFrame);
    } else if (WIN_STATE_MAXIMIZED == gGlobalPrefs->windowState) {
        // use GetWindowPlacement to get the non-maximized position
        // so we know which monitor the window is on (for #5277)
        WINDOWPLACEMENT wp{};
        wp.length = sizeof(wp);
        if (GetWindowPlacement(win->hwndFrame, &wp)) {
            gGlobalPrefs->windowPos = ToRect(wp.rcNormalPosition);
        }
    }
}

static void UpdateDisplayStateWindowRect(MainWindow* win, FileState* fs, bool updateGlobal = true) {
    if (updateGlobal) {
        RememberDefaultWindowPosition(win);
    }

    fs->windowState = gGlobalPrefs->windowState;
    fs->windowPos = gGlobalPrefs->windowPos;
    fs->sidebarDx = gGlobalPrefs->sidebarDx;
}

static void UpdateSidebarDisplayState(WindowTab* tab, FileState* fs) {
    ReportIf(!tab);
    MainWindow* win = tab->win;
    fs->showToc = tab->showToc;
    if (win->tocLoaded && tab == win->CurrentTab()) {
        TocTree* tocTree = tab->ctrl->GetToc();
        UpdateTocExpansionState(tab->tocState, win->tocTreeView, tocTree);
    }
    *fs->tocState = tab->tocState;
}

void UpdateTabFileDisplayStateForTab(WindowTab* tab) {
    if (!tab || !tab->ctrl) {
        return;
    }
    MainWindow* win = tab->win;
    // TODO: this is called multiple times for each tab
    RememberDefaultWindowPosition(win);
    const char* fp = tab->filePath;
    FileState* fs = gFileHistory.FindByName(fp, nullptr);
    if (!fs) {
        return;
    }
    tab->ctrl->GetDisplayState(fs);
    UpdateDisplayStateWindowRect(win, fs, false);
    UpdateSidebarDisplayState(tab, fs);
}

static bool gForceRtl = false;

bool IsUIRtl() {
    if (gForceRtl) {
        return true;
    }
    return trans::IsCurrLangRtl();
}

uint MbRtlReadingMaybe() {
    if (IsUIRtl()) {
        return MB_RTLREADING;
    }
    return 0;
}

void MessageBoxWarning(HWND hwnd, const char* msg, const char* title) {
    uint type = MB_OK | MB_ICONEXCLAMATION | MbRtlReadingMaybe();
    if (!title) {
        title = _TRA("Warning");
    }
    MsgBox(hwnd, msg, title, type);
}

static BOOL CALLBACK SetRtlCallback(HWND hwnd, LPARAM lParam) {
    HwndSetRtl(hwnd, (bool)lParam);
    return TRUE;
}

// updates the layout for a window to either left-to-right or right-to-left
// depending on the currently used language (see IsUIRtl)
static void UpdateWindowRtlLayout(MainWindow* win) {
    bool wasRTL = HwndIsRtl(win->hwndFrame);
    bool isRTL = IsUIRtl();
    if (wasRTL == isRTL) {
        return;
    }

    // https://www.microsoft.com/middleeast/msdn/mirror.aspx
    HwndSetRtl(win->hwndFrame, isRTL);
    EnumChildWindows(win->hwndFrame, SetRtlCallback, (LPARAM)isRTL);

    // https://github.com/sumatrapdfreader/sumatrapdf/issues/5326
    // Rtl reverses mouse positions on x-axis which messes up
    // identification of elements on page
    // I could 1. UnmirrorRtl() or 2. make canvas always non-rtl
    // for now chose 2
    HwndSetRtl(win->hwndCanvas, false);

    bool tocVisible = win->tocVisible;
    bool favVisible = gGlobalPrefs->showFavorites;
    if (tocVisible || favVisible) {
        SetSidebarVisibility(win, false, false);
    }

    if (win->tabsCtrl) win->tabsCtrl->LayoutTabs();

    SetWindowPos(win->hwndFrame, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE);
    RelayoutCaption(win);

    if (win->tocLabelWithClose) win->tocLabelWithClose->Layout();
    if (win->favLabelWithClose) win->favLabelWithClose->Layout();

    RelayoutNotifications(win->hwndCanvas);

    // TODO: also update the canvas scrollbars (?)

    // ensure that the ToC sidebar is on the correct side and that its
    // title and close button are also correctly laid out
    if (tocVisible || favVisible) {
        SetSidebarVisibility(win, tocVisible, favVisible);
        if (tocVisible) {
            SendMessageW(win->hwndTocBox, WM_SIZE, 0, 0);
        }
        if (favVisible) {
            SendMessageW(win->hwndFavBox, WM_SIZE, 0, 0);
        }
    }
    ReCreateToolbar(win);
    RelayoutWindow(win);
    win->RedrawAll(true);
}

static bool IsMenubarVisible() {
    if (SettingsUseTabs()) {
        return gGlobalPrefs->showMenubarWithTabs;
    }
    return gGlobalPrefs->showMenubar;
}

void RebuildMenuBarForWindow(MainWindow* win) {
    HMENU oldMenu = win->menu;
    win->menu = BuildMenu(win);
    if (!win->presentation && !win->isFullScreen && IsMenubarVisible()) {
        if (win->tabsInTitlebar) {
            // use rebar menu bar instead of native menu when tabs are in titlebar
            if (IsShowingMenuBarRebar(win)) {
                RebuildMenuBarButtons(win);
            }
        } else {
            SyncNativeMenuBar(win);
        }
    }
    FreeMenuOwnerDrawInfoData(oldMenu);
    DestroyMenu(oldMenu);
}

static bool ShouldSaveThumbnail(FileState* ds) {
    // don't create thumbnails if we won't be needing them at all
    if (!HasPermission(Perm::SavePreferences)) {
        return false;
    }

    // don't create thumbnails for files that won't need them anytime soon
    Vec<FileState*> list;
    if (gGlobalPrefs->homePageSortByFrequentlyRead) {
        gFileHistory.GetFrequencyOrder(list);
    } else {
        gFileHistory.GetRecentlyOpenedOrder(list);
    }
    int idx = list.Find(ds);
    if (idx < 0) {
        return false;
    }

    if (HasThumbnail(ds)) {
        return false;
    }
    return true;
}

struct ControllerCallbackHandler : DocControllerCallback {
    MainWindow* win{nullptr};

  public:
    explicit ControllerCallbackHandler(MainWindow* win) : win(win) {}
    ~ControllerCallbackHandler() override = default;

    void Repaint() override { ScheduleRepaint(win, 0); }
    void PageNoChanged(DocController* ctrl, int pageNo) override;
    void ZoomChanged(DocController* ctrl, float zoomVirtual) override;
    void UpdateScrollbars(Size canvas) override;
    void RequestRendering(int pageNo) override;
    void CleanUp(DisplayModel* dm) override;
    void RenderThumbnail(DisplayModel* dm, Size size, const OnBitmapRendered*) override;
    void GotoLink(IPageDestination* dest) override { win->linkHandler->GotoLink(dest); }
    void FocusFrame(bool always) override;
    void SaveDownload(const char* url, const ByteSlice&) override;
};

DocControllerCallback* CreateControllerCallbackHandler(MainWindow* win) {
    return new ControllerCallbackHandler(win);
}

struct ThumbnailRenderData {
    const OnBitmapRendered* saveThumbnail = nullptr;
};

static void ThumbnailRenderFinished(ThumbnailRenderData* d, PageRenderRequest* req) {
    // extract bitmap from request and pass to original callback
    // the callback takes ownership of the bitmap
    RenderedBitmap* bmp = req->bmp;
    req->bmp = nullptr; // prevent double-free
    d->saveThumbnail->Call(bmp);
    delete d->saveThumbnail;
    delete d;
}

static RectF GetThumbnailCropRect(EngineBase* engine, int pageNo, Size targetSize) {
    RectF pageRect = engine->PageMediabox(pageNo);
    if (pageRect.IsEmpty() || targetSize.dx <= 0 || targetSize.dy <= 0) {
        return pageRect;
    }

    pageRect = engine->Transform(pageRect, pageNo, 1.0f, 0);
    float targetAspect = (float)targetSize.dx / (float)targetSize.dy;
    float pageAspect = pageRect.dx / pageRect.dy;
    if (pageAspect > targetAspect) {
        float cropDx = pageRect.dy * targetAspect;
        pageRect.x += (pageRect.dx - cropDx) / 2.f;
        pageRect.dx = cropDx;
    } else {
        float cropDy = pageRect.dx / targetAspect;
        pageRect.y += (pageRect.dy - cropDy) / 2.f;
        pageRect.dy = cropDy;
    }
    return engine->Transform(pageRect, pageNo, 1.0f, 0, true);
}

void ControllerCallbackHandler::RenderThumbnail(DisplayModel* dm, Size size, const OnBitmapRendered* saveThumbnail) {
    auto engine = dm->GetEngine();
    RectF pageRect = GetThumbnailCropRect(engine, 1, size);
    if (pageRect.IsEmpty()) {
        // saveThumbnail must always be called for clean-up code
        saveThumbnail->Call(nullptr);
        return;
    }
    float zoom = size.dx / (float)pageRect.dx;

    // always render thumbnails with anti-aliasing for quality
    bool savedAntiAlias = engine->disableAntiAlias;
    engine->disableAntiAlias = false;

    auto* td = new ThumbnailRenderData();
    td->saveThumbnail = saveThumbnail;
    auto cb = MkFunc1(ThumbnailRenderFinished, td);
    gRenderCache->Render(dm, 1, 0, zoom, pageRect, cb);
    engine->disableAntiAlias = savedAntiAlias;
}

struct CreateThumbnailFromFileData {
    char* filePath = nullptr;
    RenderedBitmap* bmp = nullptr;
    ~CreateThumbnailFromFileData() { str::Free(filePath); }
};

static void CreateThumbnailFromFileFinish(CreateThumbnailFromFileData* d) {
    if (d->bmp) {
        FileState* fs = gFileHistory.FindByPath(d->filePath);
        SetThumbnail(fs, d->bmp);
    }
    delete d;
}

static void CreateThumbnailFromFileThread(CreateThumbnailFromFileData* d) {
    SetCreateEngineForThumbnail(true);
    defer {
        SetCreateEngineForThumbnail(false);
    };
    HwndPasswordUI pwdUI(nullptr);
    EngineBase* engine = CreateEngineFromFile(d->filePath, &pwdUI, true);
    if (!engine) {
        delete d;
        return;
    }
    RectF pageRect = GetThumbnailCropRect(engine, 1, Size{kThumbnailDx, kThumbnailDy});
    if (pageRect.IsEmpty()) {
        engine->Release();
        delete d;
        return;
    }
    float zoom = (float)kThumbnailDx / (float)pageRect.dx;
    RenderPageArgs args(1, zoom, 0, &pageRect);
    d->bmp = engine->RenderPage(args);
    engine->Release();
    auto fn = MkFunc0<CreateThumbnailFromFileData>(CreateThumbnailFromFileFinish, d);
    uitask::Post(fn, "SetThumbnailFromFile");
}

// create a thumbnail by loading the file with a temporary engine
// used for lazy-loaded files that don't have a loaded controller
static void CreateThumbnailFromFileAsync(FileState* ds) {
    auto* d = new CreateThumbnailFromFileData();
    d->filePath = str::Dup(ds->filePath);
    auto fn = MkFunc0<CreateThumbnailFromFileData>(CreateThumbnailFromFileThread, d);
    RunAsync(fn, "CreateThumbnailFromFile");
}

static void CreateThumbnailForFile(MainWindow* win, FileState* ds) {
    if (!ShouldSaveThumbnail(ds)) {
        return;
    }

    // don't create thumbnails for password protected documents
    // (unless we're also remembering the decryption key anyway)
    if (win->IsDocLoaded()) {
        auto* model = win->AsFixed();
        if (model) {
            auto* engine = model->GetEngine();
            bool withPwd = engine->IsPasswordProtected();
            const char* decrKey = engine->decryptionKey.s;
            if (withPwd && !decrKey) {
                RemoveThumbnail(ds);
                return;
            }
            // save decryption key to file history so the thumbnail thread can use it
            if (decrKey && !str::Eq(ds->decryptionKey, decrKey)) {
                free(ds->decryptionKey);
                ds->decryptionKey = str::Dup(decrKey);
            }
        }
    }

    // always use file-based async thumbnail creation; it's independent
    // of the tab lifecycle so it works even if the tab is closed before
    // the render completes
    CreateThumbnailFromFileAsync(ds);
}

/* Send the request to render a given page to a rendering thread */
void ControllerCallbackHandler::RequestRendering(int pageNo) {
    DisplayModel* dm = win->AsFixed();
    if (!dm) {
        return;
    }

    // don't render any plain images on the rendering thread,
    // they'll be rendered directly in DrawDocument during
    // WM_PAINT on the UI thread
    if (dm->ShouldCacheRendering(pageNo)) {
        gRenderCache->RequestRendering(dm, pageNo);
    }
}

void ControllerCallbackHandler::CleanUp(DisplayModel* dm) {
    gRenderCache->CancelRendering(dm);
    gRenderCache->WaitForRenderingComplete(dm);
    gRenderCache->FreeForDisplayModel(dm);
}

void ControllerCallbackHandler::FocusFrame(bool always) {
    if (always || !FindMainWindowByHwnd(GetFocus())) {
        HwndSetFocus(win->hwndFrame);
    }
}

void ControllerCallbackHandler::SaveDownload(const char* url, const ByteSlice& data) {
    TempStr path = url::GetFileNameTemp(url);
    // LinkSaver linkSaver(win->CurrentTab(), win->hwndFrame, fileName);
    SaveDataToFile(win->hwndFrame, path, data);
}

static void makeFullScrollbar(SCROLLINFO& si) {
    si.nPos = 0;
    si.nMin = 0;
    si.nMax = 99;
    si.nPage = 100;
}

SeqStrings gScrollbarModeNames = "windows\0smart\0overlay\0hidden\0";

int ScrollbarModeFromPrefs() {
    int idx = seqstrings::StrToIdxIS(gScrollbarModeNames, gGlobalPrefs->scrollbars);
    if (idx < 0) {
        idx = kScrollbarWindows;
    }
    return idx;
}

bool ScrollbarsAreHidden() {
    return ScrollbarModeFromPrefs() == kScrollbarHidden;
}

bool ScrollbarsUseOverlay() {
    int mode = ScrollbarModeFromPrefs();
    return mode == kScrollbarSmart || mode == kScrollbarOverlay;
}

OverlayScrollbar::Mode ScrollbarsOverlayMode() {
    if (ScrollbarModeFromPrefs() == kScrollbarOverlay) {
        return OverlayScrollbar::Mode::Thick;
    }
    return OverlayScrollbar::Mode::Smart;
}

void ControllerCallbackHandler::UpdateScrollbars(Size canvas) {
    DisplayModel* dm = win->AsFixed();
    if (!dm) {
        return;
    }

    bool hideScrollbar = ScrollbarsAreHidden();
    bool useOverlay = ScrollbarsUseOverlay();
    SCROLLINFO si{};
    si.cbSize = sizeof(si);
    si.fMask = SIF_ALL;

    Size viewPort = dm->GetViewPort().Size();

    if (viewPort.dx >= canvas.dx) {
        makeFullScrollbar(si);
    } else {
        si.nPos = dm->GetViewPort().x;
        si.nMin = 0;
        si.nMax = canvas.dx - 1;
        si.nPage = viewPort.dx;
    }

    bool showHScroll = (viewPort.dx < canvas.dx) && !hideScrollbar;
    if (useOverlay) {
        SetScrollInfo(win->hwndCanvas, SB_HORZ, &si, FALSE);
        if (!win->overlayScrollH) {
            win->overlayScrollH =
                OverlayScrollbarCreate(win->hwndCanvas, OverlayScrollbar::Type::Horz, ScrollbarsOverlayMode());
        }
        if (showHScroll) {
            OverlayScrollbarShow(win->overlayScrollH, true);
            OverlayScrollbarSetInfo(win->overlayScrollH, &si, TRUE);
        } else {
            OverlayScrollbarShow(win->overlayScrollH, false);
        }
    } else {
        ShowScrollBar(win->hwndCanvas, SB_HORZ, showHScroll);
        SetScrollInfo(win->hwndCanvas, SB_HORZ, &si, TRUE);
    }

    bool isSinglePageMode = gGlobalPrefs->scrollbarInSinglePage && (dm->GetDisplayMode() == DisplayMode::SinglePage);
    bool showVScroll = true;
    if (isSinglePageMode) {
        int pageCount = dm->PageCount();
        int currentPage = dm->CurrentPageNo();
        si.nPos = currentPage - 1; // 0-based position
        si.nMin = 0;
        si.nMax = pageCount - 1; // 0-based max
        si.nPage = 1;            // One page visible at a time
    } else {
        if (viewPort.dy >= canvas.dy) {
            makeFullScrollbar(si);
        } else {
            si.nPos = dm->GetViewPort().y;
            si.nMin = 0;
            si.nMax = canvas.dy - 1;
            si.nPage = viewPort.dy;

            if (kZoomFitPage != dm->GetZoomVirtual()) {
                // keep the top/bottom 5% of the previous page visible after paging down/up
                si.nPage = (uint)(si.nPage * 0.95);
                si.nMax -= viewPort.dy - si.nPage;
            }
        }
        showVScroll = (viewPort.dy < canvas.dy);
    }
    bool showScrollbar = !hideScrollbar;
    BOOL showWinScrollbar = showScrollbar && !useOverlay;
    BOOL showOverScrollbar = showScrollbar && useOverlay;

    if (useOverlay || hideScrollbar) {
        SetScrollInfo(win->hwndCanvas, SB_VERT, &si, FALSE);
    } else {
        ShowScrollBar(win->hwndCanvas, SB_VERT, showWinScrollbar);
        SetScrollInfo(win->hwndCanvas, SB_VERT, &si, showWinScrollbar);
    }

    if (useOverlay) {
        if (!win->overlayScrollV) {
            win->overlayScrollV =
                OverlayScrollbarCreate(win->hwndCanvas, OverlayScrollbar::Type::Vert, ScrollbarsOverlayMode());
        }
        if (showVScroll && showScrollbar) {
            OverlayScrollbarShow(win->overlayScrollV, true);
            OverlayScrollbarSetInfo(win->overlayScrollV, &si, TRUE);
        } else {
            OverlayScrollbarShow(win->overlayScrollV, false);
        }
    }
}

static TempStr BuildZoomString(float zoomLevel) {
    TempStr zoomLevelStr = ZoomLevelStr(zoomLevel);
    const char* zoomStr = _TRA("Zoom");
    return str::FormatTemp("%s: %s", zoomStr, zoomLevelStr);
}

static void UpdatePageInfoHelper(DocController* ctrl, NotificationWnd* wnd, int pageNo) {
    if (!ctrl->ValidPageNo(pageNo)) {
        pageNo = ctrl->CurrentPageNo();
    }
    int nPages = ctrl->PageCount();
    TempStr pageInfo = str::FormatTemp("%s %d / %d", _TRA("Page:"), pageNo, nPages);
    if (ctrl->HasPageLabels()) {
        TempStr label = ctrl->GetPageLabeTemp(pageNo);
        pageInfo = str::FormatTemp("%s %s (%d / %d)", _TRA("Page:"), label, pageNo, nPages);
    }
    float zoomLevel = ctrl->GetZoomVirtual();
    auto zoomStr = BuildZoomString(zoomLevel);
    pageInfo = str::JoinTemp(pageInfo, " ", zoomStr);
    NotificationUpdateMessage(wnd, pageInfo);
}

static void TogglePageInfoHelper(MainWindow* win) {
    if (!win || !win->IsDocLoaded()) {
        return;
    }
    NotificationWnd* wnd = GetNotificationForGroup(win->hwndCanvas, kNotifPageInfo);
    if (wnd) {
        RemoveNotificationsForGroup(win->hwndCanvas, kNotifPageInfo);
        return;
    }
    NotificationCreateArgs args;
    args.hwndParent = win->hwndCanvas;
    args.timeoutMs = 0;
    args.msg = "";
    args.groupId = kNotifPageInfo;
    wnd = ShowNotification(args);
    UpdatePageInfoHelper(win->ctrl, wnd, -1);
}

void ControllerCallbackHandler::ZoomChanged(DocController* ctrl, float zoomVirtual) {
    // discard change requests from documents
    // loaded asynchronously in a background tab
    if (win->ctrl != ctrl) {
        return;
    }
    NotificationWnd* wnd = GetNotificationForGroup(win->hwndCanvas, kNotifPageInfo);
    if (!wnd) {
        return;
    }
    UpdatePageInfoHelper(win->ctrl, wnd, win->currPageNo);
}

// The current page edit box is updated with the current page number
void ControllerCallbackHandler::PageNoChanged(DocController* ctrl, int pageNo) {
    // discard page number change requests from documents
    // loaded asynchronously in a background tab
    if (win->ctrl != ctrl) {
        return;
    }

    ReportIf(!win->ctrl || win->ctrl->PageCount() <= 0);
    if (!win->ctrl || win->ctrl->PageCount() == 0) {
        return;
    }

    if (kInvalidPageNo != pageNo) {
        DisplayModel* dm = win->ctrl->AsFixed();
        EngineBase* engine = dm ? dm->GetEngine() : nullptr;
        if (engine && pageNo > engine->PageCount()) {
            pageNo = engine->PageCount();
        }
        TempStr label = win->ctrl->GetPageLabeTemp(pageNo);
        HwndSetText(win->hwndPageEdit, label);
        ToolbarUpdateStateForWindow(win, false);
        int totalPages = engine ? engine->PageCount() : win->ctrl->PageCount();
        UpdateToolbarPageText(win, totalPages, win->ctrl->HasPageLabels());
    }
    if (pageNo == win->currPageNo) {
        return;
    }

    UpdateTocSelection(win, pageNo);
    win->currPageNo = pageNo;

    NotificationWnd* wnd = GetNotificationForGroup(win->hwndCanvas, kNotifPageInfo);
    if (!wnd) {
        return;
    }
    UpdatePageInfoHelper(win->ctrl, wnd, pageNo);
}

// TODO: remove when we figure out why this ctrl->GetFilePath() is not always same as path
static NO_INLINE void VerifyController(DocController* ctrl, const char* path) {
    if (!ctrl) {
        return;
    }
    const char* ctrlFilePath = ctrl->GetFilePath();
    if (str::Eq(ctrlFilePath, path)) {
        return;
    }
    ReportIf(true);
}

static DocController* CreateControllerForChm(const char* path, PasswordUI* pwdUI, MainWindow* win) {
    Kind kind = GuessFileType(path, true);

    bool isChm = ChmModel::IsSupportedFileType(kind);
    if (!isChm) {
        return nullptr;
    }
    ChmModel* chmModel = ChmModel::Create(path, win->cbHandler);
    if (!chmModel) {
        return nullptr;
    }
    // make sure that MSHTML can't be used as a potential exploit
    // vector through another browser and our plugin (which doesn't
    // advertise itself for Chm documents but could be tricked into
    // loading one nonetheless); note: this crash should never happen,
    // since gGlobalPrefs->chmUI.useFixedPageUI is set in SetupPluginMode
    ReportIf(gPluginMode);
    // if CLSID_WebBrowser isn't available, fall back on ChmEngine
    DocController* ctrl = nullptr;
    if (!chmModel->SetParentHwnd(win->hwndCanvas)) {
        delete chmModel;
        EngineBase* engine = CreateEngineFromFile(path, pwdUI, true);
        if (!engine) {
            return nullptr;
        }
        ReportIf(engine->kind != kindEngineChm);
        ctrl = new DisplayModel(engine, win->cbHandler);
        ReportIf(!ctrl || !ctrl->AsFixed() || ctrl->AsChm());
    } else {
        // another ChmModel might still be active
        chmModel->RemoveParentHwnd();
        ctrl = chmModel;
        ReportIf(!ctrl->AsChm() || ctrl->AsFixed());
    }
    ReportIf(!ctrl);
    VerifyController(ctrl, path);
    return ctrl;
}

// this allows us to target the right file when processing
// a sequence of DDE commands. Without this commands target
// the tab by path and if there's more than one with the same
// path, we pick the first one
// https://github.com/sumatrapdfreader/sumatrapdf/issues/3903
DocController* gMostRecentlyOpenedDoc = nullptr;

DocController* CreateControllerForEngineOrFile(EngineBase* engine, const char* path, PasswordUI* pwdUI,
                                               MainWindow* win) {
    auto timeStart = TimeGet();
    bool chmInFixedUI = gGlobalPrefs->chmUI.useFixedPageUI;
    // TODO: sniff file content only once
    if (!engine) {
        engine = CreateEngineFromFile(path, pwdUI, chmInFixedUI);
    }
    if (!engine) {
        // as a last resort, try to open as chm file
        auto ctrl = CreateControllerForChm(path, pwdUI, win);
        gMostRecentlyOpenedDoc = ctrl;
        return ctrl;
    }
    int nPages = engine ? engine->pageCount : 0;
    auto dur = TimeSinceInMs(timeStart);
    logf("CreateControllerForEngineOrFile: '%s', %d pages, took %2.f ms\n", path, nPages, dur);
    if (nPages <= 0) {
        // seen nPages < 0 in a crash in epub file
        SafeEngineRelease(&engine);
        return nullptr;
    }
    DocController* ctrl = new DisplayModel(engine, win->cbHandler);
    ReportIf(!ctrl || !ctrl->AsFixed() || ctrl->AsChm());
    VerifyController(ctrl, path);
    gMostRecentlyOpenedDoc = ctrl;
    return ctrl;
}

static void SetFrameTitleForTab(WindowTab* tab, bool needRefresh) {
    if (!tab || !IsWindowTabValid(tab)) {
        return;
    }
    const char* titlePath = tab->filePath;
    if (!gGlobalPrefs->fullPathInTitle) {
        titlePath = path::GetBaseNameTemp(titlePath);
    }

    TempStr docTitle = (TempStr) "";
    if (tab->ctrl) {
        TempStr title = tab->ctrl->GetPropertyTemp(kPropTitle);
        if (title != nullptr) {
            str::NormalizeWSInPlace(title);
            docTitle = str::DupTemp(title);
            if (!str::IsEmpty(title)) {
                docTitle = str::FormatTemp("- [%s] ", title);
            }
        }
    }

    TempStr s = nullptr;
    if (!IsUIRtl()) {
        s = str::FormatTemp("%s %s- %s", titlePath, docTitle, kSumatraWindowTitle);
    } else {
        // explicitly revert the title, so that filenames aren't garbled
        s = str::FormatTemp("%s %s- %s", kSumatraWindowTitle, docTitle, titlePath);
    }
    if (needRefresh && tab->ctrl) {
        // TODO: this isn't visible when tabs are used
        s = str::FormatTemp(_TRA("[Changes detected; refreshing] %s"), tab->frameTitle);
    }
    str::ReplaceWithCopy(&tab->frameTitle, s);
}

static void UpdateUiForCurrentTab(MainWindow* win) {
    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return;
    }
    // hide the scrollbars before any other relayouting (for assertion in MainWindow::GetViewPortSize)
    if (!win->AsFixed()) {
        if (!ScrollbarsAreHidden() && !ScrollbarsUseOverlay()) {
            ShowScrollBar(win->hwndCanvas, SB_BOTH, FALSE);
        }
        OverlayScrollbarShow(win->overlayScrollV, false);
        OverlayScrollbarShow(win->overlayScrollH, false);
    }

    // menu for chm and ebook docs is different, so we have to re-create it
    RebuildMenuBarForWindow(win);
    // the toolbar isn't supported for ebook docs (yet)
    ShowOrHideToolbar(win);
    // TODO: unify?
    ToolbarUpdateStateForWindow(win, true);
    UpdateToolbarState(win);

    int pageCount = win->ctrl ? win->ctrl->PageCount() : 0;
    UpdateToolbarPageText(win, pageCount);
    UpdateToolbarFindText(win);

    UpdateFindbox(win);

    WindowTab* tab = win->CurrentTab();
    const char* title = (tab && tab->frameTitle) ? tab->frameTitle : kSumatraWindowTitle;
    HwndSetText(win->hwndFrame, title);

    bool onlyNumbers = !win->ctrl || !win->ctrl->HasPageLabels();
    SetWindowStyle(win->hwndPageEdit, ES_NUMBER, onlyNumbers);
}

static bool showTocByDefault(const char* path) {
    if (!gGlobalPrefs->showToc) {
        return false;
    }
    // we don't want to show toc by default for comic book files
    Kind kind = GuessFileTypeFromName(path);
    bool showByDefault = !IsEngineCbxSupportedFileType(kind);
    return showByDefault;
}

static bool DefaultShowTocForPath(const char* path) {
    if (!path) {
        return false;
    }
    bool showToc = showTocByDefault(path);
    if (gGlobalPrefs->rememberStatePerDocument) {
        FileState* fs = gFileHistory.FindByPath(path);
        if (fs) {
            showToc = fs->showToc;
        }
    }
    return showToc;
}

static bool WinCtrlIsOwnedByAnyTab(MainWindow* win, DocController* ctrl) {
    if (!ctrl || !win) {
        return false;
    }
    for (auto& t : win->Tabs()) {
        if (t->ctrl == ctrl) {
            return true;
        }
    }
    return false;
}

static WindowTab* TabForWinCtrl(MainWindow* win, DocController* ctrl) {
    if (!ctrl || !win) {
        return nullptr;
    }
    for (auto& t : win->Tabs()) {
        if (t->ctrl == ctrl) {
            return t;
        }
    }
    return nullptr;
}

static void DetachWinCtrlIfMatches(MainWindow* win, DocController* ctrl) {
    if (win && ctrl && win->ctrl == ctrl) {
        win->ctrl = nullptr;
    }
}

static void SafeDeleteDocController(MainWindow* win, DocController*& ctrl) {
    if (!ctrl) {
        return;
    }
    DetachWinCtrlIfMatches(win, ctrl);
    for (auto& t : win->Tabs()) {
        if (t->ctrl == ctrl) {
            t->ctrl = nullptr;
        }
    }
    if (gMostRecentlyOpenedDoc == ctrl) {
        gMostRecentlyOpenedDoc = nullptr;
    }
    delete ctrl;
    ctrl = nullptr;
}

// Document is represented as DocController. Replace current DocController (if any) with ctrl
// in current tab.
// meaning of the internal values of LoadArgs:
// isNewWindow : if true then 'win' refers to a newly created window that needs
//   to be resized and placed
// placeWindow : if true then the Window will be moved/sized according
//   to the 'state' information even if the window was already placed
//   before (isNewWindow=false)

static void ReplaceDocumentInCurrentTab(LoadArgs* args, DocController* ctrl, FileState* fs) {
    MainWindow* win = args->win;
    ReportIf(!win);
    if (!win) {
        return;
    }
    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return;
    }
    WindowTab* tab = win->CurrentTab();
    if (!tab || !IsWindowTabValid(tab)) {
        SafeDeleteDocController(win, ctrl);
        return;
    }

    // Never load settings from a preexisting state if the user doesn't wish to
    // (unless we're just refreshing the document, i.e. only if state && !state->useDefaultState)
    if (!fs && gGlobalPrefs->rememberStatePerDocument) {
        const char* fn = args->FilePath();
        fs = gFileHistory.FindByPath(fn);
        if (fs) {
            if (fs->windowPos.IsEmpty()) {
                fs->windowPos = gGlobalPrefs->windowPos;
            }
            EnsureAreaVisibility(fs->windowPos);
        }
    }
    if (fs && fs->useDefaultState) {
        fs = nullptr;
    }

    DisplayMode displayMode = gGlobalPrefs->defaultDisplayModeEnum;
    float zoomVirtual = gGlobalPrefs->defaultZoomFloat;
    ScrollState ss(1, -1, -1);
    int rotation = 0;
    const char* path = args->FilePath();
    bool showToc = DefaultShowTocForPath(path);
    bool showAsFullScreen = WIN_STATE_FULLSCREEN == gGlobalPrefs->windowState;
    int showType = SW_NORMAL;
    if (gGlobalPrefs->windowState == WIN_STATE_MAXIMIZED || showAsFullScreen) {
        showType = SW_MAXIMIZE;
    }

    if (fs) {
        ss.page = fs->pageNo;
        displayMode = DisplayModeFromString(fs->displayMode, DisplayMode::Automatic);
        showAsFullScreen = WIN_STATE_FULLSCREEN == fs->windowState;
        if (fs->windowState == WIN_STATE_NORMAL) {
            showType = SW_NORMAL;
        } else if (fs->windowState == WIN_STATE_MAXIMIZED || showAsFullScreen) {
            showType = SW_MAXIMIZE;
        } else if (fs->windowState == WIN_STATE_MINIMIZED) {
            showType = SW_MINIMIZE;
        }
        if (win->presentation) {
            showToc = tab->showTocPresentation;
        } else if (win->ctrl && str::Eq(path, win->ctrl->GetFilePath())) {
            // Preserve per-tab sidebar visibility across reloads (e.g. theme change).
            showToc = tab->showToc;
        } else {
            showToc = fs->showToc;
        }
        ParsedColor* bgParsed = GetPrefsColor(fs->bgCol);
        if (bgParsed->parsedOk) {
            tab->bgColor = bgParsed->col;
            tab->bgColorCheckered = (bgParsed->col == kColorUnset);
        }
        ParsedColor* tabColParsed = GetPrefsColor(fs->tabCol);
        if (tabColParsed->parsedOk) {
            tab->tabColor = tabColParsed->col;
        }
    }

    AbortFinding(args->win, true);

    DocController* prevCtrl = win->ctrl;
    tab->ctrl = ctrl;
    win->ctrl = tab->ctrl;

    EngineBase* engine = tab->GetEngine();
    if (engine) {
        engine->hideAnnotations = tab->hideAnnotations;
        float imageZoom = gGlobalPrefs->imageUI.defaultZoomFloat;
        if (engine->kind == kindEngineImage && imageZoom != 0) {
            zoomVirtual = imageZoom;
        }
    }

    // ToC items might hold a reference to an Engine, so make sure to
    // delete them before destroying the whole DisplayModel
    // (same for linkOnLastButtonDown)
    ClearTocBox(win);
    ClearMouseState(win);

    // TODO: this crashes with new tabs
    // ReportIf(win->IsAboutWindow() || win->IsDocLoaded() != (win->ctrl != nullptr));
    // TODO: https://code.google.com/p/sumatrapdf/issues/detail?id=1570
    if (win->ctrl) {
        DisplayModel* dm = win->AsFixed();
        if (dm) {
            int dpi = gGlobalPrefs->customScreenDPI;
            if (dpi == 0) {
                dpi = DpiGetForHwnd(win->hwndFrame);
            }
            // clamp saved page only after all pages are known; during progressive
            // reflow loading DisplayModel defers restore until pages are available
            if (win->ctrl->PageCount() > 0 && !EngineIsProgressiveEbookLoading(engine)) {
                int maxPage = win->ctrl->PageCount();
                if (ss.page > maxPage) {
                    ss.page = maxPage;
                    ss.x = -1;
                    ss.y = -1;
                }
            }
            dm->SetInitialViewSettings(displayMode, ss.page, win->GetViewPortSize(), dpi);
            // TODO: also expose Manga Mode for image folders?
            if (tab->GetEngineType() == kindEngineComicBooks || tab->GetEngineType() == kindEngineImageDir) {
                dm->SetDisplayR2L(fs ? fs->displayR2L : gGlobalPrefs->comicBookUI.cbxMangaMode);
            }
            if (prevCtrl && prevCtrl->AsFixed() && str::Eq(win->ctrl->GetFilePath(), prevCtrl->GetFilePath())) {
                gRenderCache->KeepForDisplayModel(prevCtrl->AsFixed(), dm);
                dm->CopyNavHistory(*prevCtrl->AsFixed());
            }
            // tell UI Automation about content change
            if (win->uiaProvider) {
                win->uiaProvider->OnDocumentUnload();
                win->uiaProvider->OnDocumentLoad(dm);
            }
        } else if (win->AsChm()) {
            win->AsChm()->SetParentHwnd(win->hwndCanvas);
            win->ctrl->SetDisplayMode(displayMode);
            ss.page = limitValue(ss.page, 1, win->ctrl->PageCount());
            win->ctrl->GoToPage(ss.page, false);
        } else {
            ReportIf(true);
        }
    } else {
        fs = nullptr;
    }

    if (fs) {
        ReportIf(!win->IsDocLoaded());
        zoomVirtual = ZoomFromString(fs->zoom, kZoomFitPage);
        if (win->ctrl->ValidPageNo(ss.page)) {
            if (kZoomFitContent != zoomVirtual) {
                ss.x = fs->scrollPos.x;
                ss.y = fs->scrollPos.y;
            }
            // else let win->AsFixed()->Relayout() scroll to fit the page (again)
        } else if (win->ctrl->PageCount() > 0 && !EngineIsProgressiveEbookLoading(tab->GetEngine())) {
            ss.page = limitValue(ss.page, 1, win->ctrl->PageCount());
        }
        // else let win->ctrl->GoToPage(ss.page, false) verify the page number
        rotation = fs->rotation;
        tab->tocState = *fs->tocState;
    }

    // Relayout before tearing down prevCtrl so WM_PAINT during document swap
    // never calls SetViewPortSize with an invalid zoom.
    if (win->AsFixed()) {
        win->AsFixed()->Relayout(zoomVirtual, rotation);
    } else if (win && win->ctrl && win->IsDocLoaded()) {
        win->ctrl->SetZoomVirtual(zoomVirtual, nullptr);
    }

    SafeDeleteDocController(win, prevCtrl);

#if defined(ENABLE_REDRAW_ON_RELOAD)
    // TODO: why is this needed?
    if (!args->isNewWindow && win->IsDocLoaded()) {
        win->RedrawAll();
    }
#endif

    SetFrameTitleForTab(tab, false);
    UpdateUiForCurrentTab(win);

    if (CanAccessDisk() && tab->GetEngineType() == kindEngineMupdf) {
        ReportIf(!win->AsFixed() || win->AsFixed()->pdfSync);
        path = args->FilePath();
        int res = Synchronizer::Create(path, win->AsFixed()->GetEngine(), &win->AsFixed()->pdfSync);
        // expose SyncTeX in the UI
        if (PDFSYNCERR_SUCCESS == res) {
            gGlobalPrefs->enableTeXEnhancements = true;
        }
    }

    bool shouldPlace = args->isNewWindow || args->placeWindow && fs;
    if (args->noPlaceWindow) {
        shouldPlace = false;
    }
    if (shouldPlace) {
        if (args->isNewWindow && fs && !fs->windowPos.IsEmpty() && showType == SW_NORMAL) {
            // Make sure it doesn't have a position like outside of the screen etc.
            Rect rect = ShiftRectToWorkArea(fs->windowPos);
            // This shouldn't happen until !win.IsAboutWindow(), so that we don't
            // accidentally update gGlobalState with this window's dimensions
            MoveWindow(win->hwndFrame, rect);
        }
        if (args->showWin) {
            ShowWindow(win->hwndFrame, showType);
        }

#if 0
        // fix https://github.com/sumatrapdfreader/sumatrapdf/issues/5456
        // bad initial layout with RememberOpenedFiles = false
        // it's redundant with LayoutAndFocusOnStartup()

        // Fire deferred SWP_FRAMECHANGED for custom caption so the
        // non-client area is recalculated and the client rect is correct.
        // ShowMainWindow normally does this, but this code path bypasses it.
        if (win->tabsInTitlebar) {
            uint swpFlags = SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE;
            SetWindowPos(win->hwndFrame, nullptr, 0, 0, 0, 0, swpFlags);
        }
#endif

        if (win) {
            UpdateWindow(win->hwndFrame);
        }
        if (args->isNewWindow && win) {
            HwndEnsureVisible(win->hwndFrame);
        }
    }

    // if the window isn't shown and win.canvasRc is still empty, zoom
    // has not been determined yet
    // cf. https://code.google.com/p/sumatrapdf/issues/detail?id=2541
    // ReportIf(win->IsDocLoaded() && args->showWin && win->canvasRc.IsEmpty() && !win->AsChm());

    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return;
    }
    if (win->IsDocLoaded() && win->ctrl && !win->ctrl->HasToc()) {
        showToc = false;
    }
    SetSidebarVisibility(win, showToc, gGlobalPrefs->showFavorites);
    // restore scroll state after the canvas size has been restored
    if ((args->showWin || ss.page != 1) && win->AsFixed()) {
        win->AsFixed()->SetScrollState(ss);
    }

    TabsOnChangedDoc(win);

    if (!win->IsDocLoaded()) {
        win->RedrawAll(true);
        return;
    }

    TempStr unsupported = win->ctrl->GetPropertyTemp(kPropUnsupportedFeatures);
    if (unsupported) {
        const char* s = _TRA("This document uses unsupported features (%s) and might not render properly");
        TempStr msg = str::FormatTemp(s, unsupported);
        NotificationCreateArgs nargs;
        nargs.hwndParent = win->hwndCanvas;
        nargs.warning = true;
        nargs.timeoutMs = 0;
        nargs.groupId = kNotifPersistentWarning;
        nargs.msg = msg;
        ShowNotification(nargs);
    }

    // This should only happen after everything else is ready
    if ((args->isNewWindow || args->placeWindow) && args->showWin && showAsFullScreen) {
        EnterFullScreen(win);
    } else {
        win->RedrawAll(false);
    }
    if (!args->isNewWindow && win->presentation && win->ctrl) {
        win->ctrl->SetInPresentation(true);
    }
}

void ReloadDocument(MainWindow* win, bool autoRefresh) {
    WindowTab* tab = win->CurrentTab();

    if (!tab) {
        return;
    }
    // TODO: maybe should ensure it never is called for IsAboutTab() ?
    // This only happens if gLazyLoading is true
    if (tab->IsAboutTab()) {
        return;
    }

    tab->selectedAnnotation = nullptr;
    tab->selectedEbookAnnotation = nullptr;
    win->annotationBeingDragged = nullptr;
    win->ebookAnnotationBeingDragged = nullptr;
    win->ebookAnnotationDragPending = nullptr;
    win->annotationBeingResized = false;
    win->annotationUnderCursor = nullptr;
    tab->ignoreNextAutoReload = false;

    if (!tab->IsDocLoaded()) {
        if (!autoRefresh) {
            if (str::IsEmpty(tab->filePath)) {
                logf("ReloadDocument: tab->filePath is empty, can't reload\n");
                return;
            }
            LoadArgs args(tab->filePath, win);
            args.forceReuse = true;
            args.noSavePrefs = true;
            args.tabState = tab->tabState;
            LoadDocument(&args);
        }
        return;
    }

    HwndPasswordUI pwdUI(win->hwndFrame);
    const char* path = tab->filePath;
    if (str::IsEmpty(path)) {
        logf("ReloadDocument: tab->filePath is empty, auto refresh: %d\n", (int)autoRefresh);
        return;
    }
    logfa("ReloadDocument: %s, auto refresh: %d\n", path, (int)autoRefresh);

    // Save display state before potentially destroying the old controller
    FileState* fs = NewFileState(path);
    tab->ctrl->GetDisplayState(fs);
    UpdateDisplayStateWindowRect(win, fs);
    UpdateSidebarDisplayState(tab, fs);

    // DjVu: libdjvulibre caches file info by path, so we must close the old
    // engine before opening the new one to avoid stale cached data (issue #1298)
    bool isDjVu = str::EndsWithI(path, ".djvu") || str::EndsWithI(path, ".djv");
    if (isDjVu) {
        delete tab->ctrl;
        tab->ctrl = nullptr;
        win->ctrl = nullptr;
    }

    DocController* ctrl = CreateControllerForEngineOrFile(nullptr, path, &pwdUI, win);
    // We don't allow PDF-repair if it is an autorefresh because
    // a refresh event can occur before the file is finished being written,
    // in which case the repair could fail. Instead, if the file is broken,
    // we postpone the reload until the next autorefresh event
    if (!ctrl && autoRefresh) {
        SetFrameTitleForTab(tab, true);
        HwndSetText(win->hwndFrame, tab->frameTitle);
        DeleteFileState(fs);
        return;
    }
    // Set the windows state based on the actual window's placement
    int wstate = WIN_STATE_NORMAL;
    if (win->isFullScreen) {
        wstate = WIN_STATE_FULLSCREEN;
    } else {
        if (IsZoomed(win->hwndFrame)) {
            wstate = WIN_STATE_MAXIMIZED;
        } else if (IsIconic(win->hwndFrame)) {
            wstate = WIN_STATE_MINIMIZED;
        }
    }
    fs->windowState = wstate;
    fs->useDefaultState = false;

    LoadArgs args(tab->filePath, win);
    args.showWin = true;
    args.placeWindow = false;
    ReplaceDocumentInCurrentTab(&args, ctrl, fs);

    if (!ctrl) {
        DeleteFileState(fs);
        return;
    }

    // after reload, refresh the annotations list in the edit window
    // so that it stays in sync with the new engine
    UpdateAnnotationsList(tab->editAnnotsWindow);

    tab->reloadOnFocus = false;

    if (gGlobalPrefs->showStartPage) {
        // refresh the thumbnail for this file
        FileState* state = gFileHistory.FindByPath(fs->filePath);
        if (state) {
            CreateThumbnailForFile(win, state);
        }
    }

    if (tab->AsFixed()) {
        // save a newly remembered password into file history so that
        // we don't ask again at the next refresh
        const char* decryptionKey = tab->AsFixed()->GetEngine()->decryptionKey.s;
        if (decryptionKey) {
            FileState* fs2 = gFileHistory.FindByName(fs->filePath, nullptr);
            if (fs2 && !str::Eq(fs2->decryptionKey, decryptionKey)) {
                free(fs2->decryptionKey);
                fs2->decryptionKey = str::Dup(decryptionKey);
            }
        }
    }

    DeleteFileState(fs);
}

static void CreateSidebar(MainWindow* win) {
    {
        Splitter::CreateArgs args;
        args.parent = win->hwndFrame;
        args.type = SplitterType::Vert;
        win->sidebarSplitter = new Splitter();
        win->sidebarSplitter->onMove = MkFunc1Void(OnSidebarSplitterMove);
        win->sidebarSplitter->Create(args);
    }

    CreateToc(win);

    {
        Splitter::CreateArgs args;
        args.parent = win->hwndFrame;
        args.type = SplitterType::Horiz;
        win->favSplitter = new Splitter();
        win->favSplitter->onMove = MkFunc1Void(OnFavSplitterMove);
        win->favSplitter->Create(args);
    }

    CreateFavorites(win);

    if (win->tocVisible) {
        HwndRepaintNow(win->hwndTocBox);
    }

    if (gGlobalPrefs->showFavorites) {
        HwndRepaintNow(win->hwndFavBox);
    }
}

static void UpdateToolbarSidebarText(MainWindow* win) {
    UpdateToolbarPageText(win, -1);
    UpdateToolbarFindText(win);
    UpdateToolbarButtonsToolTipsForWindow(win);

    win->tocLabelWithClose->SetLabel(_TRA("Bookmarks"));
    win->tocLabelWithClose->UpdateHeaderActionTooltips();
    win->favLabelWithClose->SetLabel(_TRA("Favorites"));
}

static void UpdateWindowFrameBorderColor(MainWindow* win);
static void OnMainWindowDpiChanged(MainWindow* win, HWND hwnd, const RECT* suggestedRect, int explicitDpi = 0,
                                   bool force = false);

static MainWindow* CreateMainWindow() {
    Rect windowPos = gGlobalPrefs->windowPos;
    if (!windowPos.IsEmpty()) {
        EnsureAreaVisibility(windowPos);
    } else {
        windowPos = GetDefaultWindowPos();
    }
    // we don't want the windows to overlap so shift each window by a bit
    int nShift = (int)gWindows.size();
    windowPos.x += (nShift * 15); // TODO: DPI scale

    const WCHAR* clsName = FRAME_CLASS_NAME;
    const WCHAR* title = ToWStrTemp(kAppName);
    DWORD style = WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN;
    int x = windowPos.x;
    int y = windowPos.y;
    int dx = windowPos.dx;
    int dy = windowPos.dy;
    HINSTANCE h = GetModuleHandle(nullptr);
    HWND hwndFrame =
        CreateWindowExW(WS_EX_APPWINDOW, clsName, title, style, x, y, dx, dy, nullptr, nullptr, h, nullptr);
    if (!hwndFrame) {
        return nullptr;
    }

    // WM_NCCALCSIZE returning 0 disables DWM rounded corners; re-enable them.
    dwm::SetWindowRoundedCorners(hwndFrame, true);

    ReportIf(nullptr != FindMainWindowByHwnd(hwndFrame));
    MainWindow* win = new MainWindow(hwndFrame);
    UpdateWindowFrameBorderColor(win);

    // don't add a WS_EX_STATICEDGE so that the scrollbars touch the
    // screen's edge when maximized (cf. Fitts' law) and there are
    // no additional adjustments needed when (un)maximizing
    clsName = CANVAS_CLASS_NAME;
    style = WS_CHILD | WS_CLIPCHILDREN;
    if (!ScrollbarsAreHidden() && !ScrollbarsUseOverlay()) {
        style |= WS_HSCROLL | WS_VSCROLL;
    }
    /* position and size determined in OnSize */
    Rect rcFrame = ClientRect(hwndFrame);
    win->hwndCanvas =
        CreateWindowExW(0, clsName, nullptr, style, 0, 0, rcFrame.dx, rcFrame.dy, hwndFrame, nullptr, h, nullptr);
    if (!win->hwndCanvas) {
        delete win;
        return nullptr;
    }

    if (gShowFrameRate) {
        win->frameRateWnd = new FrameRateWnd();
        win->frameRateWnd->Create(win->hwndCanvas);
    }

    // hide scrollbars to avoid showing/hiding on empty window
    if (!ScrollbarsAreHidden() && !ScrollbarsUseOverlay()) {
        ShowScrollBar(win->hwndCanvas, SB_BOTH, FALSE);
    }

    ReportIf(win->menu);
    win->menu = BuildMenu(win);
    // menu bar is shown later, after SetTabsInTitlebar decides the mode:
    // if tabsInTitlebar, we use a rebar menu bar; otherwise native SetMenu
    win->brControlBgColor = CreateSolidBrush(ThemeChromeBackgroundColor());

    // suppress painting during window setup to avoid white flash with dark themes
    SendMessageW(win->hwndFrame, WM_SETREDRAW, FALSE, 0);

    ShowWindow(win->hwndCanvas, SW_SHOW);
    UpdateWindow(win->hwndCanvas);

    Tooltip::CreateArgs args;
    args.parent = win->hwndCanvas;
    args.font = GetAppFontForHwnd(win->hwndFrame);
    args.isRtl = IsUIRtl();

    win->infotip = new Tooltip();
    win->infotip->Create(args);

    CreateTabbar(win);
    CreateToolbar(win);
    CreateSidebar(win);
    UpdateFindbox(win);
    if (CanAccessDisk() && !gPluginMode) {
        RegisterCanvasDropTarget(win->hwndCanvas);
    }

    if (gWindows.IsEmpty() && !NeedsWindowEmbeddingHacks()) {
        RegisterScreenshotHotkey(win->hwndFrame);
    }
    gWindows.Append(win);
    ShowMaybeDelayedNotifications(win->hwndCanvas);
    // needed for RTL languages
    UpdateWindowRtlLayout(win);
    UpdateToolbarSidebarText(win);

    if (touch::SupportsGestures()) {
        GESTURECONFIG gc = {0, GC_ALLGESTURES, 0};
        touch::SetGestureConfig(win->hwndCanvas, 0, 1, &gc, sizeof(GESTURECONFIG));
    }

    // Set tabsInTitlebar state without SWP_FRAMECHANGED; the frame change
    // is deferred to ShowMainWindow so the shell sees a normal frame during
    // the first ShowWindow and creates the taskbar button.
    {
        bool inTitleBar = SettingsUseTabs();
        win->tabsInTitlebar = inTitleBar;
        win->tabsCtrl->inTitleBar = inTitleBar;
        if (inTitleBar) {
            RelayoutCaption(win);
        }
    }

    // now show the menu bar in the appropriate style
    if (IsMenubarVisible() && !NeedsWindowEmbeddingHacks()) {
        if (win->tabsInTitlebar) {
            CreateMenuBarRebar(win);
        } else {
            SyncNativeMenuBar(win);
        }
    }

    UpdateMainWindowNativeChrome(win);

    // TODO: this is hackish. in general we should divorce
    // layout re-calculations from MainWindow and creation of windows
    win->UpdateCanvasSize();
    if (UseDarkModeLib() && ThemeUsesDarkChrome()) {
        DarkMode::setDarkTitleBarEx(win->hwndFrame, true);
        DarkMode::setChildCtrlsSubclassAndTheme(win->hwndFrame);
        DarkMode::removeTabCtrlSubclass(win->tabsCtrl->hwnd);
        SyncCanvasScrollBarTheme(win);
        DarkMode::setWindowMenuBarSubclass(win->hwndFrame);
        // TODO: this over-rides the font in the control
        // this will only happen with themes
        // could custom paint instead of using DarkMode
        // DarkMode::setDarkTooltips(win->infotip->hwnd, (int)DarkMode::ToolTipsType::tooltip);
        UpdateControlsColors(win);
    }

    // re-enable painting now that dark mode is configured
    SendMessageW(win->hwndFrame, WM_SETREDRAW, TRUE, 0);

    // show menu bar rebar now that layout is done
    ShowMenuBarRebar(win);

    return win;
}

void ShowMainWindow(MainWindow* win, int windowState) {
    // Suppress an intermediate paint with stale find-control positions (issue #5456).
    SendMessageW(win->hwndFrame, WM_SETREDRAW, FALSE, 0);

    if (WIN_STATE_FULLSCREEN == windowState || WIN_STATE_MAXIMIZED == windowState) {
        ShowWindow(win->hwndFrame, SW_MAXIMIZE);
    } else {
        ShowWindow(win->hwndFrame, SW_SHOW);
    }

    // Fire the deferred SWP_FRAMECHANGED for custom caption (tabsInTitlebar).
    // Must happen after ShowWindow so the shell sees a visible window and
    // creates the taskbar button before we remove the standard frame.
    if (win->tabsInTitlebar) {
        uint flags = SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE;
        SetWindowPos(win->hwndFrame, nullptr, 0, 0, 0, 0, flags);
    }

    // Hidden startup windows can miss the final titlebar/menu-bar geometry
    // until they become visible. Force one relayout before the first paint.
    RelayoutFrame(win);
    ToolbarUpdateStateForWindow(win, true);

    SendMessageW(win->hwndFrame, WM_SETREDRAW, TRUE, 0);
    RedrawWindow(win->hwndFrame, nullptr, nullptr,
                 RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME | RDW_UPDATENOW);
    HwndEnsureVisible(win->hwndFrame);

    // Process any WM_DPICHANGED posted by the first ShowWindow before we
    // apply our own DPI refresh (GetDpiForWindow can still be stale).
    MSG msg;
    while (PeekMessageW(&msg, win->hwndFrame, WM_DPICHANGED, WM_DPICHANGED, PM_REMOVE | PM_NOYIELD)) {
        DispatchMessageW(&msg);
    }

    OnMainWindowDpiChanged(win, win->hwndFrame, nullptr, 0, true);

    if (gWindows.Size() == 1 && (true || IsDebuggerPresent())) {
        HwndToForeground(win->hwndFrame);
    }

    if (win->tabsInTitlebar) {
        RECT r = ToRECT(win->captionRect);
        InvalidateRect(win->hwndFrame, &r, TRUE);
        RedrawWindow(win->hwndFrame, &r, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_UPDATENOW | RDW_FRAME);
        if (win->hwndMenuReBar && IsWindowVisible(win->hwndMenuReBar)) {
            RedrawWindow(win->hwndMenuReBar, nullptr, nullptr,
                         RDW_ERASE | RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
        }
        if (win->tabsCtrl && win->tabsCtrl->IsVisible()) {
            RedrawWindow(win->tabsCtrl->hwnd, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_UPDATENOW);
        }
    }

    if (WIN_STATE_FULLSCREEN == windowState) {
        EnterFullScreen(win);
    }
}

MainWindow* CreateAndShowMainWindow(SessionData* data, bool showWin) {
    int windowState = gGlobalPrefs->windowState;
    MainWindow* win = CreateMainWindow();
    if (!win) {
        return nullptr;
    }
    // CreateMainWindow can inadvertently change windowState (e.g. via layout); restore it
    gGlobalPrefs->windowState = windowState;

    if (data) {
        windowState = data->windowState;
        Rect rect = ShiftRectToWorkArea(data->windowPos);
        MoveWindow(win->hwndFrame, rect);
        // TODO: also restore data->sidebarDx
    }

    // always set up toolbar and sidebar, even if we defer showing
    ShowOrHideToolbar(win);
    SetSidebarVisibility(win, false, gGlobalPrefs->showFavorites);
    ToolbarUpdateStateForWindow(win, true);

    if (SettingsUseTabs() && !gGlobalPrefs->noHomeTab && win->TabCount() == 0) {
        EnsureHomeTabForWindow(win);
        TabsSelect(win, 0);
    }

    if (showWin) {
        ShowMainWindow(win, windowState);
    }
    return win;
}

void DeleteMainWindow(MainWindow* win) {
    int winIdx = gWindows.Remove(win);

    int nWindowsLeft = gWindows.Size();
    logf("DeleteMainWindow: win: 0x%p, hwndFrame: 0x%p, hwndCanvas: 0x%p, winIdx : %d, nWindowsLeft: %d\n", win,
         win->hwndFrame, win->hwndCanvas, winIdx, nWindowsLeft);
    if (winIdx < 0) {
        logf("  not deleting because not in gWindows, probably already deleted\n");
        return;
    }

    DeletePropertiesWindow(win->hwndFrame);
    ImageList_Destroy((HIMAGELIST)SendMessageW(win->hwndToolbar, TB_GETIMAGELIST, 0, 0));
    RevokeCanvasDropTarget(win->hwndCanvas);

    ReportIf(win->findThread && WaitForSingleObject(win->findThread, 0) == WAIT_TIMEOUT);
    ReportIf(win->printThread && WaitForSingleObject(win->printThread, 0) == WAIT_TIMEOUT);

    if (win->uiaProvider) {
        // tell UIA to release all objects cached in its store
        UiaReturnRawElementProvider(win->hwndCanvas, 0, 0, nullptr);
    }

    delete win;
}

static COLORREF DwmFrameBorderColorForCurrentTheme() {
    if (ThemeUsesDarkChrome() || ThemeUsesEyeCareChrome()) {
        return ThemeChromeBackgroundColor();
    }
    return (COLORREF)DWMWA_COLOR_DEFAULT;
}

static void SyncNativeMenuBar(MainWindow* win) {
    if (!win || !win->menu || win->presentation || win->isFullScreen || win->tabsInTitlebar) {
        return;
    }
    if (!IsMenubarVisible()) {
        SetMenu(win->hwndFrame, nullptr);
        DestroyMenuBarRebar(win);
        return;
    }
    if (ThemeUsesEyeCareChrome()) {
        SetMenu(win->hwndFrame, nullptr);
        if (IsShowingMenuBarRebar(win)) {
            RebuildMenuBarButtons(win);
        } else {
            CreateMenuBarRebar(win);
        }
    } else {
        DestroyMenuBarRebar(win);
        SetMenu(win->hwndFrame, win->menu);
    }
}

static void UpdateMainWindowNativeChrome(MainWindow* win) {
    if (!win || win->isFullScreen || win->presentation || win->tabsInTitlebar) {
        return;
    }
    if (ThemeUsesEyeCareChrome()) {
        dwm::SetWindowCaptionColors(win->hwndFrame, ThemeChromeBackgroundColor(), ThemeWindowTextColor());
        SyncNativeMenuBar(win);
    } else if (ThemeUsesDarkChrome()) {
        if (UseDarkModeLib()) {
            DarkMode::setDarkTitleBarEx(win->hwndFrame, true);
        }
        SyncNativeMenuBar(win);
    } else {
        dwm::ResetWindowCaptionColors(win->hwndFrame);
        SyncNativeMenuBar(win);
    }
    UpdateWindowFrameBorderColor(win);
    RelayoutFrame(win);
    ShowMenuBarRebar(win);
}

static void UpdateWindowFrameBorderColor(MainWindow* win) {
    if (!win || !win->hwndFrame) {
        return;
    }
    // maximized windows can't be edge-resized; hiding the DWM border avoids a bright
    // 1px seam above the taskbar (especially visible on dark themes).
    COLORREF borderCol = DwmFrameBorderColorForCurrentTheme();
    if (IsZoomed(win->hwndFrame) || win->isFullScreen || win->presentation) {
        borderCol = (COLORREF)DWMWA_COLOR_NONE;
    } else {
        // COLOR_NONE (used while maximized) can leave bottom corners square after restore.
        dwm::SetWindowCornerPreference(win->hwndFrame, true);
    }
    dwm::SetWindowBorderColor(win->hwndFrame, borderCol);
}

void SyncMainWindowForNoTabsInTitlebar(MainWindow* win) {
    if (!win || win->tabsInTitlebar || win->isFullScreen || win->presentation) {
        return;
    }
    win->captionRect = {};
    for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
        win->captionBtn[i].visible = false;
    }
    win->lastLayoutState = {};

    BeginFrameRedrawSuppression(win);
    uint swpFlags = SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE;
    // Restore the standard non-client title bar before repositioning child windows.
    // Without this, ClientRect still reflects the custom-caption layout and the menu
    // bar ends up where the toolbar used to be.
    SetWindowPos(win->hwndFrame, nullptr, 0, 0, 0, 0, swpFlags);

    DestroyMenuBarRebar(win);
    if (UseDarkModeLib()) {
        if (ThemeUsesDarkChrome()) {
            DarkMode::setDarkTitleBarEx(win->hwndFrame, true);
        } else {
            DarkMode::setDarkTitleBarEx(win->hwndFrame, false);
            DarkMode::removeWindowMenuBarSubclass(win->hwndFrame);
        }
        DarkMode::setChildCtrlsTheme(win->hwndFrame);
    }
    dwm::SetWindowRoundedCorners(win->hwndFrame, true);
    UpdateMainWindowNativeChrome(win);
    UpdateTabWidth(win);
    ShowOrHideToolbar(win);

    if (ThemeUsesEyeCareChrome()) {
        dwm::SetWindowCaptionColors(win->hwndFrame, ThemeChromeBackgroundColor(), ThemeWindowTextColor());
    } else if (!ThemeUsesDarkChrome()) {
        dwm::ResetWindowCaptionColors(win->hwndFrame);
    }
    UpdateWindowFrameBorderColor(win);

    SetWindowPos(win->hwndFrame, nullptr, 0, 0, 0, 0, swpFlags);
    EndFrameRedrawSuppression(win);
}

void SyncMenuBarForTabsInTitlebar(MainWindow* win) {
    if (!win || !win->tabsInTitlebar || win->isFullScreen || win->presentation) {
        return;
    }
    SetMenu(win->hwndFrame, nullptr);
    DestroyMenuBarRebar(win);
    if (IsMenubarVisible()) {
        CreateMenuBarRebar(win);
    }
    if (ThemeUsesEyeCareChrome()) {
        dwm::ResetWindowCaptionColors(win->hwndFrame);
    }
    UpdateWindowFrameBorderColor(win);
    RelayoutCaption(win);
    RelayoutFrame(win);
    ShowMenuBarRebar(win);
}

void InvalidateLoadedThumbnails();

static void RerenderEverything();

static bool IsReflowableEbookEngine(EngineBase* engine) {
    if (!engine) {
        return false;
    }
    if (engine->kind == kindEngineMupdf) {
        return !str::EqI(engine->defaultExt, ".pdf");
    }
    return engine->kind == kindEngineMobi || engine->kind == kindEngineEpub || engine->kind == kindEngineFb2 ||
           engine->kind == kindEnginePdb || engine->kind == kindEngineHtml || engine->kind == kindEngineTxt;
}

static bool ShouldReloadForThemeChange(WindowTab* tab) {
    if (!tab || !tab->IsDocLoaded() || tab->IsAboutTab()) {
        return false;
    }
    return IsReflowableEbookEngine(tab->GetEngine());
}

static bool IsReflowableMupdfForTheme(EngineBase* engine) {
    return engine && engine->kind == kindEngineMupdf && !str::EqI(engine->defaultExt, ".pdf");
}

static void ReloadTocUiAfterReflowReparse(MainWindow* win, WindowTab* tab, bool forceReload = false) {
    EngineBase* engine = tab ? tab->GetEngine() : nullptr;
    bool engineRequestedReload = EngineMupdfReflowTocNeedsUiReload(engine);
    if (!engineRequestedReload && !forceReload) {
        return;
    }
    if (!win || tab != win->CurrentTab() || !win->tocVisible) {
        return;
    }
    if (engineRequestedReload) {
        EngineMupdfClearReflowTocNeedsUiReload(engine);
    }
    if (win->tocLoaded) {
        ClearTocBox(win);
    }
    if (tab->GetEngine() && tab->GetEngine()->HasToc()) {
        LoadTocTree(win);
        if (win->tocLoaded && win->ctrl) {
            UpdateTocSelection(win, win->ctrl->CurrentPageNo());
        }
    }
}

// Theme toggles only user CSS colors; page breaks stay the same. Re-layout only
// the visible area: synchronizing every page in a large continuous EPUB makes a
// color switch appear to hang while it needlessly walks the whole book.
static void RefreshDisplayModelAfterThemeChange(DisplayModel* dm, bool updateUi) {
    if (!dm || !dm->pagesInfo) {
        return;
    }
    if (!updateUi) {
        return;
    }
    dm->RecalcVisibleParts();
    dm->RenderVisibleParts();
    if (dm->cb) {
        dm->cb->UpdateScrollbars(dm->canvasSize);
    }
}

static void ApplyThemeChangeToTab(MainWindow* win, WindowTab* tab) {
    if (!win || !tab || !tab->IsDocLoaded() || tab->IsAboutTab()) {
        return;
    }
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        ReloadDocument(win, false);
        return;
    }

    EngineBase* engine = tab->GetEngine();
    // Pause background reflow loading so this operation isn't starved of docLock
    // (no-op unless a reflowable EPUB is still progressively loading).
    ReflowLoadingPauseScope reflowPause(engine);
    if (IsReflowableMupdfForTheme(engine)) {
        // Reflow reparsing can discard the engine-owned TocTree. Detach the UI
        // first: RelayoutFrame synchronizes TOC selection and must never walk
        // the old TreeModel after EngineMupdfRelayoutForThemeChange frees it.
        bool reloadVisibleToc = tab == win->CurrentTab() && win->tocVisible && win->tocLoaded;
        if (reloadVisibleToc) {
            ClearTocBox(win);
        }
        gRenderCache->CancelRendering(dm);
        gRenderCache->KeepForColorTransition(dm);
        if (!EngineMupdfRelayoutForThemeChange(engine)) {
            logfa("ApplyThemeChangeToTab: in-place reflow theme CSS update failed\n");
        }
        RefreshDisplayModelAfterThemeChange(dm, tab == win->CurrentTab());
        RemapAnchorsAfterReflow(tab, win);
        ReloadTocUiAfterReflowReparse(win, tab, reloadVisibleToc);
        tab->reloadOnFocus = false;
        return;
    }

    if (IsReflowableEbookEngine(engine)) {
        // MOBI and other ebook engines bake theme colors at RenderPage time.
        // Drop stale tiles so switching back does not show old-theme content
        // under a frame painted with the new theme.
        gRenderCache->CancelRendering(dm);
        gRenderCache->FreeForDisplayModel(dm);
        EbookAnnotationsInvalidateLayoutCaches(tab);
        RefreshDisplayModelAfterThemeChange(dm, tab == win->CurrentTab());
        RefreshTextSelectionAfterLayoutChange(tab, win);
        ResyncReadAloudAfterLayoutChange(tab, win);
        if (tab != win->CurrentTab()) {
            tab->reloadOnFocus = true;
        }
        return;
    }

    reflowPause.Release();
    ReloadDocument(win, false);
}

static void SyncCanvasScrollBarTheme(MainWindow* win) {
    if (!win || !win->hwndCanvas) {
        return;
    }
    if (UseDarkModeLib()) {
        if (ThemeUsesDarkChrome()) {
            DarkMode::setDarkScrollBar(win->hwndCanvas);
        } else {
            SetWindowTheme(win->hwndCanvas, nullptr, nullptr);
            SendMessageW(win->hwndCanvas, WM_THEMECHANGED, 0, 0);
            RedrawWindow(win->hwndCanvas, nullptr, nullptr, RDW_FRAME | RDW_INVALIDATE | RDW_UPDATENOW);
        }
    }
    if (win->overlayScrollV) {
        InvalidateRect(win->overlayScrollV->hwnd, nullptr, TRUE);
    }
    if (win->overlayScrollH) {
        InvalidateRect(win->overlayScrollH->hwnd, nullptr, TRUE);
    }
}

void UpdateAfterThemeChange() {
    InvalidateLoadedThumbnails();
    // Reflowable documents are updated below. Doing it here as well reparses
    // synthesized Markdown/TXT HTML twice and briefly exposes stale links.
    UpdateDocumentColors(false, false);

    // Apply EPUB/CSS updates before chrome work so page rerender can start ASAP.
    for (auto win : gWindows) {
        WindowTab* currentTab = win->CurrentTab();
        for (WindowTab* tab : win->Tabs()) {
            ChmModel* chm = tab->AsChm();
            if (chm && chm->UsesNativeHtmlWindow() && tab->IsDocLoaded() && !tab->IsAboutTab()) {
                if (tab == currentTab) {
                    chm->ReloadCurrentPageForThemeChange();
                } else {
                    tab->reloadOnFocus = true;
                }
                continue;
            }
            if (!ShouldReloadForThemeChange(tab)) {
                continue;
            }
            // In-place EPUB CSS update is cheap; apply to every loaded tab so
            // switching back does not paint stale tiles from the old theme.
            ApplyThemeChangeToTab(win, tab);
        }
    }
    RerenderEverything();
    InvalidateFindMatchPaintCache();

    for (auto win : gWindows) {
        DeleteObject(win->brControlBgColor);
        win->brControlBgColor = CreateSolidBrush(ThemeChromeBackgroundColor());

        RebuildMenuBarForWindow(win);
        // TODO: probably leaking toolbar image list
        UpdateToolbarAfterThemeChange(win);
        // Rebar border style changes with dark/light chrome and alters toolbar
        // height; RelayoutFrame must run so the canvas moves with the toolbar.
        win->lastLayoutState = {};
        RelayoutFrame(win);
        win->UpdateCanvasSize();
        DisplayModel* dm = win->AsFixed();
        if (dm && !EngineIsProgressiveEbookLoading(dm->GetEngine())) {
            dm->OnMorePagesAvailablePreservingScroll(true, true);
        } else if (dm) {
            dm->RecalcVisibleParts();
            if (dm->cb) {
                dm->cb->UpdateScrollbars(dm->canvasSize);
            }
        }
        RecreateFindBar(win);
        UpdateFindWindowTheme(win);
        if (UseDarkModeLib()) {
            if (ThemeUsesDarkChrome()) {
                DarkMode::setDarkTitleBarEx(win->hwndFrame, true);
                DarkMode::setChildCtrlsTheme(win->hwndFrame);
                DarkMode::setWindowMenuBarSubclass(win->hwndFrame);
                // DarkMode::setDarkTooltips(win->infotip->hwnd, (int)DarkMode::ToolTipsType::tooltip);
            } else {
                DarkMode::setDarkTitleBarEx(win->hwndFrame, false);
                DarkMode::setChildCtrlsTheme(win->hwndFrame);
                DarkMode::removeWindowMenuBarSubclass(win->hwndFrame);
                if (!win->tabsInTitlebar) {
                    dwm::ResetWindowCaptionColors(win->hwndFrame);
                }
            }
            SyncCanvasScrollBarTheme(win);
        }
        UpdateMainWindowNativeChrome(win);
        UpdateControlsColors(win);
        UpdateWindowFrameBorderColor(win);
        WindowTab* currentTab = win->CurrentTab();
        if (currentTab) {
            ResyncReadAloudAfterLayoutChange(currentTab, win);
        }
        for (WindowTab* tab : win->Tabs()) {
            if (tab != currentTab) {
                ScheduleReadAloudResyncAfterLayoutChange(win, tab);
            }
        }
        uint flags = RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN;
        RedrawWindow(win->hwndFrame, nullptr, nullptr, flags);
    }
    EnumThreadWindows(
        GetCurrentThreadId(),
        [](HWND hwnd, LPARAM) -> BOOL {
            if (GetWindowLongW(hwnd, GWL_STYLE) & WS_CAPTION) {
                UpdateWindowCaptionTheme(hwnd);
            }
            return TRUE;
        },
        0);
    RefreshWordLookupTheme();
    RefreshEditAnnotationsWindowsTheme();
    RefreshEbookAnnotationsWindowsTheme();
}

static void RenameFileInHistory(const char* oldPath, const char* newPath) {
    logf("RenameFileInHistory: oldPath: '%s', newPath: '%s'\n", oldPath, newPath);
    if (path::IsSame(oldPath, newPath)) {
        return;
    }
    FileState* fs = gFileHistory.FindByPath(newPath);
    bool oldIsPinned = false;
    int oldOpenCount = 0;
    if (fs) {
        oldIsPinned = fs->isPinned;
        oldOpenCount = fs->openCount;
        gFileHistory.Remove(fs);
        // TODO: merge favorites as well?
        if (fs->favorites->size() > 0) {
            UpdateFavoritesTreeForAllWindows();
        }
        DeleteFileState(fs);
    }
    fs = gFileHistory.FindByName(oldPath, nullptr);
    if (fs) {
        SetFileStatePath(fs, newPath);
        // merge Frequently Read data, so that a file
        // doesn't accidentally vanish from there
        fs->isPinned = fs->isPinned || oldIsPinned;
        fs->openCount += oldOpenCount;
        // the thumbnail is recreated by LoadDocument
        delete fs->thumbnail;
        fs->thumbnail = nullptr;
    }
}

static void ReloadTab(WindowTab* tab) {
    // tab might have been closed, so first ensure it's still valid
    // https://github.com/sumatrapdfreader/sumatrapdf/issues/1958
    MainWindow* win = FindMainWindowByTab(tab);
    if (win == nullptr) {
        return;
    }
    tab->reloadOnFocus = true;
    if (tab == win->CurrentTab()) {
        // delay the reload slightly, in case we get another request immediately after this one
        SetTimer(win->hwndCanvas, AUTO_RELOAD_TIMER_ID, AUTO_RELOAD_DELAY_IN_MS, nullptr);
    }
}

static void ScheduleReloadTab(WindowTab* tab) {
    auto fn = MkFunc0<WindowTab>(ReloadTab, tab);
    uitask::Post(fn, "ReloadTab");
}

// return true if adjustd path
static bool AdjustPathForMaybeMovedFile(LoadArgs* args) {
    const char* path = args->FilePath();
    if (DocumentPathExists(path)) {
        return false;
    }
    bool failEarly = args->win && !args->forceReuse && !args->engine;
    bool fileInHistory = gFileHistory.FindByPath(path) != nullptr;
    if (!failEarly || !fileInHistory) {
        return failEarly;
    }
    // try to find non-existent files with history data
    // on a different removable drive before failing
    char* adjPath = str::DupTemp(path);
    if (AdjustVariableDriveLetter(adjPath)) {
        RenameFileInHistory(path, adjPath);
        args->SetFilePath(adjPath);
    }
    return false;
}

static void LoadDocumentMarkNotExist(MainWindow* win, const char* path, bool noSavePrefs) {
    ShowWindow(win->hwndFrame, SW_SHOW);

    // display the notification ASAP (SaveSettings() can introduce a notable delay)
    win->RedrawAll(true);

    if (!gFileHistory.MarkFileInexistent(path)) {
        return;
    }
    // TODO: handle this better. see https://github.com/sumatrapdfreader/sumatrapdf/issues/1674
    if (!noSavePrefs) {
        SaveSettings();
    }
    // update the Frequently Read list
    if (1 == gWindows.size() && gWindows.at(0)->IsCurrentTabAbout()) {
        gWindows.at(0)->RedrawAll(true);
    }
}

static void ShowFileNotFound(MainWindow* win, const char* path, bool noSavePrefs) {
    NotificationCreateArgs nargs;
    nargs.hwndParent = win->hwndCanvas;
    nargs.warning = true;
    nargs.msg = str::FormatTemp(_TRA("File %s not found"), path);
    ShowNotification(nargs);
    LoadDocumentMarkNotExist(win, path, noSavePrefs);
}

void ShowErrorLoadingNotification(MainWindow* win, const char* path, bool noSavePrefs) {
    // TODO: same message as in Canvas.cpp to not introduce
    // new translation. Find a better message e.g. why failed.
    NotificationCreateArgs nargs;
    nargs.hwndParent = win->hwndCanvas;
    nargs.msg = str::FormatTemp(_TRA("Error loading %s"), path);
    nargs.warning = true;
    nargs.timeoutMs = 1000 * 5;
    ShowNotification(nargs);
    LoadDocumentMarkNotExist(win, path, noSavePrefs);
}

extern void SetTabState(WindowTab* tab, TabState* state);

// we call this via uitask::Post so that SaveSettings() doesn't run
// synchronously in the middle of LoadDocumentFinish while other
// documents may still be loading or tabs are being closed
// (fixes crashes with dangling tab->ctrl under rapid DDE opens + hooks)
static void SaveSettingsVoid() {
    SaveSettings();
}

// progressive ebook early display can attach a DisplayModel before
// LoadDocumentFinish runs SetInitialViewSettings (e.g. background tab)
static void EnsureDisplayModelPagesInfo(MainWindow* win, WindowTab* tab) {
    DisplayModel* dm = tab ? tab->AsFixed() : nullptr;
    if (!dm) {
        return;
    }
    bool needPagesInfo = !dm->pagesInfo;
    bool needZoom = !IsValidZoom(dm->zoomVirtual);
    if (!needPagesInfo && !needZoom) {
        return;
    }

    int dpi = gGlobalPrefs->customScreenDPI;
    if (dpi == 0) {
        dpi = DpiGetForHwnd(win->hwndFrame);
    }
    DisplayMode displayMode = gGlobalPrefs->defaultDisplayModeEnum;
    float zoomVirtual = gGlobalPrefs->defaultZoomFloat;
    int startPage = 1;
    int rotation = 0;
    if (tab->tabState) {
        displayMode = DisplayModeFromString(tab->tabState->displayMode, DisplayMode::Automatic);
        startPage = tab->tabState->pageNo;
        zoomVirtual = ZoomFromString(tab->tabState->zoom, zoomVirtual);
        rotation = tab->tabState->rotation;
    } else if (gGlobalPrefs->rememberStatePerDocument && tab->filePath) {
        FileState* fs = gFileHistory.FindByPath(tab->filePath);
        if (fs) {
            displayMode = DisplayModeFromString(fs->displayMode, DisplayMode::Automatic);
            startPage = fs->pageNo;
            zoomVirtual = ZoomFromString(fs->zoom, kZoomFitPage);
            rotation = fs->rotation;
        }
    }
    EngineBase* engine = tab->GetEngine();
    if (engine && engine->kind == kindEngineImage && gGlobalPrefs->imageUI.defaultZoomFloat != 0) {
        zoomVirtual = gGlobalPrefs->imageUI.defaultZoomFloat;
    }
    if (!IsValidZoom(zoomVirtual)) {
        zoomVirtual = kZoomFitWidth;
    }
    if (needPagesInfo) {
        dm->SetInitialViewSettings(displayMode, startPage, win->GetViewPortSize(), dpi);
    }
    if (needPagesInfo || needZoom) {
        dm->Relayout(zoomVirtual, rotation);
    }
}

// attach a finished async load to a tab that is not currently selected
// (LoadDocumentFinish assumes CurrentTab() matches the tab bar selection)
static void AttachDocumentToBackgroundTab(LoadArgs* args, WindowTab* tab) {
    MainWindow* win = args->win;
    const char* fullPath = args->FilePath();
    DocController* ctrl = args->ctrl;
    ReportIf(!tab || !ctrl);
    if (!tab || !ctrl || !IsWindowTabValid(tab) || !IsMainWindowValid(win) || win->isBeingClosed) {
        SafeDeleteDocController(win, ctrl);
        args->ctrl = nullptr;
        return;
    }
    if (win->GetTabIdx(tab) < 0) {
        SafeDeleteDocController(win, ctrl);
        args->ctrl = nullptr;
        return;
    }

    tab->SetFilePath(fullPath);
    SafeDeleteDocController(win, tab->ctrl);
    tab->ctrl = ctrl;
    args->ctrl = nullptr;

    tab->showToc = DefaultShowTocForPath(fullPath);
    if (tab->ctrl && tab->ctrl->HasToc() && gGlobalPrefs->showToc) {
        FileState* fs = gFileHistory.FindByPath(fullPath);
        if (!gGlobalPrefs->rememberStatePerDocument || !fs) {
            tab->showToc = true;
        } else {
            tab->showToc = fs->showToc;
        }
    } else if (tab->ctrl && !tab->ctrl->HasToc()) {
        tab->showToc = false;
    }
    EnsureDisplayModelPagesInfo(win, tab);

    // Defer SetTabState until LoadModelIntoTab selects this tab. Applying scroll
    // state here calls GoToPage -> UpdateScrollbars which uses win->AsFixed()
    // (the foreground tab), not tab->ctrl, and crashes during concurrent loads.
    if (tab->tabState) {
        tab->showToc = tab->tabState->showToc;
        tab->tocState = *tab->tabState->tocState;
    } else if (args->tabState) {
        tab->tabState = args->tabState;
        args->tabState = nullptr;
    }

    SetFrameTitleForTab(tab, true);
    UpdateTabTitle(tab);

    const char* path = tab->filePath;
    ReportIf(tab->watcher);
    if (gGlobalPrefs->reloadModifiedDocuments) {
        auto fn = MkFunc0(ScheduleReloadTab, tab);
        bool enableManualCheck = true;
        tab->watcher = FileWatcherSubscribe(path, fn, enableManualCheck);
    }

    if (SettingsRememberOpenedFiles()) {
        FileState* ds = gFileHistory.MarkFileLoaded(fullPath);
        if (gGlobalPrefs->showStartPage) {
            CreateThumbnailForFile(win, ds);
        }
        if (!args->noSavePrefs) {
            auto fn = MkFunc0Void(SaveSettingsVoid);
            uitask::Post(fn, "SaveSettingsAfterDocLoad");
        }
    }

    if (CanAccessDisk() && !gPluginMode && !IsStressTesting()) {
        AddPathToRecentDocs(fullPath);
    }
    if (CanAccessDisk() && !gPluginMode) {
        file::DeleteZoneIdentifier(fullPath);
    }
}

MainWindow* LoadDocumentFinish(LoadArgs* args) {
    MainWindow* win = args->win;
    const char* fullPath = args->FilePath();

    bool openNewTab = SettingsUseTabs() && !args->forceReuse;
    ReportIf(openNewTab && args->forceReuse);

    if (win->IsCurrentTabAbout()) {
        // TODO: probably need to do it when switching tabs
        // invalidate the links on the Frequently Read page
        DeleteVecMembers(win->staticLinks);
        Rect rc = {};
        // TODO: a hack, need a way to clear tooltips
        win->infotip->Delete();
        win->DeleteToolTip();
        // there's no tab to reuse at this point
        args->forceReuse = false;
    } else {
        if (openNewTab) {
            SaveCurrentWindowTab(args->win);
        }
        CloseDocumentInCurrentTab(win, true, args->forceReuse);
    }
    if (!args->forceReuse) {
        // insert a new tab for the loaded document
        WindowTab* tab = new WindowTab(win);
        tab->SetFilePath(fullPath);
        win->currentTabTemp = AddTabToWindow(win, tab);

        if (!IsMainWindowValid(win) || win->isBeingClosed) {
            return nullptr;
        }

        // logf("LoadDocument: !forceReuse, created win->CurrentTab() at 0x%p\n", win->CurrentTab());
    } else {
        WindowTab* reuseTab = win->CurrentTab();
        if (!reuseTab || !IsWindowTabValid(reuseTab)) {
            SafeDeleteDocController(win, args->ctrl);
            args->ctrl = nullptr;
            return nullptr;
        }
        reuseTab->SetFilePath(fullPath);
#if 0
        auto path = ToUtf8Temp(fullPath);
        logf("LoadDocument: forceReuse, set win->CurrentTab() (0x%p) filePath to '%s'\n", reuseTab, path.Get());
#endif
    }

    // TODO: stop remembering/restoring window positions when using tabs?
    args->placeWindow = !SettingsUseTabs();
    bool lazyLoad = args->lazyLoad;
    if (!lazyLoad) {
        if (!IsMainWindowValid(win) || win->isBeingClosed) {
            return nullptr;
        }
        ReplaceDocumentInCurrentTab(args, args->ctrl, nullptr);
    }

    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return nullptr;
    }

    if (gPluginMode) {
        // hide the menu for embedded documents opened from the plugin
        SetMenu(win->hwndFrame, nullptr);
        return win;
    }

    auto currTab = win->CurrentTab();
    if (!currTab || !IsWindowTabValid(currTab)) {
        return win;
    }
    const char* path = currTab->filePath;
#if 0
    int nPages = 0;
    if (currTab->ctrl) {
        nPages = currTab->ctrl->PageCount();
    }
    logf("LoadDocument: after ReplaceDocumentInCurrentTab win->CurrentTab() is 0x%p, path: '%s', %d pages\n", currTab,
         path.Get(), nPages);
#endif
    // when lazy loading: first time remember tab state, second time is
    // real loading so restore tab state
    if (!currTab->ctrl && !currTab->tabState) {
        currTab->tabState = args->tabState;
    } else if (currTab->tabState) {
        SetTabState(currTab, currTab->tabState);
        currTab->tabState = nullptr;
    }
    // TODO: figure why we hit this.
    // happens when opening 3 files via "Open With"
    // the first file is loaded via cmd-line arg, the rest
    // via DDE Open command.
    ReportIf(currTab->watcher);

    if (gGlobalPrefs->reloadModifiedDocuments) {
        auto fn = MkFunc0(ScheduleReloadTab, currTab);
        // was gGlobalPrefs->enableTeXEnhancements because people complained
        // about network traffic. but then people complained it stopped working
        // we'll now recommend ReloadModifiedDocuments = false for those
        // who complain
        bool enableManualCheck = true;
        currTab->watcher = FileWatcherSubscribe(path, fn, enableManualCheck);
    }

    if (SettingsRememberOpenedFiles()) {
        ReportIf(!str::Eq(fullPath, path));
        FileState* ds = gFileHistory.MarkFileLoaded(fullPath);
        if (gGlobalPrefs->showStartPage) {
            CreateThumbnailForFile(win, ds);
        }
        // TODO: this seems to save the state of file that we just opened
        // add a way to skip saving currTab?
        if (!args->noSavePrefs) {
            auto fn = MkFunc0Void(SaveSettingsVoid);
            uitask::Post(fn, "SaveSettingsAfterDocLoad");
        }
    }

    // Add the file also to Windows' recently used documents (this doesn't
    // happen automatically on drag&drop, reopening from history, etc.)
    if (CanAccessDisk() && !gPluginMode && !IsStressTesting()) {
        AddPathToRecentDocs(fullPath);
    }

    // Remove Zone.Identifier (Mark of the Web) so that Windows Explorer
    // will show previews/thumbnails for this file without security warnings
    if (CanAccessDisk() && !gPluginMode) {
        file::DeleteZoneIdentifier(fullPath);
    }

    return win;
}

static NotificationWnd* ShowLoadingNotif(MainWindow* win, const char* path) {
    NotificationCreateArgs nargs;
    nargs.hwndParent = win->hwndCanvas;
    nargs.groupId = path;
    nargs.msg = str::FormatTemp(_TRA("Loading %s ..."), path::GetBaseNameTemp(path));
    return ShowNotification(nargs);
}

// Create/select a tab and show the window before the (slow) engine load finishes.
static void PrepareLoadingTab(MainWindow* win, LoadArgs* args) {
    if (!win || !args) {
        return;
    }
    const char* fullPath = args->FilePath();
    bool openNewTab = SettingsUseTabs() && !args->forceReuse;

    if (win->IsCurrentTabAbout()) {
        DeleteVecMembers(win->staticLinks);
        win->infotip->Delete();
        win->DeleteToolTip();
        openNewTab = SettingsUseTabs();
        args->forceReuse = false;
    } else if (openNewTab) {
        SaveCurrentWindowTab(win);
    }

    CloseDocumentInCurrentTab(win, true, args->forceReuse);

    if (openNewTab && win->tabsCtrl) {
        WindowTab* tab = new WindowTab(win);
        tab->SetFilePath(fullPath);
        win->currentTabTemp = AddTabToWindow(win, tab);
        win->ctrl = nullptr;
        // Hide sidebar left over from the previous tab while the new doc loads.
        SetSidebarVisibility(win, false, gGlobalPrefs->showFavorites);
    } else if (!win->CurrentTab() && win->tabsCtrl) {
        // No-tabs mode can start from an empty window (TabCount()==0). Create a
        // shell tab before the async load so CurrentTab() and targetTab stay valid.
        WindowTab* tab = new WindowTab(win);
        tab->SetFilePath(fullPath);
        win->currentTabTemp = AddTabToWindow(win, tab);
        win->ctrl = nullptr;
        SetSidebarVisibility(win, false, gGlobalPrefs->showFavorites);
    } else if (win->CurrentTab()) {
        win->CurrentTab()->SetFilePath(fullPath);
        TabsOnChangedDoc(win);
    }

    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return;
    }

    WindowTab* tab = win->CurrentTab();
    if (tab) {
        SetFrameTitleForTab(tab, false);
    }
    UpdateUiForCurrentTab(win);

    if (!IsWindowVisible(win->hwndFrame) && args->showWin) {
        ShowMainWindow(win, gGlobalPrefs->windowState);
    }
    win->RedrawAll(true);
}

static bool ShouldLoadDocumentAsync(const char* path, LoadArgs* args) {
    if (args->lazyLoad || args->syncLoad) {
        return false;
    }
    if (!gGlobalPrefs->chmUI.useFixedPageUI) {
        Kind kind = GuessFileTypeFromName(path);
        if (ChmModel::IsSupportedFileType(kind)) {
            return false;
        }
    }
    return true;
}

static MainWindow* MaybeCreateWindowForFileLoad(LoadArgs* args) {
    MainWindow* win = args->win;
    bool openNewTab = SettingsUseTabs() && !args->forceReuse;
    if (openNewTab && !args->win) {
        // modify the args so that we always reuse the same window
        // TODO: enable the tab bar if tabs haven't been initialized
        if (!gWindows.empty()) {
            win = gWindows.Last();
            args->win = win;
            args->isNewWindow = false;
        }
    }

    if (!win && 1 == gWindows.size() && gWindows.at(0)->IsCurrentTabAbout()) {
        win = gWindows.at(0);
        args->win = win;
        args->isNewWindow = false;
    } else if (!win || !openNewTab && !args->forceReuse && win->IsDocLoaded()) {
        MainWindow* currWin = win;
        // during startup, create window hidden to avoid flashing the about page;
        // it will be shown by ReplaceDocumentInCurrentTab or ShowMainWindow later
        win = CreateAndShowMainWindow(nullptr, !gIsStartup);
        if (!win) {
            return nullptr;
        }
        args->win = win;
        args->isNewWindow = true;
        if (currWin) {
            RememberFavTreeExpansionState(currWin);
            win->expandedFavorites = currWin->expandedFavorites;
        }
    }
    return win;
}

struct LoadDocumentAsyncData {
    NotificationWnd* wndNotif = nullptr;
    LoadArgs* args = nullptr;
    EngineBase* engine = nullptr;
    WindowTab* targetTab = nullptr;
    volatile LONG displayedOnUI = 0;
    LoadDocumentAsyncData() = default;
    ~LoadDocumentAsyncData() { delete args; }
};

// thread_local: multiple RunAsync load threads can overlap (e.g. restore tabs);
// a single global was overwritten and paired the wrong engine with LoadArgs.
static thread_local LoadDocumentAsyncData* gCurrentAsyncLoad = nullptr;

struct EarlyEngineDisplayTask {
    EngineBase* engine = nullptr;
    LoadDocumentAsyncData* d = nullptr;
};

static void FinishAsyncDocumentLoad(LoadDocumentAsyncData* d) {
    auto args = d->args;
    MainWindow* win = args->win;
    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        if (args->ctrl) {
            SafeDeleteDocController(win, args->ctrl);
            args->ctrl = nullptr;
        }
        return;
    }
    WindowTab* tab = d->targetTab;
    if (!tab) {
        tab = win->CurrentTab();
    }
    int tabIdx = tab ? win->GetTabIdx(tab) : -1;
    if (tabIdx < 0) {
        // The tab was closed while the document was loading on a background
        // thread. d->targetTab is now a dangling pointer; must not deref it.
        tab = nullptr;
    }
    int selectedIdx = win->tabsCtrl ? win->tabsCtrl->GetSelected() : -1;
    // win->ctrl can still reference targetTab between a tab click and LoadModelIntoTab
    bool isForegroundTab = !SettingsUseTabs();
    if (!isForegroundTab) {
        isForegroundTab = (tabIdx >= 0 && tabIdx == selectedIdx) || (tab && win->ctrl && win->ctrl == tab->ctrl);
    }

    args->activateExisting = false;
    if (isForegroundTab) {
        win->currentTabTemp = nullptr;
        LoadDocumentFinish(args);
    } else if (tab && args->ctrl) {
        AttachDocumentToBackgroundTab(args, tab);
    } else if (args->ctrl) {
        // Tab was closed during load: destroy the orphaned controller to avoid a leak.
        SafeDeleteDocController(win, args->ctrl);
        args->ctrl = nullptr;
    }
}

static void EarlyEngineDisplayUI(EarlyEngineDisplayTask* task) {
    AutoDelete delTask(task);
    LoadDocumentAsyncData* d = task->d;
    defer {
        if (d && d->wndNotif) {
            RemoveNotification(d->wndNotif);
            d->wndNotif = nullptr;
        }
    };
    if (!d || !task->engine) {
        return;
    }
    auto args = d->args;
    if (args->ctrl) {
        return;
    }
    MainWindow* win = args->win;
    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return;
    }
    if (task->engine->pageCount <= 0) {
        return;
    }

    DisplayModel* dm = new DisplayModel(task->engine, win->cbHandler);
    VerifyController(dm, args->FilePath());
    gMostRecentlyOpenedDoc = dm;
    args->ctrl = dm;
    d->engine = nullptr;

    FinishAsyncDocumentLoad(d);
    InterlockedExchange(&d->displayedOnUI, 1);
}

void NotifyEngineDisplayReady(EngineBase* engine) {
    if (!gCurrentAsyncLoad || !engine) {
        return;
    }
    const char* enginePath = engine->FilePath();
    const char* loadPath = gCurrentAsyncLoad->args->FilePath();
    if (!str::Eq(enginePath, loadPath)) {
        return;
    }
    auto* task = new EarlyEngineDisplayTask();
    task->engine = engine;
    task->d = gCurrentAsyncLoad;
    auto fn = MkFunc0<EarlyEngineDisplayTask>(EarlyEngineDisplayUI, task);
    uitask::Post(fn, "EarlyEngineDisplay");
}

static void LoadDocumentAsyncFinish(LoadDocumentAsyncData* d) {
    if (d->wndNotif) {
        RemoveNotification(d->wndNotif);
        d->wndNotif = nullptr;
    }
    AutoDelete delData(d);

    if (InterlockedCompareExchange(&d->displayedOnUI, 0, 0) != 0) {
        return;
    }

    auto args = d->args;
    MainWindow* win = args->win;
    if (!IsMainWindowValid(win)) {
        return;
    }
    if (win->isBeingClosed) {
        return;
    }
    const char* path = args->FilePath();
    if (!args->ctrl && d->engine && d->engine->pageCount > 0) {
        args->ctrl = new DisplayModel(d->engine, win->cbHandler);
        VerifyController(args->ctrl, path);
        gMostRecentlyOpenedDoc = args->ctrl;
        d->engine = nullptr;
    }
    if (!args->ctrl) {
        ShowErrorLoadingNotification(win, path, args->noSavePrefs);
        // re-sync win->ctrl with current tab after ShowErrorLoadingNotification
        // which can pump messages and change tab selection
        WindowTab* currTab = win->CurrentTab();
        win->ctrl = currTab ? currTab->ctrl : nullptr;
        return;
    }
    FinishAsyncDocumentLoad(d);
}

// Progress notification payload posted from archive extraction (worker
// thread) to the UI thread. The NotificationWnd* is safe to use without
// a separate validity check because every progress task is enqueued
// before LoadDocumentAsyncFinish (which calls RemoveNotification), and
// uitask runs posted tasks in FIFO order on the UI thread.
struct ExtractProgressUITask {
    NotificationWnd* wnd;
    char* path; // owned by the task; duped on the worker thread
    int nDecoded;
    int nTotal;
};

static void UpdateLoadingNotifUI(ExtractProgressUITask* task) {
    AutoDelete delTask(task);
    AutoFreeStr delPath(task->path);
    if (!task->wnd) {
        return;
    }
    const char* basename = path::GetBaseNameTemp(task->path);
    TempStr loading = str::FormatTemp(_TRA("Loading %s ..."), basename);
    TempStr msg;
    if (task->nTotal > 0) {
        msg = str::FormatTemp("%s (%d/%d)", loading, task->nDecoded, task->nTotal);
    } else {
        msg = str::FormatTemp("%s (%d)", loading, task->nDecoded);
    }
    NotificationUpdateMessage(task->wnd, msg);
}

struct ExtractProgressState {
    NotificationWnd* wnd;
    const char* path;
    DWORD lastUpdate;
};

static void OnExtractProgress(ExtractProgressState* s, ArchiveExtractProgress* p) {
    // throttle: the last callback (with nDecoded == nTotal) always posts
    // so the final count is displayed; intermediate callbacks post at
    // most once every 100 ms.
    bool isFinal = (p->nTotal > 0 && p->nDecoded == p->nTotal);
    DWORD now = GetTickCount();
    if (!isFinal && (now - s->lastUpdate) < 100) {
        return;
    }
    s->lastUpdate = now;

    auto* task = new ExtractProgressUITask;
    task->wnd = s->wnd;
    task->path = str::Dup(s->path);
    task->nDecoded = p->nDecoded;
    task->nTotal = p->nTotal;
    auto fn = MkFunc0<ExtractProgressUITask>(UpdateLoadingNotifUI, task);
    uitask::Post(fn, "ExtractProgress");
}

// Progress payload for the network-drive copy step, rendered as
// "Copying <name>: 12.38 MB / 45.00 MB" so users can see large copies
// making progress. nDecoded/nTotal from OnExtractProgress's task carry
// counts; this task carries byte totals instead.
struct CopyProgressUITask {
    NotificationWnd* wnd;
    char* path;
    i64 bytesCopied;
    i64 bytesTotal;
};

static void UpdateCopyNotifUI(CopyProgressUITask* task) {
    AutoDelete delTask(task);
    AutoFreeStr delPath(task->path);
    if (!task->wnd) {
        return;
    }
    const char* basename = path::GetBaseNameTemp(task->path);
    TempStr copied = str::FormatSizeShortTemp(task->bytesCopied, nullptr);
    TempStr msg;
    if (task->bytesTotal > 0) {
        TempStr total = str::FormatSizeShortTemp(task->bytesTotal, nullptr);
        msg = str::FormatTemp("Copying %s: %s / %s", basename, copied, total);
    } else {
        msg = str::FormatTemp("Copying %s: %s", basename, copied);
    }
    NotificationUpdateMessage(task->wnd, msg);
}

struct CopyProgressState {
    NotificationWnd* wnd;
    const char* path;
};

static void OnFileCopyProgress(CopyProgressState* s, file::CopyProgress* p) {
    auto* task = new CopyProgressUITask;
    task->wnd = s->wnd;
    task->path = str::Dup(s->path);
    task->bytesCopied = p->bytesCopied;
    task->bytesTotal = p->bytesTotal;
    auto fn = MkFunc0<CopyProgressUITask>(UpdateCopyNotifUI, task);
    uitask::Post(fn, "CopyProgress");
}

static void LoadDocumentAsync(LoadDocumentAsyncData* d) {
    auto args = d->args;
    AtomicIntInc(&gDangerousThreadCount);
    MainWindow* win = args->win;
    HwndPasswordUI pwdUI(win->hwndFrame ? win->hwndFrame : nullptr);
    const char* path = args->FilePath();
    bool chmInFixedUI = gGlobalPrefs->chmUI.useFixedPageUI;

    // wire up the archive extraction progress callback so eager-load
    // archives (small cbx / epub / fb2z) can update the "Loading ..."
    // notification.
    ExtractProgressState progState;
    progState.wnd = d->wndNotif;
    progState.path = path;
    progState.lastUpdate = 0;
    gArchiveProgressCb = MkFunc1<ExtractProgressState, ArchiveExtractProgress*>(OnExtractProgress, &progState);

    // also wire up the file-copy progress callback so the cbx
    // network-drive caching step (runs before archive open) reports bytes
    // copied into the same loading notification.
    CopyProgressState copyState;
    copyState.wnd = d->wndNotif;
    copyState.path = path;
    file::gFileCopyProgressCb = MkFunc1<CopyProgressState, file::CopyProgress*>(OnFileCopyProgress, &copyState);

    gCurrentAsyncLoad = d;
    InterlockedExchange(&d->displayedOnUI, 0);

    EngineBase* engine = args->engine;
    if (!engine) {
        engine = CreateEngineFromFile(path, &pwdUI, chmInFixedUI);
    }

    gArchiveProgressCb = {};
    file::gFileCopyProgressCb = {};

    d->engine = engine;
    if (!engine) {
        args->ctrl = CreateControllerForEngineOrFile(nullptr, path, &pwdUI, win);
        d->engine = nullptr;
    } else if (engine->pageCount <= 0) {
        SafeEngineRelease(&engine);
        d->engine = nullptr;
        args->ctrl = nullptr;
    } else {
        bool progressiveReflow = EngineIsProgressiveEbookLoading(engine);
        if (!progressiveReflow) {
            args->ctrl = new DisplayModel(engine, win->cbHandler);
            VerifyController(args->ctrl, path);
            gMostRecentlyOpenedDoc = args->ctrl;
            d->engine = nullptr;
        } else if (args->ctrl || InterlockedCompareExchange(&d->displayedOnUI, 0, 0) != 0) {
            // EarlyEngineDisplayUI already transferred engine ownership to DisplayModel.
            d->engine = nullptr;
        }
    }

    gCurrentAsyncLoad = nullptr;

    auto fn = MkFunc0<LoadDocumentAsyncData>(LoadDocumentAsyncFinish, d);
    uitask::Post(fn, "TaskLoadDocumentAsyncFinish");
    AtomicIntDec(&gDangerousThreadCount);
}

void StartLoadDocument(LoadArgs* argsIn) {
    if (gCrashOnOpen) {
        log("LoadDocumentAsync: about to call CrashMe()\n");
        CrashMe();
    }

    MainWindow* win = argsIn->win;
    bool failEarly = AdjustPathForMaybeMovedFile(argsIn);
    const char* path = argsIn->FilePath();
    if (failEarly) {
        ShowFileNotFound(win, path, argsIn->noSavePrefs);
        return;
    }

    if (argsIn->activateExisting) {
        MainWindow* existing = FindMainWindowByFile(path, true);
        if (existing) {
            existing->Focus();
            return;
        }
    }

    win = MaybeCreateWindowForFileLoad(argsIn);
    if (!win) {
        return;
    }

    auto wndNotif = ShowLoadingNotif(win, path);
    LoadArgs* args = argsIn->Clone();

    bool willCreateNewTab = SettingsUseTabs() && !args->forceReuse;
    PrepareLoadingTab(win, args);
    if (win->CurrentTab()) {
        args->forceReuse = true;
    } else if (willCreateNewTab && win->tabsCtrl) {
        args->forceReuse = true;
    }

    // when using mshtml to display CHM files, we can't load in a thread
    // TODO: that's because we create web control on a thread which
    // violates threading rules and that happens as part of CreateControllerForEngineOrFile()
    // we could probably delay creating web control but that's more complicated
    if (!gGlobalPrefs->chmUI.useFixedPageUI) {
        Kind kind = GuessFileTypeFromName(path);
        bool isChm = ChmModel::IsSupportedFileType(kind);
        if (isChm) {
            // TODO: repeating the code below
            DocController* ctrl = nullptr;
            HwndPasswordUI pwdUI(win->hwndFrame ? win->hwndFrame : nullptr);
            EngineBase* engine = args->engine;
            args->ctrl = CreateControllerForEngineOrFile(engine, path, &pwdUI, win);
            RemoveNotification(wndNotif);
            if (!args->ctrl) {
                ShowErrorLoadingNotification(win, path, args->noSavePrefs);
                // re-sync win->ctrl with current tab after ShowErrorLoadingNotification
                // which can pump messages and change tab selection
                WindowTab* currTab = win->CurrentTab();
                win->ctrl = currTab ? currTab->ctrl : nullptr;
                delete args;
                return;
            }
            args->activateExisting = false;
            LoadDocumentFinish(args);
            delete args;
            return;
        }
    }

    auto data = new LoadDocumentAsyncData;
    data->wndNotif = wndNotif;
    data->args = args;
    data->targetTab = win->CurrentTab();
    auto fn = MkFunc0<LoadDocumentAsyncData>(LoadDocumentAsync, data);
    RunAsync(fn, "LoadDocumentThread");
}

// remember which files failed to open so that a failure to
// open a file doesn't block next/prev file in
static StrVec gFilesFailedToOpen;

MainWindow* LoadDocument(LoadArgs* args) {
    if (gCrashOnOpen) {
        log("LoadDocument: about to call CrashMe()\n");
        CrashMe();
    }

    const char* path = args->FilePath();
    if (args->activateExisting) {
        MainWindow* existing = FindMainWindowByFile(path, true);
        if (existing) {
            existing->Focus();
            return existing;
        }
    }

    MainWindow* win = args->win;
    bool failEarly = AdjustPathForMaybeMovedFile(args);

    // fail fast if the file doesn't exist and there is a window the user
    // has just been interacting with
    if (failEarly) {
        ShowFileNotFound(win, path, args->noSavePrefs);
        return nullptr;
    }

    if (ShouldLoadDocumentAsync(path, args)) {
        StartLoadDocument(args);
        return args->win;
    }

    win = MaybeCreateWindowForFileLoad(args);
    if (!win) {
        return nullptr;
    }

    auto timeStart = TimeGet();
    HwndPasswordUI pwdUI(win->hwndFrame);
    DocController* ctrl = nullptr;
    if (!args->lazyLoad) {
        ctrl = CreateControllerForEngineOrFile(args->engine, path, &pwdUI, win);
        {
            auto durMs = TimeSinceInMs(timeStart);
            if (ctrl) {
                int nPages = ctrl->PageCount();
                logf("LoadDocument: %.2f ms, %d pages for '%s'\n", (float)durMs, nPages, path);
            } else {
                logf("LoadDocument: failed to load '%s' in %.2f ms\n", path, (float)durMs);
                AppendIfNotExists(&gFilesFailedToOpen, path);
            }
        }

        if (!ctrl) {
            // ensure window is visible even if loading failed
            // (it may have been created hidden during startup)
            if (!IsWindowVisible(win->hwndFrame)) {
                ShowMainWindow(win, gGlobalPrefs->windowState);
            }
            ShowErrorLoadingNotification(win, path, args->noSavePrefs);
            // re-sync win->ctrl with current tab after ShowErrorLoadingNotification
            // which can pump messages and change tab selection
            WindowTab* currTab = win->CurrentTab();
            win->ctrl = currTab ? currTab->ctrl : nullptr;
            return win;
        }
    }
    args->ctrl = ctrl;
    return LoadDocumentFinish(args);
}

// Recreate the shared canvas back-buffer and paint synchronously after a tab
// switch so stale pixels from the previous tab cannot bleed through.
static void RepaintCanvasAfterTabSwitch(MainWindow* win) {
    if (!IsMainWindowValid(win) || !win->hwndCanvas) {
        return;
    }
    win->UpdateCanvasSize();
    DisplayModel* dm = win->AsFixed();
    if (dm) {
        gRenderCache->CancelRendering(dm);
        EngineBase* engine = dm->GetEngine();
        if (engine && IsReflowableEbookEngine(engine)) {
            gRenderCache->FreeForDisplayModel(dm);
            dm->RepaintDisplay();
        } else {
            gRenderCache->KeepForDisplayModel(dm, dm);
        }
    }
    InvalidateRect(win->hwndCanvas, nullptr, TRUE);
    UpdateWindow(win->hwndCanvas);
}

// Loads document data into the MainWindow.
void LoadModelIntoTab(WindowTab* tab) {
    if (!tab) {
        return;
    }

    MainWindow* win = tab->win;
    WindowTab* prevTab = win->CurrentTab();
    const bool switchingTab = (tab != prevTab);
    // Stop find/count workers and destroy find UI while ctrl still refers to the
    // tab being left, before any ShowWindow/RedrawAll that pumps messages.
    if (switchingTab) {
        ResetFindUIForTabSwitch(win);
    }
    if (gGlobalPrefs->lazyLoading && win->ctrl && !tab->ctrl && !tab->IsAboutTab()) {
        NotificationCreateArgs args;
        args.hwndParent = win->hwndCanvas;
        args.msg = str::FormatTemp(_TRA("Please wait - loading..."));
        args.warning = true;
        ShowNotification(args);
        ShowWindow(win->hwndFrame, SW_SHOW);
        // display the notification ASAP
        win->RedrawAll(true);
    }
    // ShowWindow / RedrawAll can pump messages, potentially destroying win
    if (!IsMainWindowValid(win)) {
        return;
    }
    if (win->ctrl && !WinCtrlIsOwnedByAnyTab(win, win->ctrl)) {
        win->ctrl = nullptr;
    }
    CloseDocumentInCurrentTab(win, true, false);

    win->currentTabTemp = tab;
    win->ctrl = tab->ctrl;

    bool rerenderAfterTheme = false;
    bool refreshSelectionAfterTheme = false;
    if (!tab->IsAboutTab() && tab->ctrl && tab->reloadOnFocus) {
        tab->reloadOnFocus = false;
        if (ShouldReloadForThemeChange(tab)) {
            ApplyThemeChangeToTab(win, tab);
            rerenderAfterTheme = true;
        } else {
            ReloadDocument(win, true);
        }
    }

    DisplayModel* dmAfterTheme = tab->AsFixed();
    if (dmAfterTheme && IsReflowableEbookEngine(tab->GetEngine()) &&
        tab->lastDarkModeEpoch != gRenderCache->darkModeEpoch) {
        gRenderCache->CancelRendering(dmAfterTheme);
        gRenderCache->FreeForDisplayModel(dmAfterTheme);
        EbookAnnotationsInvalidateLayoutCaches(tab);
        tab->lastDarkModeEpoch = gRenderCache->darkModeEpoch;
        rerenderAfterTheme = true;
        refreshSelectionAfterTheme = true;
    }

    if (win->AsChm()) {
        win->AsChm()->SetParentHwnd(win->hwndCanvas);
    } else if (win->AsFixed() && win->uiaProvider) {
        // tell UI Automation about content change
        win->uiaProvider->OnDocumentLoad(win->AsFixed());
    }

    UpdateUiForCurrentTab(win);
    PickAnotherRandomPromotion();

    EnsureDisplayModelPagesInfo(win, tab);

    DisplayModel* dm = tab->ctrl ? tab->ctrl->AsFixed() : nullptr;
    if (dm && dm->pagesInfo) {
        if (refreshSelectionAfterTheme) {
            dm->OnMorePagesAvailablePreservingScroll(true, false);
        } else {
            dm->OnMorePagesAvailable(true, false);
        }
        UpdateToolbarPageText(win, dm->PageCount());
    }
    if (refreshSelectionAfterTheme) {
        RefreshTextSelectionAfterLayoutChange(tab, win);
        ResyncReadAloudAfterLayoutChange(tab, win);
    }

    if (win->InPresentation()) {
        SetSidebarVisibility(win, tab->showTocPresentation, gGlobalPrefs->showFavorites);
    } else {
        SetSidebarVisibility(win, tab->showToc, gGlobalPrefs->showFavorites);
    }

    if (dm) {
        if (tab->canvasRc != win->canvasRc) {
            auto viewPort = win->GetViewPortSize();
            win->ctrl->SetViewPortSize(viewPort);
        }
        if (tab->tabState) {
            SetTabState(tab, tab->tabState);
            tab->tabState = nullptr;
        } else if (tab->canvasRc == win->canvasRc) {
            // avoid double setting of scroll state -> it gets triggered by SetViewPortSize();
            dm->SetScrollState(dm->GetScrollState());
        }
        if (dm->InPresentation() != win->InPresentation()) {
            dm->SetInPresentation(win->InPresentation());
        }
    } else if (win->AsChm()) {
        win->ctrl->GoToPage(win->ctrl->CurrentPageNo(), false);
    }
    tab->canvasRc = win->canvasRc;

    win->showSelection = tab->selectionOnPage != nullptr;
    if (win->uiaProvider) {
        win->uiaProvider->OnSelectionChanged();
    }

    HwndSetFocus(win->hwndFrame);
    if (tab->IsAboutTab()) {
        DeleteVecMembers(win->staticLinks);
        InvalidateRect(win->hwndCanvas, nullptr, TRUE);
        UpdateWindow(win->hwndCanvas);
    } else if (!tab->ctrl) {
        ReloadDocument(win, false);
        RepaintCanvasAfterTabSwitch(win);
    } else if (rerenderAfterTheme) {
        DisplayModel* themeDm = tab->AsFixed();
        if (themeDm) {
            gRenderCache->FreeForDisplayModel(themeDm);
        }
        RepaintCanvasAfterTabSwitch(win);
    } else {
        RepaintCanvasAfterTabSwitch(win);
    }
    if (tab->AsFixed()) {
        tab->lastDarkModeEpoch = gRenderCache->darkModeEpoch;
    }
}

enum class MeasurementUnit {
    pt,
    mm,
    in
};

static TempStr FormatCursorPositionTemp(EngineBase* engine, PointF pt, MeasurementUnit unit) {
    if (pt.x < 0) {
        pt.x = 0;
    }
    if (pt.y < 0) {
        pt.y = 0;
    }
    pt.x /= engine->GetFileDPI();
    pt.y /= engine->GetFileDPI();

    // for MeasurementUnit::in
    float factor = 1;
    const char* unitName = "in";
    switch (unit) {
        case MeasurementUnit::pt:
            factor = 72;
            unitName = "pt";
            break;

        case MeasurementUnit::mm:
            factor = 25.4f;
            unitName = "mm";
            break;
    }

    char* xPos = str::FormatFloatWithThousandSepTemp((double)pt.x * (double)factor);
    char* yPos = str::FormatFloatWithThousandSepTemp((double)pt.y * (double)factor);
    if (unit != MeasurementUnit::in) {
        // use similar precision for all units
        int xLen = str::Leni(xPos);
        if (str::IsDigit(xPos[xLen - 2])) {
            xPos[xLen - 1] = '\0';
        }
        int yLen = str::Leni(yPos);
        if (str::IsDigit(yPos[yLen - 2])) {
            yPos[yLen - 1] = '\0';
        }
    }
    return fmt::FormatTemp("%s x %s %s", xPos, yPos, unitName);
}

static auto cursorPosUnit = MeasurementUnit::pt;
void UpdateCursorPositionHelper(MainWindow* win, Point pos, NotificationWnd* wnd) {
    ReportIf(!win->AsFixed());
    EngineBase* engine = win->AsFixed()->GetEngine();
    PointF pt = win->AsFixed()->CvtFromScreen(pos);
    char* posStr = FormatCursorPositionTemp(engine, pt, cursorPosUnit);
    char* selStr = nullptr;
    if (!win->selectionMeasure.IsEmpty()) {
        pt = PointF(win->selectionMeasure.dx, win->selectionMeasure.dy);
        selStr = FormatCursorPositionTemp(engine, pt, cursorPosUnit);
    }

    char* posInfo = fmt::FormatTemp("%s %s", _TRA("Cursor position:"), posStr);
    if (selStr) {
        posInfo = fmt::FormatTemp("%s - %s %s", posInfo, _TRA("Selection:"), selStr);
    }
    NotificationUpdateMessage(wnd, posInfo);
}

// re-render the document currently displayed in this window
void MainWindowRerender(MainWindow* win, bool includeNonClientArea) {
    DisplayModel* dm = win->AsFixed();
    if (!dm) {
        return;
    }
    gRenderCache->CancelRendering(dm);
    gRenderCache->KeepForDisplayModel(dm, dm);
    // KeepForDisplayModel() invalidates visible tiles and CancelRendering()
    // drops their pending requests. Queue the visible pages again now instead
    // of relying on a later scroll/paint to do it. This is especially
    // important when returning from a forced color theme to native colors.
    dm->RenderVisibleParts();
    if (includeNonClientArea) {
        win->RedrawAllIncludingNonClient();
    } else {
        win->RedrawAll(true);
    }
}

static void AddPdfMarkupOverlay(WindowTab* tab, int pageNo, Annotation* annot) {
    if (!tab || !annot || pageNo <= 0 || !IsPdfTextMarkupAnnotation(annot)) {
        return;
    }
    for (auto& entry : tab->pdfMarkupOverlays) {
        if (entry.annot == annot) {
            entry.pageNo = pageNo;
            return;
        }
    }
    tab->pdfMarkupOverlays.Append({pageNo, annot});
}

// Refresh a page in the background; optionally draw text markup as overlay until tiles catch up.
void MainWindowRerenderAnnotationChange(MainWindow* win, int pageNo, Annotation* overlayAnnot) {
    DisplayModel* dm = win->AsFixed();
    WindowTab* tab = win->CurrentTab();
    if (!dm || !tab) {
        win->RedrawAll(true);
        return;
    }
    if (pageNo > 0) {
        AddPdfMarkupOverlay(tab, pageNo, overlayAnnot);
        RectF bounds = overlayAnnot ? GetBounds(overlayAnnot) : dm->GetEngine()->PageMediabox(pageNo);
        gRenderCache->CancelRendering(dm);
        gRenderCache->Invalidate(dm, pageNo, bounds);
    } else {
        gRenderCache->CancelRendering(dm);
    }
    dm->RenderVisibleParts();
    win->RedrawAll(true);
}

static void RerenderEverything() {
    for (auto* win : gWindows) {
        MainWindowRerender(win);
    }
}

static void RerenderFixedPage() {
    for (auto* win : gWindows) {
        if (win->AsFixed()) {
            MainWindowRerender(win, true);
        }
    }
}

static void ApplyDocumentColorModeChangeToTab(MainWindow* win, WindowTab* tab) {
    if (!win || !tab || !tab->IsDocLoaded() || tab->IsAboutTab()) {
        return;
    }
    EngineBase* engine = tab->GetEngine();
    if (IsReflowableMupdfForTheme(engine)) {
        // Pause background reflow loading so this operation isn't starved of docLock.
        ReflowLoadingPauseScope reflowPause(engine);
        EngineMupdfRelayoutForThemeChange(engine);
        DisplayModel* dm = tab->AsFixed();
        if (dm) {
            RefreshDisplayModelAfterThemeChange(dm, tab == win->CurrentTab());
            RemapAnchorsAfterReflow(tab, win);
        }
        ReloadTocUiAfterReflowReparse(win, tab);
        tab->reloadOnFocus = false;
        return;
    }
    ChmModel* chm = tab->AsChm();
    if (chm && chm->UsesNativeHtmlWindow()) {
        chm->ClearUrlDataCache();
        if (tab == win->CurrentTab()) {
            chm->ReloadCurrentPageForThemeChange();
        } else {
            tab->reloadOnFocus = true;
        }
    }
}

static void ApplyDocumentColorModeChangeToAllTabs() {
    for (MainWindow* win : gWindows) {
        for (WindowTab* tab : win->Tabs()) {
            ApplyDocumentColorModeChangeToTab(win, tab);
        }
    }
}

void UpdateDocumentColors(bool rerender, bool updateReflowDocuments) {
    COLORREF bg;
    COLORREF text = ThemePageRenderColors(bg, true);
    COLORREF link = ThemeUsesDarkChrome() ? ThemeWindowLinkColor() : 0;
    static bool s_lastPreservePdfImagesInDarkMode = false;
    static int s_lastPreservePdfImagesMinSize = -1;
    static int s_lastPdfDarkModeRenderer = -1;
    static int s_lastPdfDocumentColorMode = -1;
    static bool s_lastThemeUsesDarkChrome = false;
    static bool s_lastThemeUsesOriginalPageColors = false;
    bool preservePdfImages = GetPdfDocumentColorMode() == PdfDocumentColorMode::Auto &&
                             GetPreservePdfImagesInDarkMode() && ThemeUsesDarkChrome();
    int preserveMinSize = preservePdfImages ? GetPreservePdfImagesMinSize() : 0;
    int pdfDarkModeRenderer = (int)GetPdfDarkModeRenderer();
    int pdfDocumentColorMode = (int)GetPdfDocumentColorMode();
    bool themeUsesDarkChrome = ThemeUsesDarkChrome();
    bool themeUsesOriginalPageColors = ThemeUsesOriginalPageColors();

    if ((text == gRenderCache->textColor) && (bg == gRenderCache->backgroundColor) &&
        (link == gRenderCache->linkColor) && preservePdfImages == s_lastPreservePdfImagesInDarkMode &&
        preserveMinSize == s_lastPreservePdfImagesMinSize && pdfDarkModeRenderer == s_lastPdfDarkModeRenderer &&
        pdfDocumentColorMode == s_lastPdfDocumentColorMode && themeUsesDarkChrome == s_lastThemeUsesDarkChrome &&
        themeUsesOriginalPageColors == s_lastThemeUsesOriginalPageColors) {
        return; // colors didn't change
    }
    s_lastPreservePdfImagesInDarkMode = preservePdfImages;
    s_lastPreservePdfImagesMinSize = preserveMinSize;
    s_lastPdfDarkModeRenderer = pdfDarkModeRenderer;
    s_lastPdfDocumentColorMode = pdfDocumentColorMode;
    s_lastThemeUsesDarkChrome = themeUsesDarkChrome;
    s_lastThemeUsesOriginalPageColors = themeUsesOriginalPageColors;

    gRenderCache->textColor = text;
    gRenderCache->backgroundColor = bg;
    gRenderCache->linkColor = link;
    gRenderCache->darkModeEpoch++;

    // Cached bitmaps are either recolored (PDF) or baked (ebooks). Marking them
    // out-of-date is not enough because RequestRendering() skips pages that
    // still Exist() in the cache, so stale tiles keep showing until scrolled.
    // Clear old work before changing EPUB CSS; otherwise OnMorePagesAvailable()
    // queues new-theme renders which are immediately canceled here.
    for (MainWindow* win : gWindows) {
        for (WindowTab* tab : win->Tabs()) {
            DisplayModel* dm = tab->AsFixed();
            if (!dm) {
                continue;
            }
            EngineMupdfInvalidateDarkMode(dm->GetEngine());
            gRenderCache->CancelRendering(dm);
            if (IsReflowableMupdfForTheme(dm->GetEngine())) {
                gRenderCache->KeepForColorTransition(dm);
            } else {
                gRenderCache->FreeForDisplayModel(dm);
            }
        }
    }

    if (updateReflowDocuments) {
        ApplyDocumentColorModeChangeToAllTabs();
    }

    if (rerender) {
        RerenderEverything();
    }
}

void UpdateFixedPageScrollbarsVisibility() {
    bool showOverlayScrollbar = ScrollbarsUseOverlay();
    auto mode = ScrollbarsOverlayMode();
    for (MainWindow* w : gWindows) {
        OverlayScrollbarSetMode(w->overlayScrollV, mode);
        OverlayScrollbarSetMode(w->overlayScrollH, mode);
        OverlayScrollbarShow(w->overlayScrollV, showOverlayScrollbar);
        OverlayScrollbarShow(w->overlayScrollH, showOverlayScrollbar);
    }
    RerenderFixedPage();
}

static void CloseAuxiliaryTopLevelWindows() {
    CloseAboutWindow();
    SafeDeleteCommandPaletteWnd();
    DeleteManualBrowserWindow();
}

static void ExitProcessAfterShutdown() {
    if (gPluginMode) {
        PostQuitMessage(0);
        return;
    }
    CloseAuxiliaryTopLevelWindows();
    ::ExitProcess(0);
}

static void TerminateApplication() {
    if (gPluginMode) {
        PostQuitMessage(0);
        return;
    }
    CloseAuxiliaryTopLevelWindows();
    while (gWindows.size() > 0) {
        MainWindow* w = gWindows.at(0);
        w->isBeingClosed = true;
        HWND hwnd = w->hwndFrame;
        TabsOnCloseWindow(w);
        DeleteMainWindow(w);
        if (hwnd && IsWindow(hwnd)) {
            DestroyWindow(hwnd);
        }
    }
    ::ExitProcess(0);
}

static void RestartApplication() {
    if (gPluginMode) {
        return;
    }
    SaveSettings();
    gDontSaveSettings = true;
    CloseAuxiliaryTopLevelWindows();
    TempStr exePath = GetSelfExePathTemp();
    if (gInstanceMutex) {
        SafeCloseHandle(&gInstanceMutex);
    }
    LaunchProcessWithCmdLine(exePath, "");
    TerminateApplication();
}

static void OnMenuExit() {
    if (gPluginMode) {
        return;
    }

    for (MainWindow* win : gWindows) {
        if (!CanCloseWindow(win)) {
            return;
        }
    }

    // we want to preserve the session state of all windows,
    // so we save it now
    // since we are closing the windows one by one,
    // CloseWindow() must not save the session state every time
    // (or we will end up with just the last window)
    SaveSettings();
    gDontSaveSettings = true;

    CloseAuxiliaryTopLevelWindows();

    // CloseWindow removes the MainWindow from gWindows,
    // so use a stable copy for iteration
    Vec<MainWindow*> toClose = gWindows;
    for (MainWindow* win : toClose) {
        CloseWindow(win, true, false);
    }

    TerminateApplication();
}

// closes a document inside a MainWindow and optionally turns it into
// about window (set keepUIEnabled if a new document will be loaded
// into the tab right afterwards and ReplaceDocumentInCurrentTab would revert
// the UI disabling afterwards anyway)
static void CloseDocumentInCurrentTab(MainWindow* win, bool keepUIEnabled, bool deleteModel) {
    if (win->ctrl && !WinCtrlIsOwnedByAnyTab(win, win->ctrl)) {
        win->ctrl = nullptr;
    }
    bool wasntFixed = !win->AsFixed();
    if (win->AsChm()) {
        win->AsChm()->RemoveParentHwnd();
    }
    ClearTocBox(win);
    AbortFinding(win, true);

    ClearMouseState(win);
    win->annotationUnderCursor = nullptr;
    win->annotationBeingDragged = nullptr;
    win->annotationBeingResized = false;

    win->fwdSearchMark.show = false;
    if (win->uiaProvider) {
        win->uiaProvider->OnDocumentUnload();
    }
    WindowTab* unloadingTab = TabForWinCtrl(win, win->ctrl);
    if (!unloadingTab) {
        unloadingTab = win->CurrentTab();
    }
    if (unloadingTab) {
        if (DisplayModel* unloadDm = unloadingTab->AsFixed()) {
            gRenderCache->CancelRendering(unloadDm);
        }
        unloadingTab->selectedAnnotation = nullptr;
        unloadingTab->selectedEbookAnnotation = nullptr;
        if (deleteModel) {
            ResetReadAloudStateForTab(unloadingTab);
        } else if (GetReadAloudSourceTab() == unloadingTab && TtsIsSpeaking()) {
            // Tab switch: pause and remember position instead of clearing read-aloud state.
            ReadAloudStopRememberPos();
        }
    }
    win->ctrl = nullptr;
    if (deleteModel) {
        if (unloadingTab) {
            SafeDeleteDocController(win, unloadingTab->ctrl);
            FileWatcherUnsubscribe(unloadingTab->watcher);
            unloadingTab->watcher = nullptr;
        }
    } else {
        win->currentTabTemp = nullptr;
    }
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifActionResponse);
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifPageInfo);
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifCursorPos);
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifZoom);

    CloseWordLookup();

    // TODO: this can cause a mouse capture to stick around when called from LoadModelIntoTab (cf. OnSelectionStop)
    win->mouseAction = MouseAction::None;

    DeletePropertiesWindow(win->hwndFrame);

    {
        // on 3.4.6 we would call DeleteOldSelectionInfo()
        // but it wouldn't delete tab->selectionOnPage because
        // win->currentTab was null. In 3.5 we changed
        // to win->GetCurrentTab() which always returns something
        // other calls to DeleteOldSelectionInfo() might
        // incorrectly clear tab->selectionOnPage
        win->showSelection = false;
        win->selectionMeasure = SizeF();
    }

    if (!keepUIEnabled) {
        SetSidebarVisibility(win, false, gGlobalPrefs->showFavorites);
        ToolbarUpdateStateForWindow(win, true);
        UpdateToolbarPageText(win, 0);
        UpdateToolbarFindText(win);
        UpdateFindbox(win);
        UpdateTabWidth(win);
        if (wasntFixed) {
            // restore the full menu and toolbar
            RebuildMenuBarForWindow(win);
            ShowOrHideToolbar(win);
        }
        if (!ScrollbarsAreHidden() && !ScrollbarsUseOverlay()) {
            ShowScrollBar(win->hwndCanvas, SB_BOTH, FALSE);
        }
        win->RedrawAll();
        HwndSetText(win->hwndFrame, kSumatraWindowTitle);
        ReportIf(win->TabCount() != 0 || win->CurrentTab());
    }

    // Note: this causes https://code.google.com/p/sumatrapdf/issues/detail?id=2702. For whatever reason
    // edit ctrl doesn't receive WM_KILLFOCUS if we do SetFocus() here, even if we call SetFocus() later on
    // HwndSetFocus(win->hwndFrame);
}

static void ShowSavedAnnotationsNotification(HWND hwndParent, const char* path) {
    StrBuilder msg;
    msg.AppendFmt(_TRA("Saved annotations to '%s'"), path);
    NotificationCreateArgs nargs;
    nargs.hwndParent = hwndParent;
    nargs.font = GetDefaultGuiFont();
    nargs.timeoutMs = 5000;
    nargs.msg = msg.Get();
    ShowNotification(nargs);
}

static void ShowSavedAnnotationsFailedNotification(HWND hwndParent, const char* path, const char* mupdfErr) {
    StrBuilder msg;
    msg.AppendFmt(_TRA("Saving of '%s' failed with: '%s'"), path, mupdfErr);
    ShowWarningNotification(hwndParent, msg.Get(), 0);
}

struct ShowErrorData {
    WindowTab* tab;
    const char* path;
};

static void ShowSaveAnnotationError(ShowErrorData* d, const char* err) {
    auto tab = d->tab;
    auto path = d->path;
    ShowSavedAnnotationsFailedNotification(tab->win->hwndCanvas, path, err);
}

bool SaveAnnotationsToExistingFile(WindowTab* tab) {
    if (!tab) {
        return false;
    }
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        return false;
    }
    EngineBase* engine = dm->GetEngine();
    if (!engine) {
        return false;
    }
    const char* path = engine->FilePath();
    tab->ignoreNextAutoReload = true;
    ShowErrorData data{tab, path};
    auto fn = MkFunc1(ShowSaveAnnotationError, &data);
    bool ok = EngineMupdfSaveUpdated(engine, nullptr, fn);
    if (!ok) {
        tab->ignoreNextAutoReload = false;
        return false;
    }
    ShowSavedAnnotationsNotification(tab->win->hwndCanvas, path);

    // have to re-open edit annotations window because the current has
    // a reference to deleted Engine
    bool hadEditAnnotations = CloseAndDeleteEditAnnotationsWindow(tab);
    ReloadDocument(tab->win, false);
    if (hadEditAnnotations) {
        // TODO: improve by remembering which annotation was selected and restoring it after  we reload
        ShowEditAnnotationsWindow(tab, nullptr);
    }

    return true;
}

static void InvokeInverseSearch(WindowTab* tab) {
    if (!tab) {
        return;
    }
    if (!gGlobalPrefs->enableTeXEnhancements) {
        return;
    }
    MainWindow* win = tab->win;
    Point pt = HwndGetCursorPos(win->hwndCanvas);
    OnInverseSearch(win, pt.x, pt.y);
}

// returns true if saved successully
bool SaveAnnotationsToMaybeNewPdfFile(WindowTab* tab) {
    if (!tab) {
        return false;
    }
    WCHAR dstFileName[MAX_PATH + 1]{};

    OPENFILENAME ofn{};
    StrBuilder fileFilter(256);
    fileFilter.Append(_TRA("PDF documents"));
    fileFilter.Append("\1*.pdf\1");
    fileFilter.Append("\1*.*\1");
    str::TransCharsInPlace(fileFilter.CStr(), "\1", "\0");
    TempWStr fileFilterW = ToWStrTemp(fileFilter);

    // TODO: automatically construct "foo.pdf" => "foo Copy.pdf"
    EngineBase* engine = tab->AsFixed()->GetEngine();
    char* srcFileName = str::Dup(engine->FilePath());
    str::BufSet(dstFileName, dimof(dstFileName), srcFileName);

    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = tab->win->hwndFrame;
    ofn.lpstrFile = dstFileName;
    ofn.nMaxFile = dimof(dstFileName);
    ofn.lpstrFilter = fileFilterW;
    ofn.nFilterIndex = 1;
    // ofn.lpstrTitle = _TRA("Rename To");
    // ofn.lpstrInitialDir = initDir;
    ofn.lpstrDefExt = L".pdf";
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY;

    bool ok = GetSaveFileNameW(&ofn);
    if (!ok) {
        str::Free(srcFileName);
        return false;
    }
    char* dstFilePath = ToUtf8Temp(dstFileName);
    bool savingToExisting = str::Eq(dstFilePath, srcFileName);
    if (savingToExisting) {
        str::Free(srcFileName);
        return SaveAnnotationsToExistingFile(tab);
    }

    ShowErrorData data{tab, dstFilePath};
    auto fn = MkFunc1(ShowSaveAnnotationError, &data);
    ok = EngineMupdfSaveUpdated(engine, dstFilePath, fn);
    if (!ok) {
        str::Free(srcFileName);
        return false;
    }

    // have to re-open edit annotations window because the current has
    // a reference to deleted Engine
    bool hadEditAnnotations = CloseAndDeleteEditAnnotationsWindow(tab);

    auto win = tab->win;
    UpdateTabFileDisplayStateForTab(tab);
    CloseDocumentInCurrentTab(win, true, true);
    HwndSetFocus(win->hwndFrame);

    char* newPath = path::NormalizeTemp(dstFilePath);
    // TODO: this should be 'duplicate FileInHistory"
    RenameFileInHistory(srcFileName, newPath);
    str::Free(srcFileName);

    LoadArgs args(newPath, win);
    args.forceReuse = true;
    LoadDocument(&args);

    ShowSavedAnnotationsNotification(win->hwndCanvas, newPath);
    if (hadEditAnnotations) {
        // TODO: improve by remembering which annotation was selected and restoring it after reload
        // could do it by index
        ShowEditAnnotationsWindow(tab, nullptr);
    }
    return true;
}

enum class SaveChoice {
    Discard,
    SaveNew,
    SaveExisting,
    Cancel,
};

SaveChoice ShouldSaveAnnotationsDialog(HWND hwndParent, const char* filePath) {
    TempStr fileName = (TempStr)path::GetBaseNameTemp(filePath);
    TempStr mainInstrA = str::FormatTemp(_TRA("Unsaved annotations in '%s'"), fileName);
    TempWStr mainInstr = ToWStrTemp(mainInstrA);
    auto content = _TRA("Save annotations?");

    constexpr int kBtnIdDiscard = 100;
    constexpr int kBtnIdSaveToExisting = 101;
    constexpr int kBtnIdSaveToNew = 102;
    // constexpr int kBtnIdCancel = 103;
    TASKDIALOGCONFIG dialogConfig{};
    TASKDIALOG_BUTTON buttons[4];

    buttons[0].nButtonID = kBtnIdSaveToExisting;
    auto s = _TRA("&Save to existing PDF");
    buttons[0].pszButtonText = ToWStrTemp(s);
    buttons[1].nButtonID = kBtnIdSaveToNew;
    s = _TRA("Save to &new PDF");
    buttons[1].pszButtonText = ToWStrTemp(s);
    buttons[2].nButtonID = kBtnIdDiscard;
    s = _TRA("&Discard changes");
    buttons[2].pszButtonText = ToWStrTemp(s);
    buttons[3].nButtonID = IDCANCEL;
    s = _TRA("&Cancel");
    buttons[3].pszButtonText = ToWStrTemp(s);

    DWORD flags =
        TDF_ALLOW_DIALOG_CANCELLATION | TDF_SIZE_TO_CONTENT | TDF_ENABLE_HYPERLINKS | TDF_POSITION_RELATIVE_TO_WINDOW;
    if (trans::IsCurrLangRtl()) {
        flags |= TDF_RTL_LAYOUT;
    }
    dialogConfig.cbSize = sizeof(TASKDIALOGCONFIG);
    s = _TRA("Unsaved annotations");
    dialogConfig.pszWindowTitle = ToWStrTemp(s);
    dialogConfig.pszMainInstruction = mainInstr;
    dialogConfig.pszContent = ToWStrTemp(content);
    dialogConfig.nDefaultButton = IDCANCEL;
    dialogConfig.dwFlags = flags;
    dialogConfig.cxWidth = 0;
    dialogConfig.pfCallback = nullptr;
    dialogConfig.dwCommonButtons = 0;
    dialogConfig.cButtons = dimof(buttons);
    dialogConfig.pButtons = &buttons[0];
    dialogConfig.pszMainIcon = TD_INFORMATION_ICON;
    dialogConfig.hwndParent = hwndParent;

    int buttonPressedId = 0;

    auto hr = TaskDialogIndirect(&dialogConfig, &buttonPressedId, nullptr, nullptr);
    ReportIf(hr == E_INVALIDARG);
    bool discard = (hr != S_OK) || (buttonPressedId == kBtnIdDiscard);
    if (discard) {
        return SaveChoice::Discard;
    }
    switch (buttonPressedId) {
        case kBtnIdSaveToExisting:
            return SaveChoice::SaveExisting;
        case kBtnIdSaveToNew:
            return SaveChoice::SaveNew;
        case kBtnIdDiscard:
            return SaveChoice::Discard;
        case IDCANCEL:
            return SaveChoice::Cancel;
    }
    ReportIf(true);
    return SaveChoice::Cancel;
}

// if returns true, can proceed with closing
// if returns false, should cancel closing
static bool MaybeSaveAnnotations(WindowTab* tab) {
    if (!tab) {
        return true;
    }
    // TODO: hacky because CloseCurrentTab() can call CloseWindow() and
    // they both ask to save annotations
    // Could determine in CloseCurrentTab() if will CloseWindow() and
    // not ask
    if (tab->askedToSaveAnnotations) {
        return true;
    }

    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        return true;
    }
    EngineBase* engine = dm->GetEngine();
    // shouldn't really happen but did happen.
    // don't block stress testing if opening a document flags it hasving unsaved annotations
    if (IsStressTesting()) {
        return true;
    }
    // if the file no longer exists (e.g. USB removed, network drive disconnected),
    // don't try to access it - engine uses memory-mapped I/O and accessing
    // pages of a gone file causes EXCEPTION_IN_PAGE_ERROR
    auto filePath = dm->GetFilePath();
    if (!file::Exists(filePath)) {
        logf("MaybeSaveAnnotations: file '%s' no longer exists, skipping\n", filePath);
        return true;
    }
    bool shouldConfirm = EngineHasUnsavedAnnotations(engine);
    if (!shouldConfirm) {
        return true;
    }
    tab->askedToSaveAnnotations = true;
    MainWindow* win = tab->win;
    auto path = dm->GetFilePath();
    auto choice = ShouldSaveAnnotationsDialog(win->hwndFrame, path);
    // the dialog pumps messages; during that, the window can be destroyed
    // (e.g. WM_CLOSE from plugin host) which frees win and tab
    if (!IsMainWindowValid(win)) {
        return true;
    }
    switch (choice) {
        case SaveChoice::Discard:
            return true;
        case SaveChoice::SaveNew: {
            bool didSave = SaveAnnotationsToMaybeNewPdfFile(tab);
            if (!didSave) {
                tab->askedToSaveAnnotations = false;
            }
            return didSave;
        }
        case SaveChoice::SaveExisting: {
            // const char* path = engine->FileName();
            ShowErrorData data{tab, path};
            auto fn = MkFunc1(ShowSaveAnnotationError, &data);
            bool ok = EngineMupdfSaveUpdated(engine, nullptr, fn);
        } break;
        case SaveChoice::Cancel:
            tab->askedToSaveAnnotations = false;
            return false;
        default:
            ReportIf(true);
    }
    return true;
}

void CloseTab(WindowTab* tab, bool quitIfLast) {
    if (!tab) {
        return;
    }
    MainWindow* win = tab->win;
    logf("CloseTab: tab: 0x%p win: 0x%p, hwndFrame: 0x%x, quitIfLast: %d, dm: 0x%p\n", tab, win, win->hwndFrame,
         (int)quitIfLast, tab->AsFixed());

    AbortFinding(win, true);
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifPageInfo);
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifAnnotation);
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifZoom);

    RememberRecentlyClosedDocument(tab->filePath);

    // TODO: maybe should have a way to over-ride this for unconditional close?
    bool canClose = MaybeSaveAnnotations(tab);
    if (!canClose) {
        return;
    }
    if (DisplayModel* dm = tab->AsFixed()) {
        dm->pauseRendering = true;
    }
    // MaybeSaveAnnotations() can show a dialog that pumps messages.
    // During message pumping, the window might be destroyed
    // (e.g., WM_DESTROY from a plugin host). If so, everything
    // is already cleaned up by the reentrant CloseWindow().
    if (!IsMainWindowValid(win)) {
        return;
    }

    // Stop eventual TTS reading
    StopReadAloudIfSourceTab(tab);

    if (tab->IsAboutTab()) {
        if (SettingsUseTabs() && !gGlobalPrefs->noHomeTab) {
            if (!HasOpenedDocuments(win) && CanCloseWindow(win)) {
                CloseWindow(win, quitIfLast, false);
            }
            return;
        }
    }

    int tabCount = win->TabCount();
    if (tabCount == 1 || (tabCount == 0 && quitIfLast)) {
        if (CanCloseWindow(win)) {
            CloseWindow(win, quitIfLast, false);
            return;
        }
    } else {
        ReportIf(gPluginMode && !gWindows.Contains(win));
        RemoveTab(tab);
        // RemoveTab -> LoadModelIntoTab can pump messages, potentially destroying win
        // and its cbHandler. Since tab was already removed from win's tab list,
        // ~MainWindow won't delete it, so we must delete it here.
        // Null out cb to prevent dangling pointer access in ~DisplayModel.
        if (!IsMainWindowValid(win)) {
            if (tab->ctrl) {
                tab->ctrl->cb = nullptr;
            }
            delete tab;
            return;
        }
        delete tab;
    }

    if (!IsMainWindowValid(win)) {
        return;
    }

    SaveSettings();
}

// closes the current tab, selecting the next one
// if on the pinned home tab with no document tabs, closes the window
void CloseCurrentTab(MainWindow* win, bool quitIfLast) {
    WindowTab* tab = win->CurrentTab();
    logf("CloseCurrentTab: tab: 0x%p win: 0x%p, hwndFrame: 0x%x, quitIfLast: %d\n", tab, win, win->hwndFrame,
         (int)quitIfLast);
    if (tab && tab->IsAboutTab() && SettingsUseTabs() && !gGlobalPrefs->noHomeTab && !HasOpenedDocuments(win)) {
        if (CanCloseWindow(win)) {
            CloseWindow(win, true, false);
        }
        return;
    }
    if (tab) {
        CloseTab(tab, quitIfLast);
    } else {
        // Legacy tabless home page (tabs disabled or NoHomeTab).
        CloseWindow(win, true, false);
    }
}

bool CanCloseWindow(MainWindow* win) {
    if (!win) {
        return false;
    }
    // a plugin window should only be closed when its parent is destroyed
    if (gPluginMode && !gWindows.Contains(win)) {
        return false;
    }

    if (win->printThread && !win->printCanceled && WaitForSingleObject(win->printThread, 0) == WAIT_TIMEOUT) {
        UINT flags = MB_ICONEXCLAMATION | MB_YESNO | MbRtlReadingMaybe();
        auto caption = _TRA("Printing in progress.");
        auto msg = _TRA("Printing is still in progress. Abort and quit?");
        int res = MsgBox(win->hwndFrame, msg, caption, flags);
        if (IDNO == res) {
            return false;
        }
    }

    return true;
}

/* Close the documents associated with window 'hwnd'.
   Closes the window unless this is the last window in which
   case it switches to empty window and disables the "File\Close"
   menu item. */
void CloseWindow(MainWindow* win, bool quitIfLast, bool forceClose) {
    if (!win) {
        return;
    }
    // guard against reentrant CloseWindow calls triggered by Windows theme
    // system message pumping (uxtheme.dll). The forceClose=true path from
    // WM_DESTROY is the expected reentrant cleanup and must still proceed.
    // quitIfLast=true (user clicked X / File->Exit) must also proceed so a
    // prior in-progress close cannot block application shutdown.
    if (win->isBeingClosed && !forceClose && !quitIfLast) {
        return;
    }
    logf("CloseWindow: win: 0x%p, hwndFrame: 0x%x, quitIfLast: %d, forceClose: %d\n", win, win->hwndFrame,
         (int)quitIfLast, (int)forceClose);
    win->isBeingClosed = true;
    ReportIf(forceClose && !quitIfLast);
    if (forceClose) {
        quitIfLast = true;
    }

    // when used as an embedded plugin, closing should happen automatically
    // when the parent window is destroyed (cf. WM_DESTROY)
    if (gPluginMode && !gWindows.Contains(win) && !forceClose) {
        win->isBeingClosed = false;
        return;
    }

    AbortFinding(win, true);
    AbortPrinting(win);

    for (auto& tab : win->Tabs()) {
        if (tab->AsFixed()) {
            tab->AsFixed()->pauseRendering = true;
        }
    }

    if (win->presentation) {
        ExitFullScreen(win);
    }

    // Stop eventual TTS reading
    StopReadAloudIfSourceWindow(win);

    bool canCloseWindow = true;
    for (auto& tab : win->Tabs()) {
        bool canCloseTab = MaybeSaveAnnotations(tab);
        if (!canCloseTab) {
            canCloseWindow = false;
        }
        // MaybeSaveAnnotations() can show a dialog that pumps messages.
        // During message pumping, the window might be destroyed by a
        // reentrant CloseWindow() call (e.g., from WM_DESTROY).
        if (!IsMainWindowValid(win)) {
            if (gWindows.size() == 0) {
                ExitProcessAfterShutdown();
            }
            return;
        }
    }

    // TODO: should be more intelligent i.e. close the tabs we can and only
    // leave those where user cancelled closing
    // would have to remember a list of tabs to not close above
    // if list not empty, only close the tabs not on the list
    if (!canCloseWindow) {
        win->isBeingClosed = false;
        for (auto& tab : win->Tabs()) {
            if (tab->AsFixed()) {
                tab->AsFixed()->pauseRendering = false;
            }
        }
        return;
    }

    bool lastWindow = (1 == gWindows.size());
    // RememberDefaultWindowPosition becomes a no-op once the window is hidden
    RememberDefaultWindowPosition(win);

    // if this is a last window, save state before closing window
    // if not last, save after closing window (#5418)
    if (lastWindow) {
        SaveSettings();
    }
    // Detach tree items before tab/engine destruction frees TocItem memory.
    ClearTocBox(win);
    TabsOnCloseWindow(win);

    if (!IsMainWindowValid(win)) {
        if (gWindows.size() == 0) {
            ExitProcessAfterShutdown();
        }
        return;
    }

    if (forceClose) {
        // WM_DESTROY has already been sent, so don't destroy win->hwndFrame again
        DeleteMainWindow(win);
    } else if (lastWindow && !quitIfLast) {
        /* last window - don't delete it */
        CloseDocumentInCurrentTab(win, false, false);
        win->isBeingClosed = false;
        HwndSetFocus(win->hwndFrame);
        ReportIf(!gWindows.Contains(win));
    } else {
        HWND hwnd = win->hwndFrame;
        // hide immediately before destroying so we don't leave a hidden zombie window
        // if SaveSettings or other work above pumped messages and re-entered CloseWindow
        if (!lastWindow || quitIfLast) {
            ShowWindow(hwnd, SW_HIDE);
        }
        DeleteMainWindow(win);
        DestroyWindow(hwnd);
    }

    if (!lastWindow) {
        SaveSettings();
    }

    if (gWindows.size() == 0) {
        ExitProcessAfterShutdown();
    }
}

// returns false if no filter has been appended
static bool AppendFileFilterForDoc(DocController* ctrl, StrBuilder& fileFilter) {
    // TODO: use ctrl->GetDefaultFileExt()
    Kind type = nullptr;
    if (ctrl->AsFixed()) {
        type = ctrl->AsFixed()->engineType;
    } else if (ctrl->AsChm()) {
        type = kindEngineChm;
    }

    auto ext = ctrl->GetDefaultFileExt();
    if (str::EqI(ext, ".xps")) {
        fileFilter.Append(_TRA("XPS documents"));
    } else if (str::EqI(ext, ".epub")) {
        // .epub can be handled by kindEngineMupdf
        fileFilter.Append(_TRA("EPUB ebooks"));
    } else if (type == kindEngineDjVu) {
        fileFilter.Append(_TRA("DjVu documents"));
    } else if (type == kindEngineComicBooks) {
        fileFilter.Append(_TRA("Comic books"));
    } else if (type == kindEngineImage) {
        WCHAR* extW = ToWStrTemp(ctrl->GetDefaultFileExt() + 1);
        fileFilter.AppendFmt(_TRA("Image files (*.%s)"), extW);
    } else if (type == kindEngineImageDir) {
        return false; // only show "All files"
    } else if (type == kindEnginePostScript) {
        fileFilter.Append(_TRA("Postscript documents"));
    } else if (type == kindEngineChm) {
        fileFilter.Append(_TRA("CHM documents"));
    } else if (type == kindEngineEpub) {
        fileFilter.Append(_TRA("EPUB ebooks"));
    } else if (type == kindEngineMobi) {
        fileFilter.Append(_TRA("Mobi documents"));
    } else if (type == kindEngineFb2) {
        fileFilter.Append(_TRA("FictionBook documents"));
    } else if (type == kindEnginePdb) {
        fileFilter.Append(_TRA("PalmDoc documents"));
    } else if (type == kindEngineTxt) {
        fileFilter.Append(_TRA("Text documents"));
    } else {
        fileFilter.Append(_TRA("PDF documents"));
    }
    return true;
}

static void SaveCurrentFileAs(MainWindow* win) {
    if (!CanAccessDisk()) {
        return;
    }
    if (!win->IsDocLoaded()) {
        return;
    }

    auto* ctrl = win->ctrl;
    TempStr srcFileName = (TempStr)ctrl->GetFilePath();
    if (gPluginMode) {
        // fall back to a generic "filename" instead of the more confusing temporary filename
        srcFileName = (TempStr) "filename";
        TempStr urlName = url::GetFileNameTemp(gPluginURL);
        if (urlName) {
            srcFileName = urlName;
        }
    }

    if (!srcFileName) {
        ShowTemporaryNotification(win->hwndCanvas, _TRA("File path not available"), kNotif5SecsTimeOut);
        return;
    }

    DisplayModel* dm = win->AsFixed();
    EngineBase* engine = dm ? dm->GetEngine() : nullptr;
    if (EngineHasUnsavedAnnotations(engine)) {
        SaveAnnotationsToMaybeNewPdfFile(win->CurrentTab());
        return;
    }

    auto defExt = ctrl->GetDefaultFileExt();
    // Prepare the file filters (use \1 instead of \0 so that the
    // double-zero terminated string isn't cut by the string handling
    // methods too early on)
    StrBuilder fileFilter(256);
    if (AppendFileFilterForDoc(ctrl, fileFilter)) {
        fileFilter.AppendFmt("\1*%s\1", defExt);
    }
    fileFilter.Append(_TRA("All files"));
    fileFilter.Append("\1*.*\1");
    str::TransCharsInPlace(fileFilter.CStr(), "\1", "\0");

    WCHAR dstFileName[MAX_PATH];
    TempStr baseName = path::GetBaseNameTemp(srcFileName);
    str::BufSet(dstFileName, dimof(dstFileName), baseName);
    if (str::FindChar(dstFileName, ':')) {
        // handle embed-marks (for embedded PDF documents):
        // remove the container document's extension and include
        // the embedding reference in the suggested filename
        WCHAR* colon = (WCHAR*)str::FindChar(dstFileName, ':');
        str::TransCharsInPlace(colon, L":", L"_");
        WCHAR* ext;
        for (ext = colon; ext > dstFileName && *ext != '.'; ext--) {
            // no-op
        }
        if (ext == dstFileName) {
            ext = colon;
        }
        memmove(ext, colon, (str::Len(colon) + 1) * sizeof(WCHAR));
    } else if (str::EndsWithI(dstFileName, ToWStrTemp(defExt))) {
        // Remove the extension so that it can be re-added depending on the chosen filter
        int idx = str::Leni(dstFileName) - str::Leni(defExt);
        dstFileName[idx] = '\0';
    }

    OPENFILENAME ofn{};
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = win->hwndFrame;
    ofn.lpstrFile = dstFileName;
    ofn.nMaxFile = dimof(dstFileName);
    ofn.lpstrFilter = ToWStrTemp(fileFilter);
    ofn.nFilterIndex = 1;
    // defExt can be null, we want to skip '.'
    if (str::Leni(defExt) > 0 && defExt[0] == L'.') {
        defExt++;
    }
    ofn.lpstrDefExt = ToWStrTemp(defExt);
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY;
    // note: explicitly not setting lpstrInitialDir so that the OS
    // picks a reasonable default (in particular, we don't want this
    // in plugin mode, which is likely the main reason for saving as...)

    bool ok = GetSaveFileNameW(&ofn);
    if (!ok) {
        return;
    }

    // GetSaveFileNameW() runs a modal dialog that pumps messages.
    // During the dialog, a file watcher notification can trigger ReloadDocument(),
    // destroying the old engine and invalidating srcFileName, defExt, engine pointers.
    // Re-acquire everything from the (potentially new) controller.
    if (!win->IsDocLoaded()) {
        return;
    }
    ctrl = win->ctrl;
    srcFileName = (TempStr)ctrl->GetFilePath();
    if (gPluginMode) {
        srcFileName = (TempStr) "filename";
        TempStr urlName = url::GetFileNameTemp(gPluginURL);
        if (urlName) {
            srcFileName = urlName;
        }
    }
    if (!srcFileName) {
        ShowTemporaryNotification(win->hwndCanvas, _TRA("File path not available"), kNotif5SecsTimeOut);
        return;
    }
    defExt = ctrl->GetDefaultFileExt();
    if (str::Leni(defExt) > 0 && defExt[0] == '.') {
        defExt++;
    }
    dm = win->AsFixed();
    engine = dm ? dm->GetEngine() : nullptr;

    TempStr realDstFileName = ToUtf8Temp(dstFileName);

    // Make sure that the file has a valid extension
    if (!str::EndsWithI(realDstFileName, defExt)) {
        realDstFileName = str::JoinTemp(realDstFileName, defExt);
    }

    logf("Saving '%s' to '%s'\n", srcFileName, realDstFileName);

    // TODO: engine->SaveFileA() is stupid
    // Replace with EngineGetDocumentData() and save that if not empty
    TempStr errorMsg = nullptr;
    if (!file::Exists(srcFileName) && engine) {
        // Recreate inexistant files from memory...
        logf("calling engine->SaveFileAs(%s)\n", realDstFileName);
        ok = engine->SaveFileAs(realDstFileName);
    } else if (!path::IsSame(srcFileName, realDstFileName)) {
        ok = file::Copy(realDstFileName, srcFileName, false);
        if (ok) {
            // Make sure that the copy isn't write-locked or hidden
            const DWORD attributesToDrop = FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM;
            DWORD attributes = file::GetAttributes(realDstFileName);
            if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & attributesToDrop)) {
                file::SetAttributes(realDstFileName, attributes & ~attributesToDrop);
            }
        } else {
            TempStr s = GetLastErrorStrTemp();
            if (str::Leni(s) > 0) {
                errorMsg = str::FormatTemp("%s\n\n%s", _TRA("Failed to save a file"), s);
            }
        }
    }
    if (!ok) {
        TempStr msg = (errorMsg != nullptr) ? errorMsg : (TempStr)_TRA("Failed to save a file");
        logf("SaveCurrentFileAs() failed with '%s'\n", msg);
        MessageBoxWarning(win->hwndFrame, msg);
    }

    auto path = ctrl->GetFilePath();
    if (ok && IsUntrustedFile(path, gPluginURL)) {
        file::SetZoneIdentifier(realDstFileName);
    }
}

void SumatraOpenPathInDefaultFileManager(const char* path) {
    if (gPluginMode || !CanAccessDisk()) {
        return;
    }
    OpenPathInDefaultFileManager(path);
}

static void ShowCurrentFileInFolder(MainWindow* win) {
    if (!win->IsDocLoaded()) {
        return;
    }
    auto* ctrl = win->ctrl;
    SumatraOpenPathInDefaultFileManager(ctrl->GetFilePath());
}

static void DeleteCurrentFile(MainWindow* win) {
    if (!CanAccessDisk()) {
        return;
    }
    if (!win->IsDocLoaded()) {
        return;
    }
    if (gPluginMode) {
        return;
    }
    auto* ctrl = win->ctrl;
    const char* path = str::DupTemp(ctrl->GetFilePath());
    // this happens e.g. for embedded documents and directories
    if (!file::Exists(path)) {
        return;
    }
    CloseCurrentTab(win, false);
    file::DeleteFileToTrash(path);
    DeleteThumbnailForFile(path);
    FileState* fs = gFileHistory.FindByPath(path);
    if (fs) {
        gFileHistory.Remove(fs);
        DeleteFileState(fs);
    }
    SaveSettings();
    // CloseCurrentTab may have destroyed the window if it had no more tabs
    if (IsMainWindowValid(win)) {
        win->RedrawAll(true);
    }
}

static void RenameCurrentFile(MainWindow* win) {
    if (!CanAccessDisk()) {
        return;
    }
    if (!win->IsDocLoaded()) {
        return;
    }
    if (gPluginMode) {
        return;
    }

    auto* ctrl = win->ctrl;
    const char* srcPath = str::DupTemp(ctrl->GetFilePath());
    // this happens e.g. for embedded documents and directories
    if (!file::Exists(srcPath)) {
        return;
    }

    // Prepare the file filters (use \1 instead of \0 so that the
    // double-zero terminated string isn't cut by the string handling
    // methods too early on)
    const char* defExt = ctrl->GetDefaultFileExt();
    TempWStr defExtW = ToWStrTemp(defExt);
    StrBuilder fileFilter(256);
    bool ok = AppendFileFilterForDoc(ctrl, fileFilter);
    ReportIf(!ok);
    fileFilter.AppendFmt("\1*%s\1", defExt);
    str::TransCharsInPlace(fileFilter.Get(), "\1", "\0");

    WCHAR dstFilePathW[MAX_PATH];
    auto baseName = path::GetBaseNameTemp(srcPath);
    str::BufSet(dstFilePathW, dimof(dstFilePathW), baseName);
    // Remove the extension so that it can be re-added depending on the chosen filter
    if (str::EndsWithI(dstFilePathW, defExtW)) {
        int idx = str::Leni(dstFilePathW) - str::Leni(defExtW);
        dstFilePathW[idx] = '\0';
    }

    WCHAR* srcPathW = ToWStrTemp(srcPath);
    WCHAR* initDir = path::GetDirTemp(srcPathW);

    OPENFILENAME ofn{};
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = win->hwndFrame;
    ofn.lpstrFile = dstFilePathW;
    ofn.nMaxFile = dimof(dstFilePathW);
    ofn.lpstrFilter = ToWStrTemp(fileFilter);
    ofn.nFilterIndex = 1;
    // note: the other two dialogs are named "Open" and "Save As"
    auto s = _TRA("Rename To");
    ofn.lpstrTitle = ToWStrTemp(s);
    ofn.lpstrInitialDir = initDir;
    ofn.lpstrDefExt = defExtW + 1;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY;

    ok = GetSaveFileNameW(&ofn);
    if (!ok) {
        return;
    }
    TempStr dstFilePath = ToUtf8Temp(dstFilePathW);
    TempStr dstPathNormalized = path::NormalizeTemp(dstFilePath);
    TempStr srcPathNormalized = path::NormalizeTemp(srcPath);
    if (path::IsSame(srcPathNormalized, dstPathNormalized)) {
        return;
    }

    UpdateTabFileDisplayStateForTab(win->CurrentTab());
    CloseDocumentInCurrentTab(win, true, true);
    HwndSetFocus(win->hwndFrame);

    DWORD flags = MOVEFILE_COPY_ALLOWED | MOVEFILE_REPLACE_EXISTING;
    BOOL moveOk = MoveFileExW(srcPathW, dstFilePathW, flags);
    if (!moveOk) {
        LogLastError();
        LoadArgs args(srcPath, win);
        args.forceReuse = true;
        LoadDocument(&args);
        NotificationCreateArgs nargs;
        nargs.hwndParent = win->hwndCanvas;
        nargs.msg = _TRA("Failed to rename the file!");
        nargs.warning = true;
        nargs.timeoutMs = 0;
        ShowNotification(nargs);
        return;
    }
    RenameFileInHistory(srcPath, dstPathNormalized);

    LoadArgs args(dstPathNormalized, win);
    args.forceReuse = true;
    LoadDocument(&args);
}

static void CreateLnkShortcut(MainWindow* win) {
    if (!CanAccessDisk() || gPluginMode) {
        return;
    }
    if (!win->IsDocLoaded()) {
        return;
    }

    auto* ctrl = win->ctrl;
    const char* path = ctrl->GetFilePath();

    const WCHAR* defExt = ToWStrTemp(ctrl->GetDefaultFileExt());

    WCHAR dstFileName[MAX_PATH] = {};
    // Remove the extension so that it can be replaced with .lnk
    auto name = path::GetBaseNameTemp(path);
    str::BufSet(dstFileName, dimof(dstFileName), name);
    str::TransCharsInPlace(dstFileName, L":", L"_");
    if (str::EndsWithI(dstFileName, defExt)) {
        int idx = str::Leni(dstFileName) - str::Leni(defExt);
        dstFileName[idx] = '\0';
    }

    // Prepare the file filters (use \1 instead of \0 so that the
    // double-zero terminated string isn't cut by the string handling
    // methods too early on)
    StrBuilder fileFilter;
    fileFilter.AppendFmt("%s\1*.lnk\1", _TRA("Bookmark Shortcuts"));
    str::TransCharsInPlace(fileFilter.CStr(), "\1", "\0");
    TempWStr fileFilterW = ToWStrTemp(fileFilter);

    OPENFILENAME ofn{};
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = win->hwndFrame;
    ofn.lpstrFile = dstFileName;
    ofn.nMaxFile = dimof(dstFileName);
    ofn.lpstrFilter = fileFilterW;
    ofn.nFilterIndex = 1;
    ofn.lpstrDefExt = L"lnk";
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY;

    if (!GetSaveFileNameW(&ofn)) {
        return;
    }

    char* fileName = ToUtf8Temp(dstFileName);
    if (!str::EndsWithI(fileName, ".lnk")) {
        fileName = str::JoinTemp(fileName, ".lnk");
    }

    ScrollState ss(win->ctrl->CurrentPageNo(), 0, 0);
    if (win->AsFixed()) {
        ss = win->AsFixed()->GetScrollState();
    }
    const char* viewMode = DisplayModeToString(ctrl->GetDisplayMode());
    TempStr zoomVirtual = str::FormatTemp("%.2f", ctrl->GetZoomVirtual());
    if (kZoomFitPage == ctrl->GetZoomVirtual()) {
        zoomVirtual = (TempStr) "fitpage";
    } else if (kZoomFitWidth == ctrl->GetZoomVirtual()) {
        zoomVirtual = (TempStr) "fitwidth";
    } else if (kZoomFitContent == ctrl->GetZoomVirtual()) {
        zoomVirtual = (TempStr) "fitcontent";
    }

    TempStr args = str::FormatTemp("\"%s\" -page %d -view \"%s\" -zoom %s -scroll %d,%d", path, ss.page, viewMode,
                                   zoomVirtual, (int)ss.x, (int)ss.y);
    TempStr label = ctrl->GetPageLabeTemp(ss.page);
    TempStr desc = str::FormatTemp(_TRA("Bookmark shortcut to page %s of %s"), label, path);
    auto exePath = GetSelfExePathTemp();
    CreateShortcut(fileName, exePath, args, desc, 1);
}

#if 0
// code adapted from https://support.microsoft.com/kb/131462/en-us
static UINT_PTR CALLBACK FileOpenHook(HWND hDlg, UINT uiMsg, WPARAM wp, LPARAM lp)
{
    switch (uiMsg) {
    case WM_INITDIALOG:
        SetWindowLongPtr(hDlg, GWLP_USERDATA, (LONG_PTR)lp);
        break;
    case WM_NOTIFY:
        if (((LPOFNOTIFY)lp)->hdr.code == CDN_SELCHANGE) {
            LPOPENFILENAME lpofn = (LPOPENFILENAME)GetWindowLongPtr(hDlg, GWLP_USERDATA);
            // make sure that the filename buffer is large enough to hold
            // all the selected filenames
            int cbLength = CommDlg_OpenSave_GetSpec(GetParent(hDlg), nullptr, 0) + MAX_PATH;
            if (cbLength >= 0 && lpofn->nMaxFile < (DWORD)cbLength) {
                WCHAR *oldBuffer = lpofn->lpstrFile;
                lpofn->lpstrFile = (LPWSTR)realloc(lpofn->lpstrFile, cbLength * sizeof(WCHAR));
                if (lpofn->lpstrFile)
                    lpofn->nMaxFile = cbLength;
                else
                    lpofn->lpstrFile = oldBuffer;
            }
        }
        break;
    }

    return 0;
}
#endif

void DuplicateTabInNewWindow(WindowTab* tab) {
    if (!tab || tab->IsAboutTab()) {
        return;
    }
    // so that the file is opened in the same state
    SaveSettings();

    const char* path = tab->filePath;
    ReportIf(!path);
    if (!path) {
        return;
    }
    MainWindow* newWin = CreateAndShowMainWindow(nullptr);
    if (!newWin) {
        return;
    }

    // TODO: should copy the display state from current file
    LoadArgs args(path, newWin);
    args.showWin = true;
    args.noPlaceWindow = true;
    args.activateExisting = false;
    LoadDocument(&args);
}

// create a new window and load currently shown document into it
// meant to make it easy to compare 2 documents
static void DuplicateInNewWindow(MainWindow* win) {
    if (win->IsCurrentTabAbout()) {
        return;
    }
    if (!win->IsDocLoaded()) {
        return;
    }
    WindowTab* tab = win->CurrentTab();
    DuplicateTabInNewWindow(tab);
}

// create a new tab in current window and load currently shown document into it
// meant to make it easy to compare 2 documents side by side
static void DuplicateInNewTab(MainWindow* win) {
    if (win->IsCurrentTabAbout()) {
        return;
    }
    if (!win->IsDocLoaded()) {
        return;
    }
    WindowTab* currentTab = win->CurrentTab();
    if (!currentTab || !currentTab->filePath) {
        return;
    }

    const char* path = currentTab->filePath;
    ReportIf(!path);
    if (!path) {
        return;
    }

    // Save current window/tab state before loading new tab
    SaveSettings();

    // TODO: should copy the display state from current file
    LoadArgs args(path, win);
    args.showWin = true;
    args.noPlaceWindow = true;
    args.forceReuse = false; // Force creation of new tab instead of reusing current
    args.activateExisting = false;
    LoadDocument(&args);
}

static void GetFilesFromGetOpenFileName(OPENFILENAMEW* ofn, StrVec& filesOut) {
    WCHAR* dir = ofn->lpstrFile;
    WCHAR* file = ofn->lpstrFile + ofn->nFileOffset;
    // only a single file, full path
    char* path;
    if (file[-1] != 0) {
        path = ToUtf8Temp(dir);
        filesOut.Append(path);
        return;
    }
    // the layout of lpstrFile is:
    // <dir> 0 <file1> 0 <file2> 0 0
    while (*file) {
        path = ToUtf8Temp(path::JoinTemp(dir, file));
        filesOut.Append(path);
        file += str::Leni(file) + 1;
    }
}

static TempWStr GetFileFilterTemp() {
    const struct {
        const char* name; /* nullptr if only to include in "All supported documents" */
        const char* filter;
        bool available;
    } fileFormats[] = {
        {_TRA("PDF documents"), "*.pdf;*.p7m", true},
        {_TRA("XPS documents"), "*.xps;*.oxps", true},
        {_TRA("DjVu documents"), "*.djvu", true},
        {_TRA("Postscript documents"), "*.ps;*.eps", IsEnginePsAvailable()},
        {_TRA("Comic books"), "*.cbz;*.cbr;*.cb7;*.cbt", true},
        {_TRA("CHM documents"), "*.chm", true},
        {_TRA("SVG documents"), "*.svg", true},
        {_TRA("EPUB ebooks"), "*.epub", true},
        {_TRA("Mobi documents"), "*.mobi", true},
        {_TRA("FictionBook documents"), "*.fb2;*.fb2z;*.zfb2;*.fb2.zip", true},
        {_TRA("PalmDoc documents"), "*.pdb;*.prc", true},
        {_TRA("Images"), "*.bmp;*.dib;*.gif;*.jpg;*.jpeg;*.jxr;*.png;*.tga;*.tif;*.tiff;*.webp;*.heic;*.avif", true},
        {_TRA("Text documents"), "*.txt;*.log;*.nfo;file_id.diz;read.me;*.tcr", true},
        {_TRA("Markdown documents"), "*.md;*.markdown", true},
    };
    // Prepare the file filters (use \1 instead of \0 so that the
    // double-zero terminated string isn't cut by the string handling
    // methods too early on)
    StrBuilder fileFilter;
    fileFilter.Append(_TRA("All supported documents"));
    fileFilter.AppendChar('\1');
    for (int i = 0; i < dimof(fileFormats); i++) {
        if (fileFormats[i].available) {
            fileFilter.Append(fileFormats[i].filter);
            fileFilter.AppendChar(';');
        }
    }
    ReportIf(fileFilter.Last() != ';');
    fileFilter.Last() = '\1';
    for (int i = 0; i < dimof(fileFormats); i++) {
        if (fileFormats[i].available && fileFormats[i].name) {
            fileFilter.Append(fileFormats[i].name);
            fileFilter.AppendChar('\1');
            fileFilter.Append(fileFormats[i].filter);
            fileFilter.AppendChar('\1');
        }
    }
    fileFilter.Append(_TRA("All files"));
    fileFilter.Append("\1*.*\1");
    str::TransCharsInPlace(fileFilter.CStr(), "\1", "\0");
    return ToWStrTemp(fileFilter);
}

static void OpenFile(MainWindow* win) {
    if (!CanAccessDisk()) {
        return;
    }

    // don't allow opening different files in plugin mode
    if (gPluginMode) {
        return;
    }

    OPENFILENAMEW ofn{};
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = win->hwndFrame;

    ofn.lpstrFilter = GetFileFilterTemp();
    ofn.nFilterIndex = 1;
    ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_ALLOWMULTISELECT | OFN_EXPLORER;

    // OFN_ENABLEHOOK disables the new Open File dialog under Windows Vista
    // and later, so don't use it and just allocate enough memory to contain
    // several dozen file paths and hope that this is enough
    // TODO: Use IFileOpenDialog instead (requires a Vista SDK, though)
    ofn.nMaxFile = MAX_PATH * 100;
    if (false && !IsWindowsVistaOrGreater()) {
#if 0
        ofn.lpfnHook = FileOpenHook;
        ofn.Flags |= OFN_ENABLEHOOK;
        ofn.nMaxFile = MAX_PATH / 2;
#endif
    }
    // note: ofn.lpstrFile can be reallocated by GetOpenFileName -> FileOpenHook

    AutoFreeWStr file = AllocArray<WCHAR>(ofn.nMaxFile);
    ofn.lpstrFile = file;

    if (!GetOpenFileNameW(&ofn)) {
        return;
    }

    StrVec files;
    GetFilesFromGetOpenFileName(&ofn, files);
    for (char* path : files) {
        LoadArgs args(path, win);
        SetUserOpenActivateExisting(args);
        LoadDocument(&args);
    }
}

static void RemoveFailedFiles(StrVec& files) {
    for (char* path : gFilesFailedToOpen) {
        int idx = files.Find(path);
        if (idx >= 0) {
            files.RemoveAt(idx);
        }
    }
}

static StrVec& CollectNextPrevFilesIfChanged(const char* path) {
    StrVec& files = gNextPrevDirCache;

    char* dir = path::GetDirTemp(path);
    if (path::IsSame(dir, gNextPrevDir)) {
        // failed files could have changed
        RemoveFailedFiles(files);
        return files;
    }
    files.Reset();
    str::ReplaceWithCopy(&gNextPrevDir, dir);
    DirIter di{dir};
    for (DirIterEntry* de : di) {
        files.Append(de->filePath);
    }
    RemoveFailedFiles(files);

    // remove unsupported files that have never been successfully loaded
    int nFiles = files.Size();
    // remove unsupported files
    // traverse from the end so that removing doesn't change iterator
    for (int i = nFiles - 1; i >= 0; i--) {
        char* path2 = files[i];
        Kind kind = GuessFileTypeFromName(path2);
        bool isSupported = IsSupportedFileType(kind, true) || DocIsSupportedFileType(kind);
        bool inHistory = gFileHistory.FindByPath(path2);
        if (isSupported || inHistory) {
            continue;
        }
        files.RemoveAt(i);
    }
    AppendIfNotExists(&files, path);
    SortNatural(&files);
    return files;
}

static void OpenNextPrevFileInFolder(MainWindow* win, bool forward) {
    ReportIf(win->IsCurrentTabAbout());
    if (win->IsCurrentTabAbout()) {
        return;
    }
    if (!CanAccessDisk() || gPluginMode) {
        return;
    }

    WindowTab* tab = win->CurrentTab();
    bool didRetry = false;
again:
    const char* path = tab->filePath;
    StrVec files = CollectNextPrevFilesIfChanged(path);
    if (files.Size() < 2) {
        return;
    }

    int nFiles = files.Size();
    int idx = files.Find(path);
    if (forward) {
        idx = (idx + 1) % nFiles;
    } else {
        idx = (idx + nFiles - 1) % nFiles;
    }
    path = files[idx];
    if (!file::Exists(path)) {
        if (didRetry) {
            // TODO: can I do something better?
            return;
        }
        didRetry = true;
        str::FreePtr(&gNextPrevDir); // trigger re-reading the directory
        goto again;
    }

    // TODO: check for unsaved modifications
    UpdateTabFileDisplayStateForTab(tab);
    // TODO: should take onFinish() callback so that if failed
    // we could automatically go to next file
    LoadArgs args(path, win);
    args.forceReuse = true;
    LoadDocument(&args);
    HwndRepaintNow(win->tabsCtrl->hwnd);
}

constexpr int kSplitterDx = 5;
constexpr int kSplitterDy = 4;
constexpr int kSidebarMinDx = 150;
constexpr int kTocMinDy = 100;

constexpr int kFrameBorderSize = 1;

using LayoutState = MainWindow::LayoutState;

static bool IsLayoutStateEq(LayoutState* s1, LayoutState* s2) {
    return s1->rc == s2->rc && s1->presentation == s2->presentation && s1->tabsInTitlebar == s2->tabsInTitlebar &&
           s1->isFullScreen == s2->isFullScreen && s1->tabsVisible == s2->tabsVisible &&
           s1->isToolbarVisible == s2->isToolbarVisible && s1->tocVisible == s2->tocVisible &&
           s1->showFavorites == s2->showFavorites && s1->showMenuBarRebar == s2->showMenuBarRebar;
}

static void RelayoutFrame(MainWindow* win, bool updateToolbars, int sidebarDx) {
    Rect rc = ClientRect(win->hwndFrame);
    // don't relayout while the window is minimized
    if (rc.IsEmpty()) {
        return;
    }
    // build a snapshot of all state that affects layout
    MainWindow::LayoutState curState;
    curState.rc = rc;
    curState.presentation = (int)win->presentation;
    curState.tabsInTitlebar = win->tabsInTitlebar;
    curState.isFullScreen = win->isFullScreen;
    curState.tabsVisible = win->tabsVisible;
    curState.isToolbarVisible = win->isToolbarVisible;
    curState.tocVisible = win->tocVisible;
    curState.showFavorites = gGlobalPrefs->showFavorites;
    curState.showMenuBarRebar = IsShowingMenuBarRebar(win);

    // skip redundant relayouts when all layout-affecting state is unchanged
    if (IsLayoutStateEq(&curState, &win->lastLayoutState) && updateToolbars && sidebarDx == -1) {
        return;
    }
    // only cache for default calls; non-default calls (sidebar dragging etc.)
    // must not prevent a subsequent default call from running
    if (updateToolbars && sidebarDx == -1) {
        win->lastLayoutState = curState;
    } else {
        win->lastLayoutState = {};
    }
    if (gRedrawLog) {
        RECT r = ToRECT(rc);
        LogRedraw("RelayoutFrame", win->hwndFrame, &r);
    }

    if (PM_BLACK_SCREEN == win->presentation || PM_WHITE_SCREEN == win->presentation) {
        // make the black/white canvas cover the entire window
        MoveWindow(win->hwndCanvas, rc);
        return;
    }

    // inset by border for resize hit-testing (only with custom caption, not when maximized/fullscreen)
    if (win->tabsInTitlebar && !IsZoomed(win->hwndFrame) && !win->isFullScreen && !win->presentation) {
        rc.x += kFrameBorderSize;
        // top border is kFrameBorderSize - 1 because 1px is already NC area
        // (WM_NCCALCSIZE keeps 1px NC to prevent DWM transparent flash)
        rc.y += kFrameBorderSize - 1;
        rc.dx -= 2 * kFrameBorderSize;
        // bottom resize is handled via WM_NCCALCSIZE NC band + WM_NCHITTEST; don't
        // inset content here or WM_PAINT fills opaque chrome over DWM rounded corners.
        rc.dy -= kFrameBorderSize - 1;
    }

    // hide overlay scrollbars before relayout so they don't appear at
    // stale positions while child windows are being repositioned
    OverlayScrollbarHide(win->overlayScrollV);
    OverlayScrollbarHide(win->overlayScrollH);

    bool suppressIntermediateRedraws = !win->suppressFrameRedraw;
    if (suppressIntermediateRedraws) {
        // suppress intermediate repaints during relayout
        SendMessageW(win->hwndFrame, WM_SETREDRAW, FALSE, 0);
    }

    DeferWinPosHelper dh;

    // Tabbar and toolbar at the top
    if (!win->presentation && !win->isFullScreen) {
        if (win->tabsInTitlebar) {
            bool showingMenuBar = IsMenubarVisible() && IsShowingMenuBarRebar(win);
            // Add a visible gap above the caption for window dragging.
            // Skip when menu bar is showing (it goes all the way to the top).
            if (!IsZoomed(win->hwndFrame) && !showingMenuBar) {
                rc.y += kCaptionTopPadding;
                rc.dy -= kCaptionTopPadding;
            }
            int tabHeight = GetTabbarHeight(win->hwndFrame);
            int captionHeight = tabHeight + 2;
            if (showingMenuBar) {
                int menuBarDy = (int)SendMessageW(win->hwndMenuReBar, RB_GETBARHEIGHT, 0, 0) + 1;
                // check if there are actual file tabs to show
                bool hasFileTabs = false;
                for (WindowTab* tab : win->Tabs()) {
                    if (!tab->IsAboutTab()) {
                        hasFileTabs = true;
                        break;
                    }
                }
                // menu bar row + optional tabs row
                captionHeight = menuBarDy + (hasFileTabs ? tabHeight : 0);
            }
            win->captionRect = {rc.x, rc.y, rc.dx, captionHeight};
            if (updateToolbars) {
                RelayoutCaption(win);
            }
            rc.y += captionHeight;
            rc.dy -= captionHeight;
        } else if (win->tabsVisible) {
            int tabHeight = GetTabbarHeight(win->hwndFrame);
            if (updateToolbars) {
                dh.SetWindowPos(win->tabsCtrl->hwnd, nullptr, rc.x, rc.y, rc.dx, tabHeight, SWP_NOZORDER);
            }
            rc.y += tabHeight;
            rc.dy -= tabHeight;
        }
    }
    bool showMenuRebar =
        IsMenubarVisible() && IsShowingMenuBarRebar(win) && (!win->tabsInTitlebar || win->isFullScreen);
    if (showMenuRebar) {
        // menu bar rebar in client area (non-titlebar case, or fullscreen where there's no titlebar)
        int menuBarDy = (int)SendMessageW(win->hwndMenuReBar, RB_GETBARHEIGHT, 0, 0) + 1;
        if (updateToolbars) {
            dh.SetWindowPos(win->hwndMenuReBar, nullptr, rc.x, rc.y, rc.dx, menuBarDy, SWP_NOZORDER);
        }
        rc.y += menuBarDy;
        rc.dy -= menuBarDy;
    }
    if (win->isToolbarVisible) {
        if (updateToolbars) {
            Rect rcRebar = WindowRect(win->hwndReBar);
            dh.SetWindowPos(win->hwndReBar, nullptr, rc.x, rc.y, rc.dx, rcRebar.dy, SWP_NOZORDER);
        }
        Rect rcRebar = WindowRect(win->hwndReBar);
        rc.y += rcRebar.dy;
        rc.dy -= rcRebar.dy;
    }
    if (updateToolbars) {
        ShowWindow(win->hwndReBar, win->isToolbarVisible ? SW_SHOW : SW_HIDE);
    }

    // ToC and Favorites sidebars at the left
    bool favVisible = gGlobalPrefs->showFavorites && !gPluginMode && CanAccessDisk();
    bool tocVisible = win->tocVisible;
    if (tocVisible || favVisible) {
        Size toc = ClientRect(win->hwndTocBox).Size();
        if (sidebarDx > 0) {
            toc = Size(sidebarDx, rc.y);
        }
        if (0 == toc.dx) {
            // TODO: use saved sidebarDx from saved preferences?
            toc.dx = rc.dx / 4;
        }
        // make sure that the sidebar is never too wide or too narrow
        // note: requires that the main frame is at least 2 * kSidebarMinDx
        //       wide (cf. OnFrameGetMinMaxInfo)
        toc.dx = limitValue(toc.dx, kSidebarMinDx, rc.dx / 2);

        toc.dy = 0;
        if (tocVisible) {
            if (!favVisible) {
                toc.dy = rc.dy;
            } else {
                toc.dy = gGlobalPrefs->tocDy;
                if (toc.dy > 0) {
                    toc.dy = limitValue(gGlobalPrefs->tocDy, 0, rc.dy);
                } else {
                    toc.dy = rc.dy / 2; // default value
                }
            }
        }

        if (tocVisible && favVisible) {
            toc.dy = limitValue(toc.dy, kTocMinDy, rc.dy - kTocMinDy);
        }

        if (tocVisible) {
            Rect rToc(rc.TL(), toc);
            dh.MoveWindow(win->hwndTocBox, rToc);
            if (favVisible) {
                Rect rSplitV(rc.x, rc.y + toc.dy, toc.dx, kSplitterDy);
                dh.MoveWindow(win->favSplitter->hwnd, rSplitV);
                toc.dy += kSplitterDy;
            }
        }
        if (favVisible) {
            Rect rFav(rc.x, rc.y + toc.dy, toc.dx, rc.dy - toc.dy);
            dh.MoveWindow(win->hwndFavBox, rFav);
        }
        Rect rSplitH(rc.x + toc.dx, rc.y, kSplitterDx, rc.dy);
        dh.MoveWindow(win->sidebarSplitter->hwnd, rSplitH);

        rc.x += toc.dx + kSplitterDx;
        rc.dx -= toc.dx + kSplitterDx;
    }

    dh.MoveWindow(win->hwndCanvas, rc);

    dh.End();

    if (suppressIntermediateRedraws) {
        // re-enable redraw and invalidate once
        SendMessageW(win->hwndFrame, WM_SETREDRAW, TRUE, 0);
        // RDW_ALLCHILDREN ensures notification windows (children of canvas) also repaint
        RedrawWindow(win->hwndCanvas, nullptr, nullptr, RDW_INVALIDATE | RDW_ALLCHILDREN);
        RedrawWindow(win->hwndFrame, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_FRAME);
    }
    if (tocVisible) {
        RedrawWindow(win->hwndTocBox, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
    }
    if (favVisible) {
        RedrawWindow(win->hwndFavBox, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
    }
    if (tocVisible || favVisible) {
        InvalidateRect(win->sidebarSplitter->hwnd, nullptr, TRUE);
    }
    if (tocVisible && favVisible) {
        InvalidateRect(win->favSplitter->hwnd, nullptr, TRUE);
    }
    if (updateToolbars && win->isToolbarVisible) {
        RedrawWindow(win->hwndReBar, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
    }
    if (updateToolbars && IsShowingMenuBarRebar(win) && !win->tabsInTitlebar) {
        RedrawWindow(win->hwndMenuReBar, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
    }
    if (updateToolbars && win->tabsInTitlebar && !win->isFullScreen) {
        RECT r = ToRECT(win->captionRect);
        InvalidateRect(win->hwndFrame, &r, TRUE);
        if (win->hwndMenuReBar && IsWindowVisible(win->hwndMenuReBar)) {
            RedrawWindow(win->hwndMenuReBar, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
        }
        if (win->tabsCtrl && win->tabsCtrl->IsVisible()) {
            RedrawWindow(win->tabsCtrl->hwnd, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE);
        }
    }

    // TODO: if a document with ToC and a broken document are loaded
    //       and the first document is closed with the ToC still visible,
    //       we have tocVisible but !win->ctrl
    if (tocVisible && win->ctrl) {
        // the ToC selection may change due to resizing
        // (and SetSidebarVisibility relies on this for initialization)
        UpdateTocSelection(win, win->ctrl->CurrentPageNo());
    }

    // reposition overlay scrollbars after relayout (they were hidden at the
    // start to prevent stale positioning); skip during fullscreen transitions
    // where EndFrameRedrawSuppression handles this
    if (!win->suppressFrameRedraw) {
        UpdateOverlayScrollbarPositions(win);
    }

    if (updateToolbars && win->isToolbarVisible && win->hwndToolbar) {
        int pageCount = win->ctrl ? win->ctrl->PageCount() : -1;
        UpdateToolbarPageText(win, pageCount);
        if (NeedsFindUI(win)) {
            UpdateToolbarFindText(win);
        }
    }
}

static void BeginFrameRedrawSuppression(MainWindow* win) {
    if (win->suppressFrameRedraw) {
        return;
    }
    win->suppressFrameRedraw = true;
    SendMessageW(win->hwndFrame, WM_SETREDRAW, FALSE, 0);
}

static void EndFrameRedrawSuppression(MainWindow* win) {
    if (!win->suppressFrameRedraw) {
        return;
    }
    win->suppressFrameRedraw = false;
    SendMessageW(win->hwndFrame, WM_SETREDRAW, TRUE, 0);
    InvalidateRect(win->hwndCanvas, nullptr, FALSE);
    RedrawWindow(win->hwndFrame, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME);
}

static void UpdateOverlayScrollbarPositions(MainWindow* win) {
    if (win->overlayScrollV) {
        OverlayScrollbarUpdatePos(win->overlayScrollV);
    }
    if (win->overlayScrollH) {
        OverlayScrollbarUpdatePos(win->overlayScrollH);
    }
}

static void FrameOnSize(MainWindow* win, int, int) {
    UpdateWindowFrameBorderColor(win);
    RelayoutFrame(win);
    // re-anchor the floating find bar over the (possibly moved) search icon
    FindBarReposition(win);
    FindWindowReposition(win);

    if (win->presentation || win->isFullScreen) {
        Rect fullscreen = GetFullscreenRect(win->hwndFrame);
        Rect rect = WindowRect(win->hwndFrame);
        // Windows XP sometimes seems to change the window size on it's own
        if (rect != fullscreen && rect != GetVirtualScreenRect()) {
            MoveWindow(win->hwndFrame, fullscreen);
        }
    }
}

void RelayoutWindow(MainWindow* win) {
    RelayoutFrame(win);
}

void SetCurrentLanguageAndRefreshUI(const char* langCode) {
    if (!langCode) {
        return;
    }
    langCode = trans::ValidateLangCode(langCode);
    if (!langCode) {
        logf("SetCurrentLanguageAndRefreshUI: unknown lang code\n");
        return;
    }
    SetCurrentLang(langCode);
    UpdateThemeCommandLabels();

    for (MainWindow* win : gWindows) {
        RebuildMenuBarForWindow(win);
        UpdateToolbarSidebarText(win);
        UpdateWindowRtlLayout(win);
        HideSelectionToolbar(win);
    }

    UpdateHomeTabTitles();
    HomePageOnLanguageChangedAll();

    SaveSettings();
}

static void OnMenuChangeLanguage(HWND hwnd) {
    const char* newLangCode = Dialog_ChangeLanguge(hwnd, trans::GetCurrentLangCode());
    SetCurrentLanguageAndRefreshUI(newLangCode);
}

static void OnMenuViewShowHideToolbar(MainWindow* win) {
    if (win->isFullScreen) {
        gGlobalPrefs->fullscreen.showToolbar = !gGlobalPrefs->fullscreen.showToolbar;
    } else {
        gGlobalPrefs->showToolbar = !gGlobalPrefs->showToolbar;
    }
    for (MainWindow* w : gWindows) {
        ShowOrHideToolbar(w);
    }
}

static void OnMenuChangeBackgroundColor(MainWindow* win) {
    WindowTab* tab = win->CurrentTab();
    if (!tab || !tab->ctrl) {
        return;
    }
    auto* engine = tab->GetEngine();
    bool isImage = engine && engine->IsImageCollection();
    bool isCbx = engine && engine->kind == kindEngineComicBooks;
    bool isEbook = engine && engine->kind == kindEngineMupdf && !str::EqI(engine->defaultExt, ".pdf");

    COLORREF curColor = tab->bgColor;
    bool isCur = (curColor != kColorUnset);
    bool isCheckered = (curColor == kColorUnset);

    // if tab has no per-doc color, check global settings for the appropriate type
    if (!isCur) {
        COLORREF bg;
        ThemeDocumentColors(bg);
        curColor = bg;
        isCheckered = false;
    }

    const char* allFilesLabel = "For all &PDF files";
    if (isCbx) {
        allFilesLabel = "For all &comic books";
    } else if (isImage) {
        allFilesLabel = "For all &images";
    } else if (isEbook) {
        allFilesLabel = "For all &ebooks";
    }

    BgColorResult result;
    if (!Dialog_ChangeBackgroundColor(win->hwndFrame, curColor, isCheckered, allFilesLabel, result)) {
        return;
    }

    const char* colorStr;
    if (result.isCheckered) {
        colorStr = "checkered";
    } else {
        colorStr = SerializeColorTemp(result.color);
    }
    COLORREF newColor = result.isCheckered ? kColorUnset : result.color;

    if (result.applyToAllFiles) {
        if (isCbx) {
            str::ReplaceWithCopy(&gGlobalPrefs->comicBookUI.windowBgCol, colorStr);
            gGlobalPrefs->comicBookUI.windowBgColParsed.wasParsed = false;
        } else if (isImage) {
            str::ReplaceWithCopy(&gGlobalPrefs->imageUI.windowBgCol, colorStr);
            gGlobalPrefs->imageUI.windowBgColParsed.wasParsed = false;
        } else if (isEbook) {
            str::ReplaceWithCopy(&gGlobalPrefs->eBookUI.windowBgCol, colorStr);
            gGlobalPrefs->eBookUI.windowBgColParsed.wasParsed = false;
        } else {
            str::ReplaceWithCopy(&gGlobalPrefs->fixedPageUI.windowBgCol, colorStr);
            gGlobalPrefs->fixedPageUI.windowBgColParsed.wasParsed = false;
        }
        // clear per-file override so it inherits the global setting
        FileState* fs = gFileHistory.FindByPath(tab->filePath);
        if (fs) {
            str::ReplaceWithCopy(&fs->bgCol, "");
        }
        tab->bgColor = kColorUnset;
        tab->bgColorCheckered = false;
        SaveSettings();
    } else {
        // apply to this file only
        FileState* fs = gFileHistory.FindByPath(tab->filePath);
        if (fs) {
            str::ReplaceWithCopy(&fs->bgCol, colorStr);
            fs->bgColParsed.wasParsed = false;
        }
        tab->bgColor = newColor;
        tab->bgColorCheckered = result.isCheckered;
        SaveSettings();
    }
    // trigger repaint
    InvalidateRect(win->hwndCanvas, nullptr, TRUE);
}

static void OnMenuChangeScrollbar(HWND hwnd) {
    if (Dialog_ChangeScrollbar(hwnd)) {
        UpdateFixedPageScrollbarsVisibility();
    }
}

#if 0 // note: was used in OpenAdvancedOptions()
static void OpenFileWithTextEditor(const char* path) {
    Vec<TextEditor*> editors;
    DetectTextEditors(editors);
    const char* cmd = editors[0]->openFileCmd;

    char* cmdLine = BuildOpenFileCmd(cmd, path, 1, 1);
    logf("OpenFileWithTextEditor: '%s'\n", cmdLine);
    char* appDir = GetSelfExeDirTemp();
    AutoCloseHandle process(LaunchProcess(cmdLine, appDir));
    str::Free(cmdLine);
}
#endif

static void OpenAdvancedOptions() {
    if (!CanAccessDisk() || !HasPermission(Perm::SavePreferences)) {
        return;
    }

    // TODO: disable/hide the menu item when there's no prefs file
    //       (happens e.g. when run in portable mode from a CD)?
    TempStr path = GetSettingsPathTemp();
    LaunchFileIfExists(path);
}

static void ShowOptionsDialog(HWND hwnd) {
    if (!HasPermission(Perm::SavePreferences)) {
        return;
    }

    bool useTabsBefore = gGlobalPrefs->useTabs;
    bool checkForUpdatesBefore = gGlobalPrefs->checkForUpdates;

    if (IDOK != Dialog_Settings(hwnd, gGlobalPrefs)) {
        return;
    }

    if (!SettingsRememberOpenedFiles()) {
        gFileHistory.Clear(true);
        DeleteThumbnailCacheDirectory();
    }
    UpdateDocumentColors();
    if (gGlobalPrefs->checkForUpdates != checkForUpdatesBefore) {
        RefreshAutomaticUpdateChecks();
    }

    if (gGlobalPrefs->useTabs != useTabsBefore) {
        SaveSettings();
        RestartApplication();
        return;
    }
    if (!SettingsUseTabs()) {
        for (MainWindow* w : gWindows) {
            SetTabsInTitlebar(w, false);
        }
    }
    SaveSettings();
}

// TODO: should use currently active window, but most of the time
// there's only one window
void MaybeRedrawHomePage() {
    if (!gWindows.empty() && gWindows.at(0)->IsCurrentTabAbout()) {
        gWindows.at(0)->RedrawAll(true);
    }
}

static void ShowOptionsDialog(MainWindow* win) {
    ShowOptionsDialog(win->hwndFrame);
    MaybeRedrawHomePage();
}

// toggles 'show pages continuously' state
static void ToggleContinuousView(MainWindow* win) {
    if (!win->IsDocLoaded()) {
        return;
    }

    DisplayMode newMode = win->ctrl->GetDisplayMode();
    switch (newMode) {
        case DisplayMode::SinglePage:
        case DisplayMode::Continuous:
            newMode = IsContinuous(newMode) ? DisplayMode::SinglePage : DisplayMode::Continuous;
            break;
        case DisplayMode::Facing:
        case DisplayMode::ContinuousFacing:
            newMode = IsContinuous(newMode) ? DisplayMode::Facing : DisplayMode::ContinuousFacing;
            break;
        case DisplayMode::BookView:
        case DisplayMode::ContinuousBookView:
            newMode = IsContinuous(newMode) ? DisplayMode::BookView : DisplayMode::ContinuousBookView;
            break;
    }
    SwitchToDisplayMode(win, newMode);
}

static void ToggleMangaMode(MainWindow* win) {
    DisplayModel* dm = win->AsFixed();
    if (!dm) {
        return;
    }
    dm->SetDisplayR2L(!dm->GetDisplayR2L());
    ScrollState state = dm->GetScrollState();
    dm->Relayout(dm->GetZoomVirtual(), dm->GetRotation());
    dm->SetScrollState(state);
}

static Point GetSelectionCenter(MainWindow* win) {
    bool hasSelection = win->showSelection && win->CurrentTab()->selectionOnPage;
    if (!hasSelection) {
        return {};
    }
    DisplayModel* dm = win->AsFixed();
    Rect selRect;
    for (SelectionOnPage& sel : *win->CurrentTab()->selectionOnPage) {
        selRect = selRect.Union(sel.GetRect(dm));
    }

    Rect rc = ClientRect(win->hwndCanvas);
    Point pt;
    pt.x = 2 * selRect.x + selRect.dx - rc.dx / 2;
    pt.y = 2 * selRect.y + selRect.dy - rc.dy / 2;
    pt.x = limitValue(pt.x, selRect.x, selRect.x + selRect.dx);
    pt.y = limitValue(pt.y, selRect.y, selRect.y + selRect.dy);
    return pt;
}

static Point GetFirstVisiblePageTopLeft(MainWindow* win) {
    DisplayModel* dm = win->AsFixed();
    int page = dm->FirstVisiblePageNo();
    PageInfo* pageInfo = dm->GetPageInfo(page);
    if (!pageInfo) {
        return {};
    }
    Rect visible = pageInfo->pageOnScreen.Intersect(win->canvasRc);
    return visible.TL();
}

static Point GetCanvasCenter(MainWindow* win) {
    Rect rc = ClientRect(win->hwndCanvas);
    auto x = rc.x + (rc.dx / 2);
    auto y = rc.y + (rc.dy / 2);
    return {x, y};
}

static bool IsPointOnPage(DisplayModel* dm, Point& pt) {
    if (!dm) {
        return false;
    }
    if (pt.IsEmpty()) {
        return false;
    }
    int pageNo = dm->GetPageNoByPoint(pt);
    if (!dm->ValidPageNo(pageNo)) {
        return false;
    }
    if (!dm->PageVisible(pageNo)) {
        return false;
    }
    return true;
}

static bool gZoomAroundCenterCanvas = true;

static Point GetSmartZoomPos(MainWindow* win, Point suggestdPoint) {
    // zoom around current selection takes precedence
    DisplayModel* dm = win->AsFixed();
    Point pt = GetSelectionCenter(win);
    if (IsPointOnPage(dm, pt)) {
        return pt;
    }
    // suggestedPoint is typically a current mouse position
    if (IsPointOnPage(dm, suggestdPoint)) {
        return suggestdPoint;
    }
    // or towards the top-left-most part of the first visible page
    // TODO: something better, like center of the screen?
    if (gZoomAroundCenterCanvas) {
        pt = GetCanvasCenter(win);
    } else {
        pt = GetFirstVisiblePageTopLeft(win);
    }
    if (IsPointOnPage(dm, pt)) {
        return pt;
    }
    return {};
}

static void ShowZoomNotification(MainWindow* win, float zoomLevel) {
    // don't show zoom info if showing page info
    NotificationWnd* wnd = GetNotificationForGroup(win->hwndCanvas, kNotifPageInfo);
    if (wnd) {
        return;
    }
    NotificationCreateArgs args;
    args.groupId = kNotifZoom;
    args.timeoutMs = 2000;
    args.hwndParent = win->hwndCanvas;
    args.msg = BuildZoomString(zoomLevel);
    ShowNotification(args);
}

// if suggestedPoint is provided, it's position on canvas and we'll try to preserve that point after zoom
// if suggestedPoint is nullptr we'll try to pick a smart point to zoom around if smartZoom is true
void SmartZoom(MainWindow* win, float newZoom, Point* suggestedPoint, bool smartZoom) {
    if (!win->IsDocLoaded()) {
        return;
    }
    Point* pt = suggestedPoint;

    Point ptSmart;
    if (smartZoom) {
        ptSmart = GetSmartZoomPos(win, ptSmart);
        if (!ptSmart.IsEmpty()) {
            pt = &ptSmart;
        }
    }
    if (newZoom < 0) {
        // if newZoom is one of kZoomFit* constants, we don't do smartZoom
        // TODO: shouldn't happen if !smartZoom
        pt = nullptr;
    }

    win->ctrl->SetZoomVirtual(newZoom, pt);
    UpdateToolbarState(win);
    ShowZoomNotification(win, newZoom);
}

/* Zoom document in window 'hwnd' to zoom level 'zoom'.
   'zoom' is given as a floating-point number, 1.0 is 100%, 2.0 is 200% etc.
*/
static void OnMenuZoom(MainWindow* win, int menuId) {
    if (!win->IsDocLoaded()) {
        return;
    }

    float zoom = ZoomMenuItemToZoom(menuId);
    SmartZoom(win, zoom, nullptr, true);
}

static void ChangeZoomLevel(MainWindow* win, float newZoom, bool pagesContinuously) {
    if (!win->IsDocLoaded()) {
        return;
    }

    float zoom = win->ctrl->GetZoomVirtual();
    DisplayMode mode = win->ctrl->GetDisplayMode();
    DisplayMode newMode = pagesContinuously ? DisplayMode::Continuous : DisplayMode::SinglePage;

    if (mode != newMode || zoom != newZoom) {
        float prevZoom = win->CurrentTab()->prevZoomVirtual;
        DisplayMode prevMode = win->CurrentTab()->prevDisplayMode;

        if (mode != newMode) {
            SwitchToDisplayMode(win, newMode);
        }
        OnMenuZoom(win, CmdIdFromVirtualZoom(newZoom));

        // remember the previous values for when the toolbar button is unchecked
        if (kInvalidZoom == prevZoom) {
            win->CurrentTab()->prevZoomVirtual = zoom;
            win->CurrentTab()->prevDisplayMode = mode;
        } else {
            // keep the rememberd values when toggling between the two toolbar buttons
            win->CurrentTab()->prevZoomVirtual = prevZoom;
            win->CurrentTab()->prevDisplayMode = prevMode;
        }
    } else if (win->CurrentTab()->prevZoomVirtual != kInvalidZoom) {
        float prevZoom = win->CurrentTab()->prevZoomVirtual;
        SwitchToDisplayMode(win, win->CurrentTab()->prevDisplayMode);
        SmartZoom(win, prevZoom, nullptr, true);
    }
}

static void FocusPageNoEdit(HWND hwndPageEdit) {
    if (HwndIsFocused(hwndPageEdit)) {
        SendMessageW(hwndPageEdit, WM_SETFOCUS, 0, 0);
    } else {
        HwndSetFocus(hwndPageEdit);
    }
}

static void OnMenuGoToPage(MainWindow* win) {
    if (!win->IsDocLoaded()) {
        return;
    }

    // Don't show a dialog if we don't have to - use the Toolbar instead
    if (gGlobalPrefs->showToolbar && !win->isFullScreen && !win->presentation) {
        FocusPageNoEdit(win->hwndPageEdit);
        return;
    }

    auto* ctrl = win->ctrl;
    TempStr label = ctrl->GetPageLabeTemp(ctrl->CurrentPageNo());
    AutoFreeStr newPageLabel = Dialog_GoToPage(win->hwndFrame, label, ctrl->PageCount(), !ctrl->HasPageLabels());
    if (!newPageLabel) {
        return;
    }

    int newPageNo = ctrl->GetPageByLabel(newPageLabel.Get());
    if (ctrl->ValidPageNo(newPageNo)) {
        ctrl->GoToPage(newPageNo, true);
    }
}

void EnterFullScreen(MainWindow* win, bool presentation) {
    if (!HasPermission(Perm::FullscreenAccess) || gPluginMode) {
        return;
    }

    if (!IsWindowVisible(win->hwndFrame)) {
        return;
    }

    if (presentation ? win->presentation : win->isFullScreen) {
        return;
    }

    ReportIf(presentation ? win->isFullScreen : win->presentation);
    if (presentation) {
        ReportIf(!win->ctrl);
        if (!win->IsDocLoaded()) {
            return;
        }

        if (IsZoomed(win->hwndFrame)) {
            win->windowStateBeforePresentation = WIN_STATE_MAXIMIZED;
        } else {
            win->windowStateBeforePresentation = WIN_STATE_NORMAL;
        }
        win->presentation = PM_ENABLED;
        // hack: this tells OnMouseMove() to hide cursor immediately
        win->dragPrevPos = Point(-2, -3);
    } else {
        win->isFullScreen = true;
    }

    // Save window style and rect before hiding anything, since
    // SetMenu(nullptr) can alter the window style bits.
    long ws = GetWindowLong(win->hwndFrame, GWL_STYLE);
    if (!presentation || !win->isFullScreen) {
        win->nonFullScreenWindowStyle = ws;
    }
    win->nonFullScreenFrameRect = WindowRect(win->hwndFrame);

    // Hide sidebar before suppressing redraws so the hide takes
    // visual effect immediately, preventing a flash of sidebar at
    // fullscreen size during the transition.
    // TODO: make showFavorites a per-window pref
    bool showFavoritesTmp = gGlobalPrefs->showFavorites;
    if (presentation && (win->tocVisible || gGlobalPrefs->showFavorites)) {
        SetSidebarVisibility(win, false, false, false);
    }

    // Set state flags; RelayoutFrame (triggered by SetWindowPos/WM_SIZE)
    // will handle the actual showing/hiding of toolbar and tabs.
    bool showToolbarInFS = !presentation && gGlobalPrefs->fullscreen.showToolbar && gGlobalPrefs->showToolbar;
    bool showMenubarInFS = !presentation && gGlobalPrefs->fullscreen.showMenubar;
    win->isToolbarVisible = showToolbarInFS;
    win->tabsCtrl->SetIsVisible(false);

    // suppress redraws before any operations that trigger WM_SIZE
    // (SetMenu, SetWindowLong, SetWindowPos all change non-client area)
    BeginFrameRedrawSuppression(win);

    // always remove native menu in fullscreen (WS_CAPTION is stripped, so SetMenu won't work)
    SetMenu(win->hwndFrame, nullptr);
    if (showMenubarInFS) {
        // use rebar-based menu bar which renders in the client area
        CreateMenuBarRebar(win);
    } else {
        DestroyMenuBarRebar(win);
    }

    // remove window styles that add to non-client area
    ws &= ~(WS_CAPTION | WS_THICKFRAME);
    ws |= WS_MAXIMIZE;
    Rect rect = GetFullscreenRect(win->hwndFrame);

    UpdateWindowFrameBorderColor(win);
    // disable DWM rounded corners and border for true edge-to-edge fullscreen
    dwm::SetWindowRoundedCorners(win->hwndFrame, false);

    SetWindowLong(win->hwndFrame, GWL_STYLE, ws);
    uint flags = SWP_FRAMECHANGED | SWP_NOACTIVATE | SWP_NOZORDER;
    SetWindowPos(win->hwndFrame, nullptr, rect.x, rect.y, rect.dx, rect.dy, flags);

    if (presentation) {
        win->ctrl->SetInPresentation(true);
    }

    // Make sure that no toolbar/sidebar keeps the focus
    HwndSetFocus(win->hwndFrame);
    // restore gGlobalPrefs->showFavorites changed by SetSidebarVisibility()
    gGlobalPrefs->showFavorites = showFavoritesTmp;
    EndFrameRedrawSuppression(win);
    // ensure layout is correct after fullscreen transition
    RelayoutFrame(win);
    // show menu bar rebar after layout positions it correctly
    ShowMenuBarRebar(win);

    if (gGlobalPrefs->preventSleepInFullscreen) {
        SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED);
    }
}

void ExitFullScreen(MainWindow* win) {
    if (!win->isFullScreen && !win->presentation) {
        return;
    }

    if (gGlobalPrefs->preventSleepInFullscreen) {
        SetThreadExecutionState(ES_CONTINUOUS);
    }

    bool wasPresentation = PM_DISABLED != win->presentation;
    if (wasPresentation) {
        win->presentation = PM_DISABLED;
        if (win->IsDocLoaded()) {
            win->ctrl->SetInPresentation(false);
        }
        // re-enable the auto-hidden cursor
        KillTimer(win->hwndCanvas, kHideCursorTimerID);
        SetCursorCached(IDC_ARROW);
        // ensure that no ToC is shown when entering presentation mode the next time
        for (WindowTab* tab : win->Tabs()) {
            tab->showTocPresentation = false;
        }
    } else {
        win->isFullScreen = false;
    }

    BeginFrameRedrawSuppression(win);
    bool tocVisible = win->CurrentTab() && win->CurrentTab()->showToc;
    SetSidebarVisibility(win, tocVisible, gGlobalPrefs->showFavorites, false);

    if (win->tabsVisible) {
        win->tabsCtrl->SetIsVisible(true);
    }
    win->isToolbarVisible = ShouldShowToolbar(win);
    if (win->isToolbarVisible) {
        ShowWindow(win->hwndReBar, SW_SHOW);
    }
    // destroy any fullscreen menu rebar before restoring normal menu
    DestroyMenuBarRebar(win);
    if (IsMenubarVisible()) {
        if (win->tabsInTitlebar) {
            CreateMenuBarRebar(win);
        } else {
            SyncNativeMenuBar(win);
        }
    }

    // restore DWM rounded corners and border
    dwm::SetWindowRoundedCorners(win->hwndFrame, true);
    UpdateWindowFrameBorderColor(win);

    Rect cr = ClientRect(win->hwndFrame);
    SetWindowLong(win->hwndFrame, GWL_STYLE, win->nonFullScreenWindowStyle);
    uint flags = SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE;
    SetWindowPos(win->hwndFrame, nullptr, 0, 0, 0, 0, flags);
    MoveWindow(win->hwndFrame, win->nonFullScreenFrameRect);
    // TODO: this ReportIf() fires in pre-release e.g. 64011
    // ReportIf(WindowRect(win.hwndFrame) != win.nonFullScreenFrameRect);
    // We have to relayout here, because it isn't done in the SetWindowPos nor MoveWindow,
    // if the client rectangle hasn't changed.
    if (ClientRect(win->hwndFrame) == cr) {
        RelayoutFrame(win);
    }
    // show menu bar rebar after layout positions it correctly
    ShowMenuBarRebar(win);
    EndFrameRedrawSuppression(win);
}

void ToggleFullScreen(MainWindow* win, bool presentation) {
    bool enterFullScreen = presentation ? !win->presentation : !win->isFullScreen;

    if (win->presentation || win->isFullScreen) {
        ExitFullScreen(win);
    } else {
        RememberDefaultWindowPosition(win);
    }

    if (enterFullScreen && (!presentation || win->IsDocLoaded())) {
        EnterFullScreen(win, presentation);
    }
}

static void TogglePresentationMode(MainWindow* win) {
    // only DisplayModel currently supports an actual presentation mode
    ToggleFullScreen(win, win->AsFixed() != nullptr);
}

// make sure that idx falls within <0, max-1> inclusive range
// negative numbers wrap from the end
static int wrapIdx(int idx, int max) {
    for (; idx < 0; idx += max) {
        idx += max;
    }
    return idx % max;
}

void AdvanceFocus(MainWindow* win) {
    // Tab order: Frame -> Page -> Find -> ToC -> Favorites -> Frame -> ...

    bool hasToolbar = !win->isFullScreen && !win->presentation && gGlobalPrefs->showToolbar && win->IsDocLoaded();
    int direction = IsShiftPressed() ? -1 : 1;

    const int MAX_WINDOWS = 5;
    HWND tabOrder[MAX_WINDOWS] = {win->hwndFrame};
    int nWindows = 1;
    if (hasToolbar) {
        tabOrder[nWindows++] = win->hwndPageEdit;
    }
    if (hasToolbar && NeedsFindUI(win)) {
        tabOrder[nWindows++] = win->hwndFindEdit;
    }
    if (win->tocLoaded && win->tocVisible) {
        tabOrder[nWindows++] = win->tocTreeView->hwnd;
    }
    if (gGlobalPrefs->showFavorites) {
        tabOrder[nWindows++] = win->favTreeView->hwnd;
    }
    ReportIf(nWindows > MAX_WINDOWS);

    // find the currently focused element
    HWND focused = GetFocus();
    int i = 0;
    while (i < nWindows) {
        if (tabOrder[i] == focused) {
            break;
        }
        i++;
    }
    // if it's not in the tab order, start at the beginning
    if (i == nWindows) {
        i = wrapIdx(-direction, nWindows);
    }
    // focus the next available element
    i = wrapIdx(i + direction, nWindows);
    HwndSetFocus(tabOrder[i]);
}

// allow to distinguish a '/' caused by VK_DIVIDE (rotates a document)
// from one typed on the main keyboard (focuses the find textbox)
static bool gIsDivideKeyDown = false;

static bool ChmForwardKey(WPARAM key) {
    if ((VK_LEFT == key) || (VK_RIGHT == key)) {
        return true;
    }
    if ((VK_UP == key) || (VK_DOWN == key)) {
        return true;
    }
    if ((VK_HOME == key) || (VK_END == key)) {
        return true;
    }
    if ((VK_PRIOR == key) || (VK_NEXT == key)) {
        return true;
    }
    if ((VK_MULTIPLY == key) || (VK_DIVIDE == key)) {
        return true;
    }
    return false;
}

static bool IsChmTab(WindowTab* tab) {
    if (!tab || !tab->IsDocLoaded()) {
        return false;
    }
    if (tab->AsChm()) {
        return true;
    }
    DisplayModel* dm = tab->AsFixed();
    return dm && dm->GetEngineType() == kindEngineChm;
}

static Annotation* GetAnnotionUnderCursor(WindowTab* tab, Annotation* annot) {
    DisplayModel* dm = tab->AsFixed();
    if (!dm) return nullptr;
    Point pt = HwndGetCursorPos(tab->win->hwndCanvas);
    if (pt.IsEmpty()) return nullptr;
    int pageNoUnderCursor = dm->GetPageNoByPoint(pt);
    if (pageNoUnderCursor <= 0) {
        return nullptr;
    }
    annot = dm->GetAnnotationAtPos(pt, annot);
    return annot;
}

static bool FrameOnKeydown(MainWindow* win, WPARAM key, LPARAM lp) {
    // TODO: how does this interact with new accelerators?
    if (PM_BLACK_SCREEN == win->presentation || PM_WHITE_SCREEN == win->presentation) {
        // black/white screen is disabled on any unmodified key press in FrameOnChar
        return true;
    }

    if (VK_ESCAPE == key) {
        CancelDrag(win);
        return true;
    }

    bool isCtrl = IsCtrlPressed();
    bool isShift = IsShiftPressed();
    if (!win->IsDocLoaded()) {
        return false;
    }

    DisplayModel* dm = win->AsFixed();

    // some of the chm key bindings are different than the rest and we
    // need to make sure we don't break them
    bool isChm = win->AsChm();
    // TODO: not sure how this interacts with accelerators
#if 0
    bool isPageUp = (isCtrl && (VK_UP == key));
    if (!isChm) {
        isPageUp |= (VK_PRIOR == key) && !isCtrl;
    }

    bool isPageDown = (isCtrl && (VK_DOWN == key));
    if (!isChm) {
        isPageDown |= (VK_NEXT == key) && !isCtrl;
    }
#endif
    if (isChm) {
        if (ChmForwardKey(key)) {
            win->AsChm()->PassUIMsg(WM_KEYDOWN, key, lp);
            return true;
        }
    }
    // lf("key=%d,%c,shift=%d\n", key, (char)key, (int)WasKeyDown(VK_SHIFT));

    if (VK_MULTIPLY == key && dm) {
        // logf("VK_MULTIPLY\n");
        dm->RotateBy(90);
    } else if (VK_DIVIDE == key && dm) {
        // logf("VK_DIVIDE\n");
        dm->RotateBy(-90);
        gIsDivideKeyDown = true;
    } else if (VK_DELETE == key && !isCtrl && !isShift) {
        if (IsPdfAnnotContentsEditFocused() || IsEbookAnnotContentsEditFocused()) {
            return false;
        }
        WindowTab* tab = win->CurrentTab();
        if (tab && tab->selectedEbookAnnotation) {
            if (EbookAnnotationsDelete(tab, tab->selectedEbookAnnotation)) {
                UpdateEbookAnnotationsList(tab->editEbookAnnotsWindow);
                MainWindowRerender(win);
            }
        } else if (tab && tab->selectedAnnotation) {
            DeleteAnnotationAndUpdateUI(tab, tab->selectedAnnotation);
        }
    } else {
        return false;
    }

    return true;
}

static WCHAR SingleCharLowerW(WCHAR c) {
    WCHAR buf[2] = {c, 0};
    CharLowerBuffW(buf, 1);
    return buf[0];
}

static void OnFrameKeyEsc(MainWindow* win) {
    if (AbortFinding(win, true)) {
        return;
    }
    if (RemoveNotificationsForGroup(win->hwndCanvas, kNotifPersistentWarning)) {
        return;
    }
    if (RemoveNotificationsForGroup(win->hwndCanvas, kNotifPageInfo)) {
        return;
    }
    if (RemoveNotificationsForGroup(win->hwndCanvas, kNotifCursorPos)) {
        return;
    }
    if (RemoveNotificationsForGroup(win->hwndCanvas, kNotifZoom)) {
        return;
    }
    if (win->showSelection) {
        ClearSearchResult(win);
        ToolbarUpdateStateForWindow(win, false);
        return;
    }
    if (gGlobalPrefs->escToExit && CanCloseWindow(win)) {
        CloseWindow(win, true, false);
        return;
    }
    if (win->presentation || win->isFullScreen) {
        ToggleFullScreen(win, win->presentation != PM_DISABLED);
        return;
    }
}

static void OnFrameKeyB(MainWindow* win) {
    auto* ctrl = win->ctrl;
    bool isSinglePage = IsSingle(ctrl->GetDisplayMode());

    DisplayModel* dm = win->AsFixed();
    if (dm && !isSinglePage) {
        bool forward = !IsShiftPressed();
        int currPage = ctrl->CurrentPageNo();
        bool isVisible = dm->FirstBookPageVisible();
        if (forward) {
            isVisible = dm->LastBookPageVisible();
        }
        if (isVisible) {
            return;
        }

        DisplayMode newMode = DisplayMode::BookView;
        if (IsBookView(ctrl->GetDisplayMode())) {
            newMode = DisplayMode::Facing;
        }
        SwitchToDisplayMode(win, newMode, true);

        if (forward && currPage >= ctrl->CurrentPageNo() && (currPage > 1 || newMode == DisplayMode::BookView)) {
            ctrl->GoToNextPage();
        } else if (!forward && currPage <= ctrl->CurrentPageNo()) {
            win->ctrl->GoToPrevPage();
        }
    } else if (false && !isSinglePage) {
        // "e-book view": flip a single page
        bool forward = !IsShiftPressed();
        int nextPage = ctrl->CurrentPageNo() + (forward ? 1 : -1);
        if (ctrl->ValidPageNo(nextPage)) {
            ctrl->GoToPage(nextPage, false);
        }
    } else if (win->presentation) {
        win->ChangePresentationMode(PM_BLACK_SCREEN);
    }
}

static void AddUniquePageNo(Vec<int>& v, int pageNo) {
    for (auto n : v) {
        if (n == pageNo) {
            return;
        }
    }
    v.Append(pageNo);
}

// create one or more annotations from current selection
// returns last created annotations
static Annotation* MakeAnnotationsFromSelection(WindowTab* tab, AnnotCreateArgs* args) {
    // converts current selection to annotation (or back to regular text
    // if it's already an annotation)
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        return nullptr;
    }
    auto engine = dm->GetEngine();
    bool supportsAnnots = EngineSupportsAnnotations(engine);
    MainWindow* win = tab->win;
    bool ok = supportsAnnots && win->showSelection && tab->selectionOnPage;
    if (!ok) {
        return nullptr;
    }

    Vec<SelectionOnPage>* s = tab->selectionOnPage;
    Vec<int> pageNos;
    for (auto& sel : *s) {
        int pageNo = sel.pageNo;
        if (!dm->ValidPageNo(pageNo)) {
            continue;
        }
        AddUniquePageNo(pageNos, pageNo);
    }
    if (pageNos.empty()) {
        return 0;
    }

    if (args->setContentToSelection) {
        bool isTextOnlySelection = false;
        args->content = GetSelectedTextTemp(tab, "\r\n", isTextOnlySelection);
    }

    int nCreated = 0;
    Annotation* annot = nullptr;
    for (auto pageNo : pageNos) {
        Vec<RectF> rects;
        for (auto& sel : *s) {
            if (pageNo != sel.pageNo) {
                continue;
            }
            rects.Append(sel.rect);
        }
        annot = EngineMupdfCreateAnnotation(engine, pageNo, PointF{}, args);
        if (!annot) {
            // TODO: leaking if created annots before
            return nullptr;
        }
        SetQuadPointsAsRect(annot, rects);
        annot->bounds = GetBounds(annot);
        MainWindowRerenderAnnotationChange(win, pageNo, IsPdfTextMarkupAnnotation(annot) ? annot : nullptr);
    }
    UpdateAnnotationsList(tab->editAnnotsWindow);

    // copy selection to clipboard so that user can use Ctrl-V to set contents
    if (args->copyToClipboard) {
        CopySelectionToClipboard(win);
    }
    DeleteOldSelectionInfo(win, true);
    ToolbarUpdateStateForWindow(win, true);
    return annot;
}

static void ToggleCursorPositionInDoc(MainWindow* win) {
    // "cursor position" tip: make figuring out the current
    // cursor position in cm/in/pt possible (for exact layouting)
    if (!win->AsFixed()) {
        return;
    }
    auto notif = GetNotificationForGroup(win->hwndCanvas, kNotifCursorPos);
    if (!notif) {
        NotificationCreateArgs args;
        args.hwndParent = win->hwndCanvas;
        args.groupId = kNotifCursorPos;
        args.shrinkLimit = 0.7f;
        args.timeoutMs = 0;
        notif = ShowNotification(args);
        cursorPosUnit = MeasurementUnit::pt;
    } else {
        switch (cursorPosUnit) {
            case MeasurementUnit::pt:
                cursorPosUnit = MeasurementUnit::mm;
                break;
            case MeasurementUnit::mm:
                cursorPosUnit = MeasurementUnit::in;
                break;
            case MeasurementUnit::in:
                cursorPosUnit = MeasurementUnit::pt;
                RemoveNotificationsForGroup(win->hwndCanvas, kNotifCursorPos);
                return;
            default:
                ReportIf(true);
        }
    }
    Point pt = HwndGetCursorPos(win->hwndCanvas);
    UpdateCursorPositionHelper(win, pt, notif);
}

static void FrameOnChar(MainWindow* win, WPARAM key, LPARAM info = 0) {
    if (PM_BLACK_SCREEN == win->presentation || PM_WHITE_SCREEN == win->presentation) {
        win->ChangePresentationMode(PM_ENABLED);
        return;
    }

    bool isCtrl = IsCtrlPressed();
    bool isAlt = IsAltPressed();

    if (key >= 0x100 && info && !isCtrl && !isAlt) {
        // determine the intended keypress by scan code for non-Latin keyboard layouts
        uint vk = MapVirtualKeyW((info >> 16) & 0xFF, MAPVK_VSC_TO_VK);
        if ('A' <= vk && vk <= 'Z') {
            key = vk;
        }
    }

    switch (key) {
        case VK_ESCAPE:
            OnFrameKeyEsc(win);
            return;
        case VK_TAB:
            AdvanceFocus(win);
            break;
    }

    if (!win->IsDocLoaded()) {
        return;
    }

    if (IsCharUpperW((WCHAR)key)) {
        key = (WPARAM)SingleCharLowerW((WCHAR)key);
    }

    DocController* ctrl = win->ctrl;

    switch (key) {
        // per https://en.wikipedia.org/wiki/Keyboard_layout
        // almost all keyboard layouts allow to press either
        // '+' or '=' unshifted (and one of them is also often
        // close to '-'); the other two alternatives are for
        // the major exception: the two Swiss layouts
        case '+':
        case '=':
        case 0xE0:
        case 0xE4: {
            HwndSendCommand(win->hwndFrame, CmdZoomIn);
        } break;
        case '-': {
            HwndSendCommand(win->hwndFrame, CmdZoomOut);
        } break;
        case '/':
            if (!gIsDivideKeyDown) {
                FindFirst(win);
            }
            gIsDivideKeyDown = false;
            break;
        case 'b':
            OnFrameKeyB(win);
            break;
    }
}

static bool FrameOnSysChar(MainWindow* win, WPARAM key) {
    // use Alt+1 to Alt+8 for selecting the first 8 tabs and Alt+9 for the last tab
    if (win->tabsVisible && ('1' <= key && key <= '9')) {
        TabsSelect(win, key < '9' ? (int)(key - '1') : win->TabCount() - 1);
        return true;
    }
    // Alt + Space opens a sys menu
    if (key == ' ') {
        OpenSystemMenu(win);
        return true;
    }
    return false;
}

static void OnSidebarSplitterMove(Splitter::MoveEvent* ev) {
    Splitter* splitter = ev->w;
    HWND hwnd = splitter->hwnd;
    MainWindow* win = FindMainWindowByHwnd(hwnd);

    Point pcur = HwndGetCursorPos(win->hwndFrame);
    int sidebarDx = pcur.x; // without splitter

    // make sure to keep this in sync with the calculations in RelayoutFrame
    // note: without the min/max(..., rToc.dx), the sidebar will be
    //       stuck at its width if it accidentally got too wide or too narrow
    Rect rFrame = ClientRect(win->hwndFrame);
    Rect rToc = ClientRect(win->hwndTocBox);
    int minDx = std::min(kSidebarMinDx, rToc.dx);
    int maxDx = std::max(rFrame.dx / 2, rToc.dx);
    if (sidebarDx < minDx || sidebarDx > maxDx) {
        ev->resizeAllowed = false;
        return;
    }

    RelayoutFrame(win, false, sidebarDx);
}

static void OnFavSplitterMove(Splitter::MoveEvent* ev) {
    Splitter* splitter = ev->w;
    HWND hwnd = splitter->hwnd;
    MainWindow* win = FindMainWindowByHwnd(hwnd);

    Point pcur = HwndGetCursorPos(win->hwndCanvas);
    int tocDy = pcur.y; // without splitter

    // make sure to keep this in sync with the calculations in RelayoutFrame
    Rect rFrame = ClientRect(win->hwndFrame);
    Rect rToc = ClientRect(win->hwndTocBox);
    ReportIf(rToc.dx != ClientRect(win->hwndFavBox).dx);
    int minDy = std::min(kTocMinDy, rToc.dy);
    int maxDy = std::max(rFrame.dy - kTocMinDy, rToc.dy);
    if (tocDy < minDy || tocDy > maxDy) {
        ev->resizeAllowed = false;
        return;
    }
    gGlobalPrefs->tocDy = tocDy;
    RelayoutFrame(win, false, rToc.dx);
}

static void DeferredLoadTocTree(MainWindow* win) {
    if (!IsMainWindowValid(win) || win->isBeingClosed) {
        return;
    }
    if (!win->tocVisible || win->tocLoaded) {
        return;
    }
    LoadTocTree(win);
    if (win->tocLoaded && win->ctrl) {
        UpdateTocSelection(win, win->ctrl->CurrentPageNo());
        RefreshSidebarDpiFonts(win);
    }
    if (IsFindUIVisible(win)) {
        RefreshFindSearchBlockedStatus(win);
    }
}

void SetSidebarVisibility(MainWindow* win, bool tocVisible, bool showFavorites, bool relayout) {
    if (gPluginMode || !CanAccessDisk()) {
        showFavorites = false;
    }

    if (!win->IsDocLoaded() || !win->ctrl->HasToc()) {
        tocVisible = false;
    }

    if (PM_BLACK_SCREEN == win->presentation || PM_WHITE_SCREEN == win->presentation) {
        tocVisible = false;
        showFavorites = false;
    }

    if (tocVisible && !win->tocLoaded) {
        // Build a placeholder TOC during progressive load (fast chapter-based page
        // numbers); it is rebuilt with correct pages when counting finishes.
        LoadTocTree(win);
        ReportIf(!win->tocLoaded);
    }

    if (showFavorites) {
        PopulateFavTreeIfNeeded(win);
    }

    if (!win->CurrentTab()) {
        ReportIf(tocVisible);
    } else if (!win->presentation) {
        win->CurrentTab()->showToc = tocVisible;
    } else if (PM_ENABLED == win->presentation) {
        win->CurrentTab()->showTocPresentation = tocVisible;
    }
    win->tocVisible = tocVisible;

    // TODO: make this a per-window setting as well?
    gGlobalPrefs->showFavorites = showFavorites;
    SetToolbarButtonCheckedState(win, CmdToggleBookmarks, tocVisible);

    if ((!tocVisible && HwndIsFocused(win->tocTreeView->hwnd)) ||
        (!showFavorites && HwndIsFocused(win->favTreeView->hwnd))) {
        HwndSetFocus(win->hwndFrame);
    }

    HwndSetVisibility(win->sidebarSplitter->hwnd, tocVisible || showFavorites);
    HwndSetVisibility(win->hwndTocBox, tocVisible);
    win->sidebarSplitter->isLive = true;

    HwndSetVisibility(win->favSplitter->hwnd, tocVisible && showFavorites);
    HwndSetVisibility(win->hwndFavBox, showFavorites);
    win->favSplitter->isLive = true;

    if (relayout) {
        RelayoutFrame(win, false);
        if (tocVisible) {
            RedrawWindow(win->hwndTocBox, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
        }
        if (showFavorites) {
            RedrawWindow(win->hwndFavBox, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
        }
        if (tocVisible || showFavorites) {
            InvalidateRect(win->sidebarSplitter->hwnd, nullptr, TRUE);
        }
        if (tocVisible && showFavorites) {
            InvalidateRect(win->favSplitter->hwnd, nullptr, TRUE);
        }
    }
}

// if url-encoded s is bigger than a reasonable URL path,
// we don't want to fail but truncate and encode less
static TempStr URLEncodeMayTruncateTemp(const char* s) {
    constexpr int kMaxURLLen = 1500;

    HRESULT hr;
    DWORD diff;
    WCHAR buf[kMaxURLLen + 1]{};
    DWORD flags = URL_ESCAPE_AS_UTF8;
    TempWStr ws = ToWStrTemp(s);
    // we can't predict the length of encoded string so we try
    // with increasingly smaller input strings, from 1500 down to 1000
    int maxLen = kMaxURLLen;
    for (int i = 0; i < 10; i++) {
        if (str::Leni(ws) >= maxLen) {
            ws[maxLen - 1] = 0;
        }
        DWORD cchSizeInOut = kMaxURLLen;
        hr = UrlEscapeW(ws, buf, &cchSizeInOut, flags);
        if (SUCCEEDED(hr)) {
            return ToUtf8Temp(buf);
        }
        // cchSizeInOut involves url-encoded characters
        // we can reduce ws by less characters than that
        // but don't know how many, so we use conservative guess
        diff = cchSizeInOut - kMaxURLLen;
        if (diff > 10) {
            diff = (diff * 2) / 3;
        }
        if ((int)diff >= maxLen) {
            // can't reduce further
            return nullptr;
        }
        maxLen -= diff;
    }
    return nullptr;
}

constexpr const char* kUserLangStr = "${userlang}";
constexpr const char* kSelectionStr = "${selection}";

// https://github.com/sumatrapdfreader/sumatrapdf/issues/4368
// for Google translate tl= arg seems to be ISO-639 lang code
// and we seem to use ISO-3166 country code
// this translates between them but is a heuristic that might be wrong
// https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes
// https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes

// first entry is value in gLangCodes, second is ISO 639 lang code
// I made it manually by looking at trans_lang.go and
// https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
// but not fully and it might be incorrect anyway wrt. to other translation websites
static const char* gLangsMap = "am\0hy\0by\0be\0ca-xv\0ca\0cz\0cs\0kr\0ko\0vn\0vi\0cn\0zh-CN\0tw\0zh-TW\0";
static const char* GetISO639LangCodeFromLang(const char* lang) {
    int idx = seqstrings::StrToIdx(gLangsMap, lang);
    if (idx < 0 || idx % 2 != 0) {
        return lang;
    }
    lang = seqstrings::IdxToStr(gLangsMap, idx + 1);
    return lang;
}

static void LaunchBrowserWithSelection(WindowTab* tab, const char* urlPattern) {
    if (!tab || !HasPermission(Perm::InternetAccess) || !HasPermission(Perm::CopySelection)) {
        return;
    }

#if 0 // TODO: get selection from Chm
    if (tab->AsChm()) {
        tab->AsChm()->CopySelection();
        return;
    }
#endif

    // TODO: limit the size of the selection to e.g. 1 kB?
    bool isTextOnlySelectionOut; // if false, a rectangular selection
    TempStr selText = GetSelectedTextTemp(tab, "\n", isTextOnlySelectionOut);
    if (!selText) {
        return;
    }
    TempStr encodedSelection = URLEncodeMayTruncateTemp(selText);
    // ${userLang} and and ${selectin} are typed by user in settings file
    // to be shomewhat resilient against typos, we'll accept a different case
    const char* lang = trans::GetCurrentLangCode();
    if (str::Eq(lang, "kr")) {
        lang = "ko";
    }
    auto contryCode = GetISO639LangCodeFromLang(lang);
    TempStr uri = str::ReplaceNoCaseTemp(urlPattern, kUserLangStr, contryCode);
    uri = str::ReplaceNoCaseTemp(uri, kSelectionStr, encodedSelection);
    LaunchBrowserWithReuse(uri);
}

enum class DoubaoPromptKind {
    Word,
    Sentence,
    Chinese,
};

static bool Utf8DecodeOne(const char*& s, char32_t* cpOut) {
    if (!s || !*s || !cpOut) {
        return false;
    }
    u8 c0 = (u8)s[0];
    if (c0 < 0x80) {
        *cpOut = c0;
        s += 1;
        return true;
    }
    if ((c0 & 0xE0) == 0xC0 && s[1]) {
        *cpOut = ((c0 & 0x1F) << 6) | ((u8)s[1] & 0x3F);
        s += 2;
        return true;
    }
    if ((c0 & 0xF0) == 0xE0 && s[1] && s[2]) {
        *cpOut = ((c0 & 0x0F) << 12) | (((u8)s[1] & 0x3F) << 6) | ((u8)s[2] & 0x3F);
        s += 3;
        return true;
    }
    if ((c0 & 0xF8) == 0xF0 && s[1] && s[2] && s[3]) {
        *cpOut = ((c0 & 0x07) << 18) | (((u8)s[1] & 0x3F) << 12) | (((u8)s[2] & 0x3F) << 6) | ((u8)s[3] & 0x3F);
        s += 4;
        return true;
    }
    *cpOut = c0;
    s += 1;
    return true;
}

static bool IsCjkIdeograph(char32_t cp) {
    return (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) || (cp >= 0xF900 && cp <= 0xFAFF);
}

static bool IsLatinLetter(char32_t cp) {
    return (cp >= 'A' && cp <= 'Z') || (cp >= 'a' && cp <= 'z');
}

static bool SelectionContainsCjk(const char* s) {
    const char* p = s;
    while (p && *p) {
        char32_t cp = 0;
        if (!Utf8DecodeOne(p, &cp)) {
            break;
        }
        if (IsCjkIdeograph(cp)) {
            return true;
        }
    }
    return false;
}

static bool SelectionIsSingleWord(const char* s) {
    if (!s || !*s) {
        return false;
    }
    const char* p = s;
    int letterCount = 0;
    while (*p) {
        if (str::IsWs(*p)) {
            return false;
        }
        char32_t cp = 0;
        if (!Utf8DecodeOne(p, &cp)) {
            break;
        }
        if (cp == '-' || cp == '\'' || cp == '.') {
            continue;
        }
        if (IsLatinLetter(cp)) {
            letterCount++;
            continue;
        }
        return false;
    }
    return letterCount > 0 && letterCount <= 48;
}

static DoubaoPromptKind ClassifyDoubaoSelection(const char* s) {
    char* trimmed = str::Dup(s);
    str::TrimWSInPlace(trimmed, str::TrimOpt::Both);
    defer {
        str::Free(trimmed);
    };
    if (str::IsEmpty(trimmed)) {
        return DoubaoPromptKind::Sentence;
    }
    if (SelectionContainsCjk(trimmed)) {
        return DoubaoPromptKind::Chinese;
    }
    if (SelectionIsSingleWord(trimmed)) {
        return DoubaoPromptKind::Word;
    }
    return DoubaoPromptKind::Sentence;
}

static TempStr BuildDoubaoPromptTemp(const char* selection, DoubaoPromptKind kind) {
    char* trimmed = str::Dup(selection);
    str::TrimWSInPlace(trimmed, str::TrimOpt::Both);
    defer {
        str::Free(trimmed);
    };
    switch (kind) {
        case DoubaoPromptKind::Word:
            return str::FormatTemp("%s\n%s",
                                   _TRA("Please look up this word and give phonetic transcription, "
                                        "pronunciation, definition, examples, and mnemonics:"),
                                   trimmed);
        case DoubaoPromptKind::Chinese:
            return str::FormatTemp("%s\n%s", _TRA("Please explain what the following Chinese text means:"), trimmed);
        case DoubaoPromptKind::Sentence:
            return str::FormatTemp("%s\n%s",
                                   _TRA("Please translate this sentence and analyze its syntax and difficult "
                                        "vocabulary:"),
                                   trimmed);
    }
    return str::FormatTemp("%s", trimmed);
}

static AiChatService ActiveAiChatService() {
    if (!gGlobalPrefs) {
        return AiChatService::Doubao;
    }
    const char* p = gGlobalPrefs->aiChatProvider;
    if (str::EqI(p, "deepseek")) {
        return AiChatService::DeepSeek;
    }
    if (str::EqI(p, "chatgpt")) {
        return AiChatService::ChatGPT;
    }
    return AiChatService::Doubao;
}

static void AnalyzeSelectionWithDoubao(WindowTab* tab) {
    if (!tab || !tab->win || !gGlobalPrefs->enableAskAI || !HasPermission(Perm::InternetAccess) ||
        !HasPermission(Perm::CopySelection)) {
        return;
    }

    bool isTextOnlySelectionOut;
    TempStr selText = GetSelectedTextTemp(tab, "\n", isTextOnlySelectionOut);
    if (!selText) {
        return;
    }

    DisplayModel* dm = tab->AsFixed();
    if (dm && !gDisableDocumentRestrictions && !dm->GetEngine()->AllowsCopyingText()) {
        NotificationCreateArgs args;
        args.hwndParent = tab->win->hwndCanvas;
        args.msg = _TRA("Copying text was denied");
        args.timeoutMs = 3000;
        ShowNotification(args);
        return;
    }

    DoubaoPromptKind kind = ClassifyDoubaoSelection(selText);
    TempStr prompt = BuildDoubaoPromptTemp(selText, kind);
    if (!prompt) {
        return;
    }

    AiChatService service = ActiveAiChatService();
    LaunchAiChatWithPromptAsync(service, prompt);

    NotificationCreateArgs args;
    args.hwndParent = tab->win->hwndCanvas;
    args.msg = _TRA("Question sent to AI.");
    args.timeoutMs = 5000;
    ShowNotification(args);
}

// TODO: rather arbitrary divide of responsibility between this and CopySelectionToClipboard()
static void CopySelectionInTabToClipboard(WindowTab* tab) {
    // Don't break the shortcut for text boxes
    if (!tab || !tab->win) {
        return;
    }
    if (HwndIsFocused(tab->win->hwndFindEdit) || HwndIsFocused(tab->win->hwndPageEdit)) {
        SendMessageW(GetFocus(), WM_COPY, 0, 0);
        return;
    }
    if (!HasPermission(Perm::CopySelection)) {
        return;
    }
    if (tab->AsChm()) {
        tab->AsChm()->CopySelection();
        return;
    }
    if (tab->selectionOnPage) {
        CopySelectionToClipboard(tab->win);
        return;
    }
    if (tab->AsFixed()) {
        NotificationCreateArgs args;
        args.hwndParent = tab->win->hwndCanvas;
        args.msg = _TRA("Select content with Ctrl+left mouse button");
        args.timeoutMs = 2000;
        ShowNotification(args);
    }
}

static void OnMenuCustomZoom(MainWindow* win) {
    if (!win->IsDocLoaded()) {
        return;
    }

    float virtZoom = win->ctrl->GetZoomVirtual();
    if (!Dialog_CustomZoom(win->hwndFrame, win->AsChm() != nullptr, &virtZoom)) {
        return;
    }
    SmartZoom(win, virtZoom, nullptr, true);
}

// this is a directory for not important data, like downloaded symbols
// this directory is the same for installed / portable etc. versions
TempStr GetNotImportantDataDirTemp() {
    TempStr dir = GetSpecialFolderTemp(CSIDL_LOCAL_APPDATA, false);
    if (!dir) {
        return nullptr;
    }
    return path::JoinTemp(dir, "SumatraPDF-data");
}

TempStr GetLogFilePathTemp() {
    TempStr dir = GetNotImportantDataDirTemp();
    if (!dir) {
        return nullptr;
    }
    // TODO: maybe use unique name
    return path::JoinTemp(dir, "sumatra-log.txt");
}

// separate directory for each build number / type
TempStr GetVerDirNameTemp(const char* prefix) {
    auto variant = gIsPreReleaseBuild ? "prerel" : "rel";
    if (gIsDebugBuild) {
        variant = "dbg";
    }
    auto bits = IsProcess64() ? "64" : "32";
    if (IsArmBuild()) {
        bits = "arm64";
    }
    auto ver = CURR_VERSION_STRA; // 3.6.16105 for pre-release,  3.5.2 for release
    // TODO: something different for store build?
    return str::FormatTemp("%s%s-%s-%s", prefix, variant, ver, bits);
}

TempStr GetCrashInfoDirTemp() {
    TempStr dataDir = GetNotImportantDataDirTemp();
    TempStr dirName = GetVerDirNameTemp("crashinfo-");
    return path::JoinTemp(dataDir, dirName);
}

void ShowLogFileSmart() {
    TempStr path = gLogFilePath;
    if (path == nullptr) {
        path = GetLogFilePathTemp();
    }
    WriteCurrentLogToFile(path);
    LaunchFileIfExists(path);
}

struct ListPrintersResult {
    HWND hwndParent;
    char* text;
};

static void ListPrintersShowResult(ListPrintersResult* d) {
    RemoveNotificationsForGroup(d->hwndParent, kNotifActionResponse);
    ShowTextInWindow("SumatraPDF - Printers", d->text);
    str::Free(d->text);
    delete d;
}

static void ListPrintersThread(HWND* hwndPtr) {
    StrBuilder out;
    GetPrintersInfo(out);
    auto d = new ListPrintersResult{*hwndPtr, str::Dup(out.CStr())};
    delete hwndPtr;
    uitask::Post(MkFunc0<ListPrintersResult>(ListPrintersShowResult, d));
}

void ReopenLastClosedFile(MainWindow* win) {
    char* path = PopRecentlyClosedDocument();
    if (!path) {
        return;
    }
    LoadArgs args(path, win);
    SetUserOpenActivateExisting(args);
    LoadDocument(&args);
}

void CopyFilePath(WindowTab* tab) {
    if (!tab) {
        return;
    }
    const char* path = tab->filePath;
    CopyTextToClipboard(path);
}

Kind kNotifClearHistory = "clearHistry";

struct ClearHistoryData {
    MainWindow* win = nullptr;
    int nFiles = 0;
};

static void ClearHistoryFinish(ClearHistoryData* d) {
    AutoDelete delData(d);
    MainWindow* win = d->win;
    if (!IsMainWindowValid(win)) {
        return;
    }
    RemoveNotificationsForGroup(win->hwndCanvas, kNotifClearHistory);
    HwndRepaintNow(win->hwndCanvas);
    TempStr msg2 = str::FormatTemp(_TRA("Cleared history of %d files, deleted thumbnails."), d->nFiles);
    ShowTemporaryNotification(win->hwndCanvas, msg2, kNotif5SecsTimeOut);
}

static void ClearHistoryAsync(ClearHistoryData* d) {
    DeleteThumbnailCacheDirectory();
    TempStr symDir = GetCrashInfoDirTemp();
    dir::RemoveAll(symDir);
    auto fn = MkFunc0<ClearHistoryData>(ClearHistoryFinish, d);
    uitask::Post(fn, "TaksClearHistoryAsyncPart");
    DestroyTempAllocator();
}

void ClearHistory(MainWindow* win) {
    if (!win) {
        // TODO: find current active MainWindow ?
        return;
    }

    // TODO: what is relation between gFileHistory and gGlobalPrefs->fileStates?
    int nFiles = 0;
    if (gFileHistory.states) {
        nFiles = gFileHistory.states->Size();
    }
    gFileHistory.Clear(false);

    /*
    Vec<FileState*>* files = gGlobalPrefs->fileStates;
    int nFiles = 0;
    if (files) {
        nFiles = files->Size();
        DeleteVecMembers(*files);
        delete files;
    }
    gGlobalPrefs->fileStates = new Vec<FileState*>();
    if (gGlobalPrefs->sessionData) {
        DeleteVecMembers(*gGlobalPrefs->sessionData);
        delete gGlobalPrefs->sessionData;
    }
    gGlobalPrefs->sessionData = new Vec<SessionData*>();
    */

    SaveSettings();

    const char* msg = _TRA("Clearing history...");
    NotificationCreateArgs args;
    args.groupId = kNotifClearHistory;
    args.hwndParent = win->hwndCanvas;
    args.timeoutMs = kNotif5SecsTimeOut;
    ShowNotification(args);
    auto data = new ClearHistoryData;
    data->win = win;
    data->nFiles = nFiles;
    auto fn = MkFunc0<ClearHistoryData>(ClearHistoryAsync, data);
    RunAsync(fn, "ClearHistoryAsync");
}

static void TogglePredictiveRender(MainWindow* win) {
    gPredictiveRender = !gPredictiveRender;
    NotificationCreateArgs args;
    args.hwndParent = win->hwndCanvas;
    args.msg = gPredictiveRender ? "Enabled predictive render" : "Disabled predictie render";
    args.timeoutMs = 3000;
    ShowNotification(args);
}

static void DownloadDebugSymbols() {
    TempStr msg = (TempStr) "Symbols were already downloaded";

    bool ok = AreSymbolsDownloaded(gSymbolsDir);
    if (ok) {
        goto ShowMessage;
    }
    ok = CrashHandlerDownloadSymbols();
    if (!ok) {
        msg = (TempStr) "Failed to download symbols";
        goto ShowMessage;
    }
    msg = str::FormatTemp("Downloaded symbols to %s", gSymbolsDir);
    {
        bool didInitializeDbgHelp = InitializeDbgHelp(false);
        ReportIfFast(!didInitializeDbgHelp);
    }
ShowMessage:
    MessageBoxWarning(nullptr, msg, "Downloading symbols");
}

// try to trigger a crash due to corrupting allocator
// this is a different kind of a crash than just referencing invalid memory
// as corrupted memory migh prevent crash handler from working
// this can be used to test that crash handler still works
// TODO: maybe corrupt some more
void DebugCorruptMemory() {
    if (!gIsDebugBuild) {
        return;
    }
    char* s = (char*)malloc(23);
    char* d = (char*)malloc(34);
    free(s);
    free(d);
    // this triggers ntdll.dll!RtlReportCriticalFailure()
    // cppcheck-suppress doubleFree
    free(s);
}

static bool ExtractFiles(lzma::SimpleArchive* archive, const char* destDir) {
    logf("ExtractFiles(): dir '%s'\n", destDir);
    lzma::FileInfo* fi;
    u8* uncompressed;
    bool ok;

    int nFiles = archive->filesCount;

    for (int i = 0; i < nFiles; i++) {
        fi = &archive->files[i];
        uncompressed = lzma::GetFileDataByIdx(archive, i, nullptr);

        if (!uncompressed) {
            logf("ExtractFiles: lzma::GetFileDataByIdx() failed\n");
            return false;
        }
        TempStr filePath = path::JoinTemp(destDir, fi->name);
        ok = dir::CreateForFile(filePath);
        if (!ok) {
            logf("ExtractFiles: dir::CreateForFile(%s) failed\n", filePath);
            free(uncompressed);
            return false;
        }

        ByteSlice d = {uncompressed, fi->uncompressedSize};
        ok = file::WriteFile(filePath, d);
        free(uncompressed);
        if (!ok) {
            logf("ExtractFiles: lzma::Write(%s) failed\n", filePath);
            return false;
        }
        logf("  extracted '%s'\n", filePath);
    }

    return true;
}

constexpr const char* kManualIndex = "SumatraPDF-documentation.html";
constexpr const char* kManualKeyboard = "Keyboard-shortcuts.html";

static LoadedDataResource gManualArchiveData;
static lzma::SimpleArchive gManualArchive{};
static SimpleBrowserWindow* gManualBrowserWindow = nullptr;
static bool gUseOurWindowForManual = false;

static void OnDestroyManualBrowserWindow(Wnd::DestroyEvent*) {
    gManualBrowserWindow = nullptr;
}

void DeleteManualBrowserWindow() {
    delete gManualBrowserWindow;
}

static void OpenManualAtFile(const char* htmlFileName) {
    TempStr dataDir = GetNotImportantDataDirTemp();
    TempStr dirName = GetVerDirNameTemp("crashinfo-");
    TempStr dir = path::JoinTemp(dataDir, dirName);
    TempStr htmlFilePath = path::JoinTemp(dir, htmlFileName);
    // in debug build we force extraction because those could be stale files
    bool ok = !gIsDebugBuild && file::Exists(htmlFilePath);
    if (ok) {
        logf("OpenManualAtFile: '%s' already exists\n", htmlFilePath);
        goto OpenFileInBrowser;
    }

    ok = gManualArchive.filesCount > 0;
    if (!ok) {
        ok = LockDataResource(IDR_MANUAL_PAK, &gManualArchiveData);
        if (!ok) {
            logf("OpenManualAtFile(): LockDataResource() failed\n");
            return;
        }
        auto data = gManualArchiveData.data;
        auto size = gManualArchiveData.dataSize;
        ok = lzma::ParseSimpleArchive(data, (size_t)size, &gManualArchive);
        if (!ok) {
            logf("OpenManualAtFile: lzma:ParseSimpleArchive() failed\n");
            return;
        }
        logf("OpenManualAtFile(): opened manual.dat, %d files\n", gManualArchive.filesCount);
        ok = gManualArchive.filesCount > 0;
        if (!ok) {
            return;
        }
    }
    dir::CreateAll(dir);
    // on error, ExtractFiles() shows error message itself
    ok = ExtractFiles(&gManualArchive, dir);
    if (!ok) {
        return;
    }
OpenFileInBrowser:
    TempStr url = str::JoinTemp("file://", htmlFilePath);

    if (gUseOurWindowForManual) {
        if (gManualBrowserWindow) {
            // re-use existing manual window
            gManualBrowserWindow->webView->Navigate(url);
            return;
        }
        if (!gManualBrowserWindow) {
            // try to launch in our window
            SimpleBrowserCreateArgs args;
            args.title = "SumatraPDF Documentation";
            args.url = url;
            // TODO: dataDir
            gManualBrowserWindow = SimpleBrowserWindowCreate(args);
            if (gManualBrowserWindow != nullptr) {
                auto fn = MkFunc1Void<Wnd::DestroyEvent*>(OnDestroyManualBrowserWindow);
                gManualBrowserWindow->onDestroy = fn;
                return;
            }
        }
    }
    // couldn't create WebView2 window so fallback to default web browser
    SumatraLaunchBrowser(url);
}

static void SetAnnotCreateArgsFromCommand(AnnotCreateArgs& args, CustomCommand* cmd) {
    args.copyToClipboard = GetCommandBoolArg(cmd, kCmdArgCopyToClipboard, false);
    args.setContentToSelection = GetCommandBoolArg(cmd, kCmdArgSetContent, false);

    auto col = GetCommandArg(cmd, kCmdArgColor);
    if (col && col->colorVal.parsedOk) {
        args.col = col->colorVal;
    }

    auto bgCol = GetCommandArg(cmd, kCmdArgBgColor);
    if (bgCol && bgCol->colorVal.parsedOk) {
        args.bgCol = bgCol->colorVal;
    }

    auto interiorCol = GetCommandArg(cmd, kCmdArgInteriorColor);
    if (interiorCol && interiorCol->colorVal.parsedOk) {
        args.interiorCol = interiorCol->colorVal;
    }

    args.opacity = GetCommandIntArg(cmd, kCmdArgOpacity, 100);
    setMinMax(args.opacity, 0, 100);

    args.textSize = GetCommandIntArg(cmd, kCmdArgTextSize, -1);
    if (args.textSize >= 0) {
        // set some reasonable limits
        setMinMax(args.textSize, 5, 128);
    }

    args.borderWidth = GetCommandIntArg(cmd, kCmdArgBorderWidth, -1);
    if (args.borderWidth >= 0) {
        // set some reasonable limits
        setMinMax(args.borderWidth, 0, 128);
    }
}

static void SetAnnotCreateArgs(AnnotCreateArgs& args, CustomCommand* cmd) {
    if (cmd && (cmd->id != cmd->origId)) {
        // a command definition doesn't use values from settings
        // must specify everything explicitly
        SetAnnotCreateArgsFromCommand(args, cmd);
        return;
    }
    auto& a = gGlobalPrefs->annotations;
    ParsedColor* col = nullptr;
    ParsedColor* bgCol = nullptr;
    auto typ = args.annotType;
    if (typ == AnnotationType::Text) {
        col = GetParsedColor(a.textIconColor, a.textIconColorParsed);
    } else if (typ == AnnotationType::Underline) {
        col = GetParsedColor(a.underlineColor, a.underlineColorParsed);
    } else if (typ == AnnotationType::Highlight) {
        col = GetParsedColor(a.highlightColor, a.highlightColorParsed);
    } else if (typ == AnnotationType::Squiggly) {
        col = GetParsedColor(a.squigglyColor, a.squigglyColorParsed);
    } else if (typ == AnnotationType::StrikeOut) {
        col = GetParsedColor(a.strikeOutColor, a.strikeOutColorParsed);
    } else if (typ == AnnotationType::FreeText) {
        col = GetParsedColor(a.freeTextColor, a.freeTextColorParsed);
        bgCol = GetParsedColor(a.freeTextBackgroundColor, a.freeTextBackgroundColorParsed);
        if (bgCol && bgCol->parsedOk) {
            args.bgCol = *bgCol;
        }
        args.opacity = a.freeTextOpacity;
        args.textSize = a.freeTextSize;
        args.borderWidth = a.freeTextBorderWidth;
    } else {
        logf("SetAnnotCreateArgs: unexpected type %d for default prefs color\n", (int)typ);
        // ReportIf(true);
    }
    if (col && col->parsedOk) {
        args.col = *col;
    }
}

static COLORREF GetEbookAnnotationColor(AnnotationType type, const AnnotCreateArgs& args, CustomCommand* cmd) {
    if (cmd && cmd->id != cmd->origId && args.col.parsedOk) {
        return args.col.col;
    }
    if (args.col.parsedOk) {
        return args.col.col;
    }
    AnnotCreateArgs prefsArgs{type};
    SetAnnotCreateArgs(prefsArgs, nullptr);
    if (prefsArgs.col.parsedOk) {
        return prefsArgs.col.col;
    }
    if (type == AnnotationType::FreeText) {
        return RGB(0, 0, 0);
    }
    if (type == AnnotationType::Caret) {
        return RGB(0, 0, 255);
    }
    if (type == AnnotationType::Stamp || type == AnnotationType::Line || type == AnnotationType::Square ||
        type == AnnotationType::Circle) {
        return RGB(255, 0, 0);
    }
    return GetDefaultAnnotationColor(type);
}

static void PasteImageFromClipboard(MainWindow* win) {
    if (!OpenClipboard(nullptr)) {
        return;
    }
    HBITMAP hbmp = (HBITMAP)GetClipboardData(CF_BITMAP);
    if (!hbmp) {
        CloseClipboard();
        return;
    }
    // create GDI+ bitmap from clipboard HBITMAP
    Gdiplus::Bitmap gdipBmp(hbmp, nullptr);
    CloseClipboard();

    if (gdipBmp.GetWidth() == 0 || gdipBmp.GetHeight() == 0) {
        return;
    }

    // get Downloads folder
    WCHAR* downloadsW = nullptr;
    HRESULT hr = SHGetKnownFolderPath(FOLDERID_Downloads, 0, nullptr, &downloadsW);
    if (FAILED(hr) || !downloadsW) {
        CoTaskMemFree(downloadsW);
        return;
    }
    TempStr downloadsDir = ToUtf8Temp(downloadsW);
    CoTaskMemFree(downloadsW);

    // generate unique path: clipboard.png, clipboard.1.png, etc.
    TempStr basePath = path::JoinTemp(downloadsDir, "clipboard.png");
    TempStr destPath = MakeUniqueFilePathTemp(basePath);

    // save as PNG
    CLSID pngClsid = GetGdiPlusEncoderClsid(L"image/png");
    TempWStr destW = ToWStrTemp(destPath);
    Gdiplus::Status status = gdipBmp.Save(destW, &pngClsid, nullptr);
    if (status != Gdiplus::Ok) {
        return;
    }

    // load the saved file
    if (win) {
        LoadArgs args(destPath, win);
        StartLoadDocument(&args);
    }
}

static void TocItemToText(StrBuilder& s, TocItem* item, int level) {
    while (item) {
        if (item->title) {
            for (int i = 0; i < level; i++) {
                s.AppendChar('\t');
            }
            s.Append(item->title);
            s.AppendChar('\n');
        }
        if (item->child) {
            int nextLevel = item->title ? level + 1 : level;
            TocItemToText(s, item->child, nextLevel);
        }
        item = item->next;
    }
}

static LRESULT FrameOnCommand(MainWindow* win, HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
    int cmdId = LOWORD(wp);
    bool openAnnotationEdit = false;

    if (cmdId >= 0xF000) {
        // handle system menu messages for the Window menu (needed for Tabs in Titlebar)
        return SendMessageW(hwnd, WM_SYSCOMMAND, wp, lp);
    }

    if (win && HandleMenuBarCommand(win, cmdId)) {
        return 0;
    }

    if (win && HandleReadAloudMenuCommand(win, cmdId)) {
        return 0;
    }

    if (CanAccessDisk()) {
        // check if the menuId belongs to an entry in the list of
        // recently opened files and load the referenced file if it does
        if ((cmdId >= CmdFileHistoryFirst) && (cmdId <= CmdFileHistoryLast)) {
            int idx = cmdId - (int)CmdFileHistoryFirst;
            FileState* state = gFileHistory.Get(idx);
            if (state) {
                LoadArgs args(state->filePath, win);
                SetUserOpenActivateExisting(args);
                LoadDocument(&args);
            }
            return 0;
        }
    }

    // 10 submenus max with 10 items each max (=100) plus generous buffer => 200
    static_assert(CmdFavoriteLast - CmdFavoriteFirst == 256, "wrong number of favorite menu ids");
    if ((cmdId >= CmdFavoriteFirst) && (cmdId <= CmdFavoriteLast)) {
        GoToFavoriteByMenuId(win, cmdId);
        return 0;
    }

    if (!win || win->isBeingClosed) {
        return DefWindowProc(hwnd, msg, wp, lp);
    }

    WindowTab* tab = win->CurrentTab();
    if (!win->IsCurrentTabAbout()) {
        if (CmdOpenWithKnownExternalViewerFirst < cmdId && cmdId < CmdOpenWithKnownExternalViewerLast) {
            ViewWithKnownExternalViewer(tab, cmdId);
            return 0;
        }
    }

    auto* ctrl = win->ctrl;
    DisplayModel* dm = win->AsFixed();

    Annotation* lastCreatedAnnot = nullptr;

    CustomCommand* cmd = FindCustomCommand(cmdId);
    if (cmd != nullptr) {
        cmdId = cmd->origId;
    }

    AnnotationType annotType = CmdIdToAnnotationType(cmdId);

    // most of them require a win, the few exceptions are no-ops
    switch (cmdId) {
        case CmdViewWithExternalViewer: {
            const char* cmdLine = GetCommandStringArg(cmd, kCmdArgCommandLine, nullptr);
            if (!cmdLine || !CanAccessDisk() || !tab || !file::Exists(tab->filePath)) {
                return 0;
            }
            const char* filter = GetCommandStringArg(cmd, kCmdArgFilter, nullptr);
            RunWithExe(tab, cmdLine, filter);
            return 0;
        }

        case CmdSetTheme: {
            auto theme = GetCommandStringArg(cmd, kCmdArgTheme, nullptr);
            if (theme) {
                SetTheme(theme);
                SaveSettings();
            }
            return 0;
        }

        case CmdSelectionHandler: {
            // TODO: handle kCmdArgExe
            auto url = GetCommandStringArg(cmd, kCmdArgURL, nullptr);
            if (!url) {
                return 0;
            }
            // try to auto-fix url
            bool isValidURL = (str::Find(url, "://") != nullptr);
            if (!isValidURL) {
                url = str::JoinTemp("https://", url);
            }
            LaunchBrowserWithSelection(tab, url);
            return 0;
        }

        case CmdExec: {
            auto filter = GetCommandStringArg(cmd, kCmdArgFilter, nullptr);
            auto cmdLine = GetCommandStringArg(cmd, kCmdArgExe, nullptr);
            if (cmdLine == nullptr) {
                return 0;
            }
            RunWithExe(tab, cmdLine, filter);
            return 0;
        }

        case CmdNewWindow:
            CreateAndShowMainWindow(nullptr);
            break;

        case CmdTabGroupSave:
            ShowSaveTabGroupDialog(win);
            break;

        case CmdTabGroupRestore:
            ShowOpenTabGroupDialog(win);
            break;

        case CmdDuplicateInNewWindow:
            DuplicateInNewWindow(win);
            break;

        case CmdDuplicateInNewTab:
            DuplicateInNewTab(win);
            break;

        case CmdOpenFile:
            OpenFile(win);
            break;

        case CmdShowInFolder:
            ShowCurrentFileInFolder(win);
            break;

        case CmdOpenPrevFileInFolder:
        case CmdOpenNextFileInFolder:
            if (!win->IsCurrentTabAbout()) {
                // folder browsing should also work when an error page is displayed,
                // so special-case it before the win->IsDocLoaded() check
                bool forward = cmdId == CmdOpenNextFileInFolder;
                OpenNextPrevFileInFolder(win, forward);
            }
            break;

        case CmdRenameFile:
            RenameCurrentFile(win);
            break;

        case CmdDeleteFile:
            DeleteCurrentFile(win);
            break;

        case CmdSaveAs:
            SaveCurrentFileAs(win);
            break;

        case CmdPrint:
            PrintCurrentFile(win);
            break;

        case CmdCopyFilePath:
            CopyFilePath(tab);
            break;

        case CmdCommandPalette: {
            const char* mode = nullptr;
            if (cmd) {
                mode = GetCommandStringArg(cmd, kCmdArgMode, nullptr);
            }
            RunCommandPalette(win, mode, 0);
        } break;

        case CmdClearHistory:
            ClearHistory(win);
            break;

        case CmdReopenLastClosedFile:
            ReopenLastClosedFile(win);
            break;

        case CmdShowLog:
            ShowLogFileSmart();
            break;

        case CmdScreenshot:
            TakeScreenshots();
            break;

        case CmdSetScreenshotHotkey:
            ShowSetScreenshotHotkeyDialog(win->hwndFrame);
            break;

        case CmdCropImage:
            ShowImageEditWindow(win, ImageEditMode::Crop);
            break;

        case CmdResizeImage:
            ShowImageEditWindow(win, ImageEditMode::Resize);
            break;

        case CmdPasteClipboardImage:
            PasteImageFromClipboard(win);
            break;

        case CmdListPrinters: {
            NotificationCreateArgs nargs;
            nargs.hwndParent = win->hwndCanvas;
            nargs.msg = _TRA("Collecting list of printers");
            ShowNotification(nargs);
            auto data = new HWND(win->hwndCanvas);
            RunAsync(MkFunc0<HWND>(ListPrintersThread, data), "ListPrinters");
            break;
        }

        case CmdNextTab:
        case CmdPrevTab: {
            bool reverse = cmdId == CmdPrevTab;
            TabsOnCtrlTab(win, reverse);
        } break;

        case CmdNextTabSmart:
        case CmdPrevTabSmart: {
            if (win && win->TabCount() > 1) {
                int advance = cmdId == CmdNextTabSmart ? 1 : -1;
                RunCommandPalette(win, kPalettePrefixTabs, advance);
            }
        } break;

        case CmdMoveTabRight:
        case CmdMoveTabLeft: {
            int dir = (cmdId == CmdMoveTabRight) ? 1 : -1;
            MoveTab(win, dir);
        } break;

        case CmdCloseAllTabs: {
            CloseAllTabs(win);
            break;
        }
        case CmdCloseOtherTabs:
        case CmdCloseTabsToTheRight:
        case CmdCloseTabsToTheLeft: {
            Vec<WindowTab*> toCloseOther;
            Vec<WindowTab*> toCloseRight;
            Vec<WindowTab*> toCloseLeft;
            CollectTabsToClose(win, tab, toCloseOther, toCloseRight, toCloseLeft);
            Vec<WindowTab*>& toClose = toCloseOther;
            if (cmdId == CmdCloseTabsToTheRight) {
                toClose = toCloseRight;
            }
            if (cmdId == CmdCloseTabsToTheLeft) {
                toClose = toCloseLeft;
            }
            for (WindowTab* t : toClose) {
                CloseTab(t, false);
            }
        } break;

        case CmdExit:
            OnMenuExit();
            break;

        case CmdReloadDocument:
            ReloadDocument(win, false);
            break;

        case CmdCreateShortcutToFile:
            CreateLnkShortcut(win);
            break;

        case CmdZoomFitWidthAndContinuous:
            ChangeZoomLevel(win, kZoomFitWidth, true);
            break;

        case CmdZoomFitPageAndSinglePage:
            ChangeZoomLevel(win, kZoomFitPage, false);
            break;

        case CmdZoomOut:
        case CmdZoomIn: {
            if (!win->IsDocLoaded()) {
                return 0;
            }
            float towards = (cmdId == CmdZoomIn) ? kZoomMax : kZoomMin;
            auto zoom = ctrl->GetNextZoomStep(towards);
            Point mousePos = HwndGetCursorPos(win->hwndCanvas);
            SmartZoom(win, zoom, &mousePos, true);
        } break;

        case CmdZoom6400:
        case CmdZoom3200:
        case CmdZoom1600:
        case CmdZoom800:
        case CmdZoom400:
        case CmdZoom200:
        case CmdZoom150:
        case CmdZoom125:
        case CmdZoom100:
        case CmdZoom50:
        case CmdZoom25:
        case CmdZoom12_5:
        case CmdZoom8_33:
        case CmdZoomFitPage:
        case CmdZoomFitWidth:
        case CmdZoomFitContent:
        case CmdZoomShrinkToFit:
        case CmdZoomActualSize:
            OnMenuZoom(win, cmdId);
            break;

        case CmdZoomCustom: {
            if (cmd && cmd->firstArg) {
                float virtZoom = cmd->firstArg->floatVal;
                SmartZoom(win, virtZoom, nullptr, true);
            } else {
                OnMenuCustomZoom(win);
            }
        } break;

        case CmdSinglePageView:
            SwitchToDisplayMode(win, DisplayMode::SinglePage, true);
            break;

        case CmdFacingView:
            SwitchToDisplayMode(win, DisplayMode::Facing, true);
            break;

        case CmdBookView:
            SwitchToDisplayMode(win, DisplayMode::BookView, true);
            break;

        case CmdToggleContinuousView:
            ToggleContinuousView(win);
            break;

        case CmdToggleMangaMode:
            ToggleMangaMode(win);
            break;

        case CmdToggleToolbar:
            OnMenuViewShowHideToolbar(win);
            break;

        case CmdChangeScrollbar:
            OnMenuChangeScrollbar(win->hwndFrame);
            break;

        case CmdChangeBackgroundColor:
            OnMenuChangeBackgroundColor(win);
            break;

        case CmdToggleUseTabs:
            gGlobalPrefs->useTabs = !gGlobalPrefs->useTabs;
            SaveSettings();
            RestartApplication();
            break;

        case CmdToggleTabsMru:
            gGlobalPrefs->tabsMru = !gGlobalPrefs->tabsMru;
            break;

        case CmdReadAloud: {
            if (!tab) {
                break;
            }

            bool isSpeaking = TtsIsSpeaking();
            bool canContinue = CanContinueReadAloud(tab);

            if (isSpeaking) {
                ReadAloudStopRememberPos();
                ToolbarUpdateStateForWindow(win, true);
            } else if (canContinue) {
                ReadAloudContinueInTab(tab);
            } else {
                ReadAloudInTab(tab);
            }
            break;
        }

        case CmdPauseReadAloud: {
            ReadAloudStopRememberPos();
            ToolbarUpdateStateForWindow(win, true);
            break;
        }

        case CmdContinueReadAloud: {
            if (!TtsIsSpeaking()) {
                ReadAloudContinueInTab(tab);
            }
            break;
        }

        case CmdStopReadAloud:
            ReadAloudPlaybackStop();
            break;

        case CmdReadAloudFromTopPage: {
            if (!tab) {
                break;
            }
            ReadAloudPrepareRestart(tab, win);
            ReadAloudFromViewportTopInTab(tab);
            break;
        }

        case CmdReadAloudSelection: {
            if (!tab) {
                break;
            }
            ReadAloudPrepareRestart(tab, win);
            ReadAloudSelectionInTab(tab);
            break;
        }

        case CmdReadAloudFromCursor: {
            if (!tab) {
                break;
            }
            Point readPt;
            if (ReadAloudResolveReadFromCursorPoint(win, &readPt)) {
                ReadAloudPrepareRestart(tab, win);
                ReadAloudFromCursorInTab(tab, readPt);
            }
            break;
        }

        case CmdSaveAnnotations: {
            SaveAnnotationsToExistingFile(tab);
            break;
        }

        case CmdToggleFrequentlyRead: {
            gGlobalPrefs->showStartPage = !gGlobalPrefs->showStartPage;
            win->RedrawAll(true);
            break;
        }

        case CmdInvokeInverseSearch: {
            InvokeInverseSearch(tab);
            break;
        }

        case CmdSaveAnnotationsNewFile: {
            SaveAnnotationsToMaybeNewPdfFile(tab);
            break;
        }

        case CmdToggleMenuBar: {
            ToggleMenuBar(win, false);
            break;
        }

        case CmdToggleWindowsPreviewer: {
            PreviousInstallationInfo info;
            GetPreviousInstallInfo(&info);
            if (info.installationDir) {
                if (IsPreviewInstalled()) {
                    UnRegisterPreviewer();
                } else {
                    RegisterPreviewer(info.allUsers, info.installationDir);
                }
            }
            break;
        }

        case CmdToggleWindowsSearchFilter: {
            PreviousInstallationInfo info;
            GetPreviousInstallInfo(&info);
            if (info.installationDir) {
                if (IsSearchFilterInstalled()) {
                    UnRegisterSearchFilter();
                } else {
                    RegisterSearchFilter(info.allUsers, info.installationDir);
                }
            }
            break;
        }

        case CmdChangeLanguage:
            OnMenuChangeLanguage(win->hwndFrame);
            break;

        case CmdToggleBookmarks:
        case CmdToggleTableOfContents:
            ToggleTocBox(win);
            break;

        case CmdScrollUpHalfPage: {
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, SB_PAGEUP);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            bool isCont = IsContinuous(win->ctrl->GetDisplayMode());
            int currentPos = GetScrollPos(win->hwndCanvas, SB_VERT);
            SendMessageW(win->hwndCanvas, WM_VSCROLL, SB_HALF_PAGEUP, 0);
            if (isCont && GetScrollPos(win->hwndCanvas, SB_VERT) == currentPos) {
                win->ctrl->GoToPrevPage(true);
            }
        } break;

        // TODO: do I need both CmdScrollUpPage and CmdGoToPrevPage
        case CmdScrollUpPage: {
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, SB_PAGEUP);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            int currentPos = GetScrollPos(win->hwndCanvas, SB_VERT);
            if (win->ctrl->GetZoomVirtual() != kZoomFitContent) {
                SendMessageW(win->hwndCanvas, WM_VSCROLL, SB_PAGEUP, 0);
            }
            if (GetScrollPos(win->hwndCanvas, SB_VERT) == currentPos) {
                win->ctrl->GoToPrevPage(true);
            }
        } break;

        case CmdScrollDown:
        case CmdScrollUp: {
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, cmdId == CmdScrollUp ? SB_LINEUP : SB_LINEDOWN);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            if (dm && dm->NeedVScroll() && dm->GetZoomVirtual() != kZoomFitContent) {
                int n = GetCommandIntArg(cmd, kCmdArgN, 1);
                WPARAM dir = (cmdId == CmdScrollUp) ? SB_LINEUP : SB_LINEDOWN;
                for (int i = 0; i < n; i++) {
                    SendMessageW(win->hwndCanvas, WM_VSCROLL, dir, 0);
                }
            } else {
                // in single page view or fit content, scrolls by page
                if (cmdId == CmdScrollUp) {
                    win->ctrl->GoToPrevPage(true);
                } else {
                    win->ctrl->GoToNextPage();
                }
            }
        } break;

        case CmdGoToPrevPage:
        case CmdGoToNextPage: {
            if (!win->IsDocLoaded()) {
                return 0;
            }
            int n = GetCommandIntArg(cmd, kCmdArgN, 1);
            for (int i = 0; i < n; i++) {
                if (cmdId == CmdGoToPrevPage) {
                    ctrl->GoToPrevPage();
                } else {
                    ctrl->GoToNextPage();
                }
            }
            break;
        }

        case CmdScrollDownHalfPage: {
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, SB_PAGEDOWN);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            bool isCont = IsContinuous(win->ctrl->GetDisplayMode());
            int currentPos = GetScrollPos(win->hwndCanvas, SB_VERT);
            SendMessageW(win->hwndCanvas, WM_VSCROLL, SB_HALF_PAGEDOWN, 0);
            if (isCont && GetScrollPos(win->hwndCanvas, SB_VERT) == currentPos) {
                win->ctrl->GoToNextPage();
            }
        } break;

        case CmdScrollDownPage: {
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, SB_PAGEDOWN);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            int currentPos = GetScrollPos(win->hwndCanvas, SB_VERT);
            if (win->ctrl->GetZoomVirtual() != kZoomFitContent) {
                SendMessageW(win->hwndCanvas, WM_VSCROLL, SB_PAGEDOWN, 0);
            }
            if (GetScrollPos(win->hwndCanvas, SB_VERT) == currentPos) {
                win->ctrl->GoToNextPage();
            }
        } break;

        // TODO: rename CmdScrollLeftOrPrevPage
        case CmdScrollLeft: {
            if (!win->IsDocLoaded()) {
                return 0;
            }
            if (dm && dm->NeedHScroll()) {
                SendMessageW(win->hwndCanvas, WM_HSCROLL, SB_LINELEFT, 0);
            } else {
                win->ctrl->GoToPrevPage();
            }
        } break;

        case CmdScrollLeftPage: {
            SendMessageW(win->hwndCanvas, WM_HSCROLL, SB_PAGELEFT, 0);
        } break;

        case CmdScrollRight: {
            if (!win->IsDocLoaded()) {
                return 0;
            }
            if (dm && dm->NeedHScroll()) {
                SendMessageW(win->hwndCanvas, WM_HSCROLL, SB_LINERIGHT, 0);
            } else {
                win->ctrl->GoToNextPage();
            }
        } break;

        case CmdScrollRightPage: {
            SendMessageW(win->hwndCanvas, WM_HSCROLL, SB_PAGERIGHT, 0);
        } break;

        case CmdGoToFirstPage:
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, SB_TOP);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            ctrl->GoToFirstPage();
            break;

        case CmdGoToLastPage:
            if (win->IsCurrentTabAbout()) {
                HomePageOnVScroll(win, SB_BOTTOM);
                return 0;
            }
            if (!win->IsDocLoaded()) {
                return 0;
            }
            if (!ctrl->GoToLastPage()) {
                SendMessageW(win->hwndCanvas, WM_VSCROLL, SB_BOTTOM, 0);
            }
            break;

        case CmdGoToPage:
            OnMenuGoToPage(win);
            break;

        case CmdTogglePresentationMode:
            TogglePresentationMode(win);
            break;

        case CmdToggleFullscreen:
            ToggleFullScreen(win);
            break;

        case CmdRotateLeft:
            if (dm) {
                dm->RotateBy(-90);
            }
            break;

        case CmdRotateRight:
            if (dm) {
                dm->RotateBy(90);
            }
            break;

        case CmdFindFirst:
            if (win->IsCurrentTabAbout()) {
                HomePageFocusSearch(win);
            } else {
                FindFirst(win);
            }
            break;

        case CmdFindNext:
            FindNext(win);
            break;

        case CmdFindPrev:
            FindPrev(win);
            break;

        case CmdFindToggleMatchCase:
            FindToggleMatchCase(win);
            break;

        case CmdFindToggleMatchWholeWord:
            FindToggleMatchWholeWord(win);
            break;

        case CmdFindNextSel:
            FindSelection(win, TextSearch::Direction::Forward);
            break;

        case CmdFindPrevSel:
            FindSelection(win, TextSearch::Direction::Backward);
            break;

        case CmdHelpVisitWebsite:
            SumatraLaunchBrowser(kWebsiteURL);
            break;

        case CmdHelpOpenManual:
            OpenManualAtFile(kManualIndex);
            break;

        case CmdHelpOpenKeyboardShortcuts:
            OpenManualAtFile(kManualKeyboard);
            break;

        case CmdHelpOpenManualOnWebsite:
            SumatraLaunchBrowser(kManualURL);
            break;

        case CmdContributeTranslation:
            SumatraLaunchBrowser(kContributeTranslationsURL);
            break;

        case CmdHelpAbout:
            ShowAboutWindow(win);
            break;

        case CmdCheckUpdate:
            StartAsyncUpdateCheck(win, UpdateCheck::UserInitiated);
            break;

        case CmdOptions:
            ShowOptionsDialog(win);
            break;

        case CmdAdvancedOptions:
        case CmdAdvancedSettings:
            OpenAdvancedOptions();
            break;

        case CmdAssociateCommonFileTypes: {
            TempStr exePath = GetSelfExePathTemp();
            if (!str::IsEmpty(exePath)) {
                WriteExtendedFileExtensionInfo(HKEY_CURRENT_USER, exePath);
            }
            bool launched = LaunchFileShell("ms-settings:defaultapps?registeredAppUser=SumatraPDF");
            if (!launched) {
                CreateProcessHelper("control.exe", "/name Microsoft.DefaultPrograms");
            }
            break;
        }

        case CmdSendByEmail:
            SendAsEmailAttachment(tab, win->hwndFrame);
            break;

        case CmdProperties: {
            ShowProperties(win->hwndFrame, win->ctrl);
            break;
        }

        case CmdPdShowInfo: {
            if (tab && tab->filePath && CouldBePDFDoc(tab)) {
                if (tab->hwndPDFInfo && IsWindow(tab->hwndPDFInfo)) {
                    SetForegroundWindow(tab->hwndPDFInfo);
                } else {
                    TempStr info = EngineMupdfGetPdfInfo(tab->filePath);
                    if (info) {
                        tab->hwndPDFInfo = ShowTextInWindow("PDF Info", info, &tab->hwndPDFInfo);
                    }
                }
            }
            break;
        }

        case CmdShowErrors: {
            EngineBase* engine = dm ? dm->GetEngine() : nullptr;
            if (engine && engine->errors.Size() > 0) {
                char* text = Join(&engine->errors, "");
                ShowTextInWindow("Errors", text);
                str::Free(text);
            }
            break;
        }

        case CmdDocumentShowOutline: {
            if (tab && tab->ctrl && tab->ctrl->HasToc()) {
                if (tab->hwndPDFOutline && IsWindow(tab->hwndPDFOutline)) {
                    SetForegroundWindow(tab->hwndPDFOutline);
                } else if (tab->filePath && CouldBePDFDoc(tab)) {
                    TempStr outline = EngineMupdfGetPdfOutline(tab->filePath);
                    if (outline) {
                        tab->hwndPDFOutline = ShowTextInWindow("Document Outline", outline, &tab->hwndPDFOutline);
                    }
                } else {
                    TocTree* tocTree = tab->ctrl->GetToc();
                    if (tocTree && tocTree->root) {
                        StrBuilder s;
                        TocItemToText(s, tocTree->root, 0);
                        tab->hwndPDFOutline = ShowTextInWindow("Document Outline", s.CStr(), &tab->hwndPDFOutline);
                    }
                }
            }
            break;
        }

        case CmdPdfBake:
            ShowPdfBakeDialog(win);
            break;

        case CmdPdfCompress:
            ShowPdfCompressDialog(win);
            break;

        case CmdPdfDecompress:
            ShowPdfDecompressDialog(win);
            break;

        case CmdPdfDeletePages:
            ShowPdfDeletePageDialog(win);
            break;

        case CmdPdfExtractPages:
            ShowPdfExtractPagesDialog(win);
            break;

        case CmdPdfEncrypt:
            ShowPdfEncryptDialog(win);
            break;

        case CmdPdfDecrypt:
            ShowPdfDecryptDialog(win);
            break;

        case CmdDocumentExtractText:
            ShowPdfExtractTextDialog(win);
            break;

        case CmdMoveFrameFocus:
            if (!HwndIsFocused(win->hwndFrame)) {
                HwndSetFocus(win->hwndFrame);
            } else if (win->tocVisible) {
                HwndSetFocus(win->tocTreeView->hwnd);
            }
            break;

        case CmdTranslateSelectionWithGoogle:
            LaunchBrowserWithSelection(
                tab, "https://translate.google.com/?op=translate&sl=auto&tl=${userlang}&text=${selection}");
            break;

        case CmdTranslateSelectionWithDeepL: {
            // Note: we don't know if selected string is English but I don't know
            // how to get deepl.com to auto-detect language
            const char* lang = trans::GetCurrentLangCode();
            const char* uri = "https://www.deepl.com/translator#en/${userlang}/${selection}";
            if (str::Eq(lang, "en")) {
                // it's pointless to translate from English to English
                // this format will hopefully trigger auto-detection of user languge by deepl.com
                uri = "https://www.deepl.com/translator#en/${selection}";
            }
            LaunchBrowserWithSelection(tab, uri);
        } break;

        case CmdSearchSelectionWithGoogle:
            LaunchBrowserWithSelection(tab, "https://www.google.com/search?q=${selection}");
            break;

        case CmdSearchSelectionWithBing:
            LaunchBrowserWithSelection(tab, "https://www.bing.com/search?q=${selection}");
            break;

        case CmdSearchSelectionWithWikipedia:
            LaunchBrowserWithSelection(tab, "https://wikipedia.org/w/index.php?search=${selection}");
            break;

        case CmdSearchSelectionWithGoogleScholar:
            LaunchBrowserWithSelection(tab, "https://scholar.google.com/scholar?q=${selection}");
            break;

        case CmdSearchSelectionWithYoudaoDict:
            LaunchBrowserWithSelection(tab, "https://dict.youdao.com/result?word=${selection}&lang=en");
            break;

        case CmdAnalyzeSelectionWithDoubao:
            AnalyzeSelectionWithDoubao(tab);
            break;

        case CmdLookupSelection:
            LookupSelectionInTab(win, tab);
            break;

        case CmdCopySelection:
            CopySelectionInTabToClipboard(tab);
            break;

        case CmdSelectAll:
            OnSelectAll(win);
            break;

        case CmdDebugToggleRtl:
            gForceRtl = !gForceRtl;
            for (auto w : gWindows) {
                UpdateWindowRtlLayout(w);
            }
            break;

        case CmdDebugDownloadSymbols:
            DownloadDebugSymbols();
            break;

        case CmdDebugTogglePredictiveRender:
            TogglePredictiveRender(win);
            break;

        case CmdToggleLinks:
            gGlobalPrefs->showLinks = !gGlobalPrefs->showLinks;
            for (auto& w : gWindows) {
                w->RedrawAll(true);
            }
            break;

        case CmdToggleShowAnnotations:
            if (tab) {
                tab->hideAnnotations = !tab->hideAnnotations;
                EngineBase* engine = tab->GetEngine();
                if (engine) {
                    engine->hideAnnotations = tab->hideAnnotations;
                }
                tab->pdfMarkupOverlays.Reset();
                MainWindowRerender(win);
                if (DisplayModel* fixed = win->AsFixed()) {
                    fixed->RenderVisibleParts();
                }
            }
            break;

        case CmdShowAnnotations:
            if (tab && tab->hideAnnotations) {
                tab->hideAnnotations = false;
                EngineBase* engine = tab->GetEngine();
                if (engine) {
                    engine->hideAnnotations = false;
                }
                tab->pdfMarkupOverlays.Reset();
                MainWindowRerender(win);
                if (DisplayModel* fixed = win->AsFixed()) {
                    fixed->RenderVisibleParts();
                }
            }
            break;

        case CmdHideAnnotations:
            if (tab && !tab->hideAnnotations) {
                tab->hideAnnotations = true;
                EngineBase* engine = tab->GetEngine();
                if (engine) {
                    engine->hideAnnotations = true;
                }
                tab->pdfMarkupOverlays.Reset();
                MainWindowRerender(win);
                if (DisplayModel* fixed = win->AsFixed()) {
                    fixed->RenderVisibleParts();
                }
            }
            break;

#if defined(DEBUG)
        case CmdDebugTestApp:
            extern void TestApp(HINSTANCE hInstance);
            extern void TestBrowser();
            // TestApp(GetModuleHandle(nullptr));
            TestBrowser();
            break;

        case CmdDebugStartStressTest: {
            if (!win) {
                return 0;
            }

            // TODO: ideally would ask user for the cmd-line args but this will do
            Flags f;
            // f.stressTestPath = str::Dup("C:\\Users\\kjk\\!sumatra\\all formats");
            f.stressTestPath = str::Dup("D:\\sumstress");
            f.stressRandomizeFiles = true;
            f.stressTestMax = 25;
            StartStressTest(&f, win);
        } break;

        case CmdDebugShowNotif: {
            {
                NotificationCreateArgs args;
                args.hwndParent = win->hwndCanvas;
                args.groupId = kNotifPersistentWarning;
                args.msg = "This is a second notification\nMy friend.";
                args.warning = false;
                args.timeoutMs = kNotifDefaultTimeOut;
                ShowNotification(args);
            }
            {
                NotificationCreateArgs args;
                args.hwndParent = win->hwndCanvas;
                args.groupId = kNotifAdHoc;
                args.msg = "This is a second notification\nMy friend.";
                args.warning = false;
                args.timeoutMs = 0;
                ShowNotification(args);
            }

            {
                NotificationCreateArgs args;
                args.hwndParent = win->hwndCanvas;
                args.msg = "This is a notification";
                args.groupId = kNotifAdHoc;
                args.warning = true;
                args.timeoutMs = 0;
                ShowNotification(args);
            }

            {
                NotificationCreateArgs args;
                args.hwndParent = win->hwndCanvas;
                args.groupId = kNotifAdHoc;
                args.warning = false;
                args.timeoutMs = 0;
                auto wnd = ShowNotification(args);
                UpdateNotificationProgress(wnd, "Progress", 50);
            }
        } break;

        case CmdDebugCrashMe:
            CrashMe();
            break;

        case CmdDebugCorruptMemory:
            DebugCorruptMemory();
            break;
#endif

        case CmdFavoriteAdd:
            AddFavoriteForCurrentPage(win);
            break;

        case CmdFavoriteDel:
            if (win->IsDocLoaded()) {
                auto path = ctrl->GetFilePath();
                DelFavorite(path, win->currPageNo);
            }
            break;

        case CmdFavoriteToggle:
            ToggleFavorites(win);
            break;

        case CmdTogglePageInfo: {
            // "page info" tip: make figuring out current page and
            // total pages count a one-key action (unless they're already visible)
            TogglePageInfoHelper(win);
        } break;

        case CmdInvertColors: {
            gGlobalPrefs->fixedPageUI.invertColors ^= true;
            UpdateDocumentColors();
            UpdateControlsColors(win);
            // UpdateUiForCurrentTab(win);
            SaveSettings();
            break;
        }

        case CmdTogglePreservePdfImages: {
            if (GetPdfDocumentColorMode() != PdfDocumentColorMode::Auto) {
                SetPdfDocumentColorMode(PdfDocumentColorMode::Auto);
            }
            SetPreservePdfImagesInDarkMode(!GetPreservePdfImagesInDarkMode());
            UpdateDocumentColors(true);
            for (MainWindow* w : gWindows) {
                UpdatePdfDocumentColorModeToolbarButton(w);
            }
            UpdateControlsColors(win);
            break;
        }

        case CmdToggleAntiAlias: {
            gGlobalPrefs->disableAntiAlias ^= true;
            for (auto* w : gWindows) {
                DisplayModel* fixedModel = w->AsFixed();
                if (fixedModel) {
                    fixedModel->GetEngine()->disableAntiAlias = gGlobalPrefs->disableAntiAlias;
                }
            }
            RerenderFixedPage();
            SaveSettings();
            break;
        }

        case CmdToggleEngineeringDrawingEnhance: {
            DisplayModel* fixedDm = win->AsFixed();
            if (fixedDm) {
                EngineMupdfToggleCadEnhance(fixedDm->GetEngine());
                RerenderFixedPage();
            }
            break;
        }

        case CmdToggleSmoothScroll: {
            gGlobalPrefs->smoothScroll = !gGlobalPrefs->smoothScroll;
            SaveSettings();
            break;
        }

            // removed: CmdToggleHideScrollbar (replaced by CmdChangeScrollbar)

        case CmdToggleScrollbarInSinglePage:
            gGlobalPrefs->scrollbarInSinglePage = !gGlobalPrefs->scrollbarInSinglePage;
            UpdateFixedPageScrollbarsVisibility();
            SaveSettings();
            break;

        case CmdToggleLazyLoading:
            gGlobalPrefs->lazyLoading = !gGlobalPrefs->lazyLoading;
            SaveSettings();
            break;

        case CmdToggleEscToExit:
            gGlobalPrefs->escToExit = !gGlobalPrefs->escToExit;
            SaveSettings();
            break;

        case CmdToggleReuseInstance:
            gGlobalPrefs->reuseInstance = !gGlobalPrefs->reuseInstance;
            SaveSettings();
            break;

        case CmdToggleChmUI: {
            gGlobalPrefs->chmUI.useFixedPageUI = !gGlobalPrefs->chmUI.useFixedPageUI;
            SaveSettings();
            if (IsChmTab(tab)) {
                ReloadDocument(win, false);
            }
            return 0;
        }

        case CmdToggleTips: {
            gGlobalPrefs->showTips = !gGlobalPrefs->showTips;
            SaveSettings();
            tab = win->CurrentTab();
            if (!tab || tab->IsAboutTab()) {
                win->RedrawAll(true);
            }
            break;
        }

        case CmdNavigateBack:
            if (ctrl) {
                ctrl->Navigate(-1);
            }
            break;

        case CmdNavigateForward:
            if (ctrl) {
                ctrl->Navigate(1);
            }
            break;

        case CmdToggleZoom:
            win->ToggleZoom();
            break;

        case CmdToggleCursorPosition:
            ToggleCursorPositionInDoc(win);
            break;

        case CmdPresentationBlackBackground:
            if (win->presentation) {
                win->ChangePresentationMode(PM_BLACK_SCREEN);
            }
            break;
        case CmdPresentationWhiteBackground:
            if (win->presentation) {
                win->ChangePresentationMode(PM_WHITE_SCREEN);
            }
            break;

        case CmdClose: {
            CloseCurrentTab(win, false /* quitIfLast */);
            break;
        }

        case CmdCloseCurrentDocument: {
            gDontSaveSettings = true;
            CloseCurrentTab(win, true /* quitIfLast */);
            gDontSaveSettings = false;
            SaveSettings();
            break;
        }

        case CmdEditAnnotations: {
            if (!tab) return 0;
            if (EbookAnnotationsSupported(tab) && dm) {
                Point pt = HwndGetCursorPos(win->hwndCanvas);
                if (lp != 0) {
                    pt.x = GET_X_LPARAM(lp);
                    pt.y = GET_Y_LPARAM(lp);
                }
                EbookAnnotation* annotation = EbookAnnotationsGetAt(tab, dm, pt);
                ShowEditEbookAnnotationsWindow(tab, annotation);
                return 0;
            }
            Annotation* annot = nullptr;
            Point pt = HwndGetCursorPos(win->hwndCanvas);
            if (lp != 0) {
                // when sending from Menu.cpp mouse position is encoded as LPARAM
                pt.x = GET_X_LPARAM(lp);
                pt.y = GET_Y_LPARAM(lp);
                // MapWindowPoints(win->hwndCanvas, HWND_DESKTOP, &pt, 1);
            }
            int pageNoUnderCursor = dm->GetPageNoByPoint(pt);
            if (pageNoUnderCursor > 0) {
                annot = dm->GetAnnotationAtPos(pt, nullptr);
            }
            ShowEditAnnotationsWindow(tab, annot);
            return 0;
        }

        case CmdDeleteAnnotation: {
            if (!tab) return 0;
            if (EbookAnnotationsSupported(tab) && dm) {
                Point pt = HwndGetCursorPos(win->hwndCanvas);
                if (EbookAnnotationsDeleteAt(tab, dm, pt)) {
                    UpdateEbookAnnotationsList(tab->editEbookAnnotsWindow);
                    MainWindowRerender(win);
                    return 0;
                }
            }
            Annotation* annot = tab->selectedAnnotation;
            if (!annot) annot = GetAnnotionUnderCursor(tab, nullptr);
            if (!annot) return 0;
            DeleteAnnotationAndUpdateUI(tab, annot);
            return 0;
        } break;

        case CmdCreateAnnotHighlight:
            [[fallthrough]];
        case CmdCreateAnnotSquiggly:
            [[fallthrough]];
        case CmdCreateAnnotStrikeOut:
            [[fallthrough]];
        case CmdCreateAnnotUnderline: {
            if (!win || !tab) {
                return 0;
            }
            AnnotCreateArgs args{annotType};
            SetAnnotCreateArgs(args, cmd);
            if (EbookAnnotationsSupported(tab)) {
                COLORREF color = GetEbookAnnotationColor(annotType, args, cmd);
                EbookAnnotation* annotation = EbookAnnotationsCreateFromSelection(tab, annotType, color);
                DeleteOldSelectionInfo(win, true);
                UpdateEbookAnnotationsList(tab->editEbookAnnotsWindow, annotation);
                MainWindowRerender(win);
                bool showEditor = cmd ? GetCommandBoolArg(cmd, kCmdArgOpenEdit, false) : IsShiftPressed();
                if (annotation && showEditor) {
                    ShowEditEbookAnnotationsWindow(tab, annotation, EditAnnotFocus::Edit);
                }
                return 0;
            }
            lastCreatedAnnot = MakeAnnotationsFromSelection(tab, &args);
            if (cmd) {
                // for custom commands must explicitly provide "openedit" argument
                openAnnotationEdit = GetCommandBoolArg(cmd, kCmdArgOpenEdit, false);
            } else {
                // for built-in shortcuts, Shift opens edit window
                openAnnotationEdit = IsShiftPressed();
            }
        } break;

            // Note: duplicated in OnWindowContextMenu because slightly different handling
        case CmdCreateAnnotText:
            [[fallthrough]];
        case CmdCreateAnnotFreeText:
            [[fallthrough]];
        case CmdCreateAnnotStamp:
            [[fallthrough]];
        case CmdCreateAnnotCaret:
            [[fallthrough]];
        case CmdCreateAnnotSquare:
            [[fallthrough]];
        case CmdCreateAnnotLine:
            [[fallthrough]];
        case CmdCreateAnnotCircle: {
            if (!win || !tab || !dm) {
                return 0;
            }
            Point pt = HwndGetCursorPos(win->hwndCanvas);
            if (lp != 0) {
                pt.x = GET_X_LPARAM(lp);
                pt.y = GET_Y_LPARAM(lp);
            }
            if (EbookAnnotationsSupported(tab)) {
                AnnotCreateArgs args{annotType};
                SetAnnotCreateArgs(args, cmd);
                COLORREF color = GetEbookAnnotationColor(annotType, args, cmd);
                EbookAnnotation* annotation = EbookAnnotationsCreateAt(tab, dm, pt, annotType, color);
                if (annotation) {
                    // Creating from a context menu must not inherit a stale
                    // canvas drag. A later left click either opens the editor
                    // or begins an ordinary press-and-drag move.
                    win->ebookAnnotationBeingDragged = nullptr;
                    win->ebookAnnotationDragPending = nullptr;
                    ClearMouseState(win);
                    if (annotType != AnnotationType::FreeText) {
                        tab->selectedEbookAnnotation = annotation;
                    }
                    UpdateEbookAnnotationsList(tab->editEbookAnnotsWindow, annotation);
                    MainWindowRerender(win);
                    if (annotType == AnnotationType::FreeText) {
                        ShowEditEbookAnnotationsWindow(tab, annotation, EditAnnotFocus::Edit);
                    }
                }
                return 0;
            }
            EngineBase* engine = dm->GetEngine();
            if (!engine) {
                return 0;
            }
            if (!EngineSupportsAnnotations(engine)) {
                return 0;
            }
            int pageNoUnderCursor = dm->GetPageNoByPoint(pt);
            if (pageNoUnderCursor < 0) {
                if (!cmd) return 0;
                // this is a case of custom command invoked by clicking toolbar button
                // in which case we don't know where to place the annotation
                // so we guess it as y = 20 px of hwndFrame and x being in the middle of window
                // it's a heuristic so might not be what user expects
                // TODO: ideally creating those annotations should be more visual
                // i.e. we start interactive process of creating an annotation via mouse
                auto r = WindowRect(win->hwndCanvas);
                pt.x = r.dx / 2;
                pt.y = 20;
                pageNoUnderCursor = dm->GetPageNoByPoint(pt);
                if (pageNoUnderCursor < 0) return 0;
            }
            PointF ptOnPage = dm->CvtFromScreen(pt, pageNoUnderCursor);
            MapWindowPoints(win->hwndCanvas, HWND_DESKTOP, &pt, 1);
            AnnotCreateArgs args{annotType};
            SetAnnotCreateArgs(args, cmd);
            lastCreatedAnnot = EngineMupdfCreateAnnotation(engine, pageNoUnderCursor, ptOnPage, &args);
            openAnnotationEdit = GetCommandBoolArg(cmd, kCmdArgOpenEdit, false);
        } break;

        case CmdSelectNextTheme:
            SelectNextTheme();
            break;

        case CmdToggleLightDarkTheme:
            ToggleLightDarkTheme();
            SaveSettings();
            break;

        case CmdSetPdfDocumentColorModeAuto:
        case CmdSetPdfDocumentColorModeBlack:
        case CmdSetPdfDocumentColorModeLight:
            if (NeedsDocumentColorModeUI(win)) {
                PdfDocumentColorMode mode = PdfDocumentColorMode::Auto;
                if (cmdId == CmdSetPdfDocumentColorModeBlack) {
                    mode = PdfDocumentColorMode::Black;
                } else if (cmdId == CmdSetPdfDocumentColorModeLight) {
                    mode = PdfDocumentColorMode::Light;
                }
                SetPdfDocumentColorMode(mode);
                UpdateDocumentColors(true);
                for (MainWindow* w : gWindows) {
                    UpdatePdfDocumentColorModeToolbarButton(w);
                }
                SaveSettings();
            }
            break;

        case CmdToggleDoubleClickWordLookup:
            gGlobalPrefs->enableDoubleClickWordLookup = !gGlobalPrefs->enableDoubleClickWordLookup;
            if (!gGlobalPrefs->enableDoubleClickWordLookup) {
                CloseWordLookup();
            }
            UpdateDoubleClickWordLookupToolbarButton(win);
            SaveSettings();
            break;

        case CmdToggleInverseSearch:
            // https://github.com/sumatrapdfreader/sumatrapdf/issues/5289
            // allow to temporarily disable invoking tex inverse search
            // with left mouse click
            extern bool gDisableInteractiveInverseSearch;
            gDisableInteractiveInverseSearch = !gDisableInteractiveInverseSearch;
            break;

        default:
            return DefWindowProc(hwnd, msg, wp, lp);
    }
    if (!lastCreatedAnnot) {
        return 0;
    }
    UpdateAnnotationsList(tab->editAnnotsWindow);

    EditAnnotFocus focusTarget = EditAnnotFocus::Default;
    if (GetCommandBoolArg(cmd, kCmdArgFocusEdit, false)) {
        focusTarget = EditAnnotFocus::Edit;
    } else if (GetCommandBoolArg(cmd, kCmdArgFocusList, false)) {
        focusTarget = EditAnnotFocus::List;
    }

    if (openAnnotationEdit) {
        ShowEditAnnotationsWindow(tab, lastCreatedAnnot, focusTarget);
        return 0;
    }

    // proper action for a given annotation type
    switch (lastCreatedAnnot->type) {
        case AnnotationType::Highlight:
        case AnnotationType::Squiggly:
        case AnnotationType::StrikeOut:
        case AnnotationType::Underline: {
            ToolbarUpdateStateForWindow(win, false);
            return 0;
        }
        case AnnotationType::FreeText: {
            // for FreeText you want to edit text so show edit window
            ShowEditAnnotationsWindow(tab, lastCreatedAnnot, focusTarget);
            return 0;
        } break;
    }

    // mark as selected so it can be moved / resized
    SetSelectedAnnotation(tab, lastCreatedAnnot);
    return 0;
}

// minimum size of the window
constexpr LONG kWinMinDx = 500;
constexpr LONG kWinMinDy = 320;

static LRESULT OnFrameGetMinMaxInfo(MINMAXINFO* info) {
    // limit windows min width to prevent render loop when siderbar is too big
    info->ptMinTrackSize.x = kWinMinDx - kSidebarMinDx + gGlobalPrefs->sidebarDx;
    info->ptMinTrackSize.y = kWinMinDy;
    return 0;
}

// --- Caption code (moved from Caption.cpp) ---

#define UNDOCUMENTED_MENU_CLASS_NAME L"#32768"
#define DO_NOT_REOPEN_MENU_TIMER_ID 1
#define DO_NOT_REOPEN_MENU_DELAY_IN_MS 1
#define CBS_INACTIVE 5
#define NON_CLIENT_BAND 1

static HMENU GetUpdatedSystemMenu(HWND hwnd, bool changeDefaultItem) {
    HMENU menu = GetSystemMenu(hwnd, FALSE);
    SetWindowStyle(hwnd, WS_VISIBLE, false);

    bool maximized = IsZoomed(hwnd);
    EnableMenuItem(menu, SC_SIZE, maximized ? MF_GRAYED : MF_ENABLED);
    EnableMenuItem(menu, SC_MOVE, maximized ? MF_GRAYED : MF_ENABLED);
    EnableMenuItem(menu, SC_MINIMIZE, MF_ENABLED);
    EnableMenuItem(menu, SC_MAXIMIZE, maximized ? MF_GRAYED : MF_ENABLED);
    EnableMenuItem(menu, SC_CLOSE, MF_ENABLED);
    EnableMenuItem(menu, SC_RESTORE, maximized ? MF_ENABLED : MF_GRAYED);
    if (changeDefaultItem) {
        SetMenuDefaultItem(menu, maximized ? SC_RESTORE : SC_MAXIMIZE, FALSE);
    } else {
        SetMenuDefaultItem(menu, SC_CLOSE, FALSE);
    }

    SetWindowStyle(hwnd, WS_VISIBLE, true);
    return menu;
}

static void TrackCaptionPopupMenu(MainWindow* win, HMENU menu, Rect btnRect);

void OpenSystemMenu(MainWindow* win) {
    Rect r = win->captionBtn[CB_SYSTEM_MENU].rect;
    HMENU systemMenu = GetUpdatedSystemMenu(win->hwndFrame, false);
    TrackCaptionPopupMenu(win, systemMenu, r);
}

static int CaptionButtonAt(MainWindow* win, Point pt) {
    UnmirrorRtl(win->hwndFrame, pt);
    for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
        if (win->captionBtn[i].visible && win->captionBtn[i].rect.Contains(pt)) {
            return i;
        }
    }
    return -1;
}

constexpr int kCaptionButtonHoverAccent = 8;
constexpr int kCaptionButtonPushedAccent = 12;

static bool CaptionButtonsActive(MainWindow* win) {
    return !win->captionBtn[CB_MINIMIZE].inactive;
}

static COLORREF CaptionSysButtonHotBg(COLORREF chromeBg) {
    return AccentColor(chromeBg, kCaptionButtonHoverAccent);
}

static COLORREF CaptionSysButtonPushedBg(COLORREF chromeBg) {
    return AccentColor(chromeBg, kCaptionButtonPushedAccent);
}

static COLORREF CaptionCloseHotBg() {
    return RGB(0xC4, 0x2B, 0x1C);
}

static COLORREF CaptionClosePushedBg() {
    return RGB(0xA0, 0x22, 0x14);
}

static Color CaptionInactiveIconColor(COLORREF chromeBg, COLORREF textCol) {
    auto mix = [](int fg, int bg) { return (fg * 42 + bg * 58) / 100; };
    return Color(mix(GetRValue(textCol), GetRValue(chromeBg)), mix(GetGValue(textCol), GetGValue(chromeBg)),
                 mix(GetBValue(textCol), GetBValue(chromeBg)));
}

static Color CaptionSysButtonIconColor(COLORREF chromeBg, COLORREF textCol) {
    // Win11 caption glyphs are softer than chrome text.
    auto blend = [](int fg, int bg, int fgPct) { return (fg * fgPct + bg * (100 - fgPct)) / 100; };
    if (ThemeUsesDarkChrome()) {
        constexpr int kFgPct = 88;
        return Color(blend(GetRValue(textCol), GetRValue(chromeBg), kFgPct),
                     blend(GetGValue(textCol), GetGValue(chromeBg), kFgPct),
                     blend(GetBValue(textCol), GetBValue(chromeBg), kFgPct));
    }
    // Light chrome: native buttons are ~54% grey ink, not title-text black.
    constexpr int kBlackPct = 54;
    int r = GetRValue(chromeBg) * (100 - kBlackPct) / 100;
    int g = GetGValue(chromeBg) * (100 - kBlackPct) / 100;
    int b = GetBValue(chromeBg) * (100 - kBlackPct) / 100;
    return Color(r, g, b);
}

static void RepaintButton(HWND hwnd, int btnIdx, MainWindow* win) {
    if (win->captionBtn[btnIdx].visible) {
        RECT rc = ToRECT(win->captionBtn[btnIdx].rect);
        InvalidateRect(hwnd, &rc, FALSE);
    } else {
        InvalidateRect(hwnd, nullptr, FALSE);
    }
}

static void ClearAllHighlights(MainWindow* win) {
    for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
        if (win->captionBtn[i].highlighted || win->captionBtn[i].pressed) {
            win->captionBtn[i].highlighted = false;
            win->captionBtn[i].pressed = false;
            RepaintButton(win->hwndFrame, i, win);
        }
    }
}

static void TrackCaptionPopupMenu(MainWindow* win, HMENU menu, Rect btnRect) {
    SetForegroundWindow(win->hwndFrame);

    Rect anchor = MapLtrClientRectToScreen(win->hwndFrame, btnRect);
    uint flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_VERTICAL;
    int x = anchor.x;
    int y = anchor.y + anchor.dy;
    if (IsUIRtl()) {
        x = anchor.x + anchor.dx;
        flags = TPM_RIGHTALIGN | TPM_TOPALIGN | TPM_VERTICAL | TPM_LAYOUTRTL;
    }

    // rcExclude must cover the activating control so the opening click doesn't
    // dismiss the popup immediately (especially on mixed-DPI / PerMonitorV2).
    Rect exclude = anchor;
    exclude.Inflate(DpiScale(win->hwndFrame, 4), DpiScale(win->hwndFrame, 4));

    TPMPARAMS tpm{};
    tpm.cbSize = sizeof(TPMPARAMS);
    tpm.rcExclude = ToRECT(exclude);

    TrackPopupMenuEx(menu, flags, x, y, win->hwndFrame, &tpm);

    // Win32 menu tracking requires this so the next menu can receive input.
    PostMessageW(win->hwndFrame, WM_NULL, 0, 0);
}

static void MenuBarAsPopupMenu(MainWindow* win, Rect btnRect) {
    if (!win->menu || GetMenuItemCount(win->menu) <= 0) {
        RebuildMenuBarForWindow(win);
    }
    HMENU popup = BuildMenubarPopupMenu(win);
    int count = GetMenuItemCount(popup);
    if (count <= 0) {
        DestroyMenu(popup);
        return;
    }

    MarkMenuOwnerDraw(popup, false, true);
    TrackCaptionPopupMenu(win, popup, btnRect);

    while (count > 0) {
        --count;
        RemoveMenu(popup, count, MF_BYPOSITION);
    }
    FreeMenuOwnerDrawInfoData(popup, false);
    DestroyMenu(popup);
}

static void HandleCaptionClick(MainWindow* win, int btnIdx) {
    switch (btnIdx) {
        case CB_MINIMIZE:
            PostMessageW(win->hwndFrame, WM_SYSCOMMAND, SC_MINIMIZE, 0);
            break;
        case CB_MAXIMIZE:
            PostMessageW(win->hwndFrame, WM_SYSCOMMAND, SC_MAXIMIZE, 0);
            break;
        case CB_RESTORE:
            PostMessageW(win->hwndFrame, WM_SYSCOMMAND, SC_RESTORE, 0);
            break;
        case CB_CLOSE:
            PostMessageW(win->hwndFrame, WM_SYSCOMMAND, SC_CLOSE, 0);
            break;
        case CB_MENU: {
            BOOL hadTimer = KillTimer(win->hwndFrame, DO_NOT_REOPEN_MENU_TIMER_ID);
            if (!hadTimer && !win->isMenuOpen) {
                Rect r = win->captionBtn[CB_MENU].rect;
                win->isMenuOpen = true;
                RepaintButton(win->hwndFrame, CB_MENU, win);
                MenuBarAsPopupMenu(win, r);
                win->isMenuOpen = false;
                RepaintButton(win->hwndFrame, CB_MENU, win);
                SetTimer(win->hwndFrame, DO_NOT_REOPEN_MENU_TIMER_ID, DO_NOT_REOPEN_MENU_DELAY_IN_MS, nullptr);
            }
            HwndSetFocus(win->hwndFrame);
            break;
        }
        case CB_SYSTEM_MENU:
            OpenSystemMenu(win);
            break;
    }
}

constexpr int kCaptionButtonWidthDip = 46;
constexpr int kTabsButtonGapDip = 32;

static int CaptionSysButtonWidth(HWND hwnd) {
    return DpiScale(hwnd, kCaptionButtonWidthDip);
}

static void LayoutCaptionSysButtons(MainWindow* win, Rect& rc, int btnY, int btnDy, bool maximized) {
    int btnDx = CaptionSysButtonWidth(win->hwndFrame);

    int x = rc.x + rc.dx - btnDx;
    win->captionBtn[CB_CLOSE].rect = {x, btnY, btnDx, btnDy};
    win->captionBtn[CB_CLOSE].visible = true;
    rc.dx -= btnDx;

    x = rc.x + rc.dx - btnDx;
    win->captionBtn[CB_RESTORE].rect = {x, btnY, btnDx, btnDy};
    win->captionBtn[CB_RESTORE].visible = maximized;

    win->captionBtn[CB_MAXIMIZE].rect = {x, btnY, btnDx, btnDy};
    win->captionBtn[CB_MAXIMIZE].visible = !maximized;
    rc.dx -= btnDx;

    x = rc.x + rc.dx - btnDx;
    win->captionBtn[CB_MINIMIZE].rect = {x, btnY, btnDx, btnDy};
    win->captionBtn[CB_MINIMIZE].visible = true;
    rc.dx -= btnDx;
}

void RelayoutCaption(MainWindow* win) {
    for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
        win->captionBtn[i].id = i;
    }
    Rect rc = win->captionRect;
    bool maximized = IsZoomed(win->hwndFrame);
    bool showingMenuBar = IsMenubarVisible() && IsShowingMenuBarRebar(win);
    int tabHeight = GetTabbarHeight(win->hwndFrame);

    if (showingMenuBar) {
        // Two-row layout:
        //   Row 1 (top): CB_SYSTEM_MENU, menu bar rebar, [drag area], min/max/close
        //   Row 2: tabs, [drag area]
        // Menu bar goes all the way to the top for compactness.

        // Get menu bar natural height; RB_GETBARHEIGHT underreports by 1px without WS_BORDER
        int menuBarDy = (int)SendMessageW(win->hwndMenuReBar, RB_GETBARHEIGHT, 0, 0) + 1;

        int row1Y = rc.y;
        int row2Y = rc.y + menuBarDy;

        int btnDy = menuBarDy;
        LayoutCaptionSysButtons(win, rc, row1Y, btnDy, maximized);

        // Row 1 left: system menu (square, matches menu bar row height)
        win->captionBtn[CB_SYSTEM_MENU].rect = {rc.x, row1Y, menuBarDy, menuBarDy};
        win->captionBtn[CB_SYSTEM_MENU].visible = true;
        int row1X = rc.x + menuBarDy;
        int row1Dx = rc.dx - menuBarDy;

        // CB_MENU hidden when menu bar rebar is showing
        win->captionBtn[CB_MENU].rect = {row1X, row1Y, menuBarDy, menuBarDy};
        win->captionBtn[CB_MENU].visible = false;

        // Menu bar rebar in row 1 after system menu, natural width
        int menuBarWidth = row1Dx; // default: fill available
        if (win->hwndMenuToolbar) {
            int btnCount = (int)SendMessageW(win->hwndMenuToolbar, TB_BUTTONCOUNT, 0, 0);
            if (btnCount > 0) {
                RECT lastBtn;
                SendMessageW(win->hwndMenuToolbar, TB_GETITEMRECT, btnCount - 1, (LPARAM)&lastBtn);
                int naturalWidth = lastBtn.right + GetSystemMetrics(SM_CXBORDER) * 2;
                if (naturalWidth < row1Dx) {
                    menuBarWidth = naturalWidth;
                }
            }
        }

        // check if there are actual file tabs to show
        bool hasFileTabs = false;
        for (WindowTab* tab : win->Tabs()) {
            if (!tab->IsAboutTab()) {
                hasFileTabs = true;
                break;
            }
        }

        DeferWinPosHelper dh;
        dh.SetWindowPos(win->hwndMenuReBar, nullptr, row1X, row1Y, menuBarWidth, menuBarDy, SWP_NOZORDER);

        if (hasFileTabs) {
            // Row 2: tabs
            win->tabsCtrl->SetIsVisible(true);
            dh.SetWindowPos(win->tabsCtrl->hwnd, nullptr, rc.x, row2Y, rc.dx, tabHeight, SWP_NOZORDER);
        } else {
            // no file tabs: hide tab bar, single-row caption
            win->tabsCtrl->SetIsVisible(false);
        }
        dh.End();
    } else {
        // Single-row layout (original)
        int btnDy = rc.y + rc.dy;
        LayoutCaptionSysButtons(win, rc, 0, btnDy, maximized);

        // tabs fill the full caption height (rc.dy)
        int tabDy = rc.dy;
        rc.y += rc.dy - tabDy;

        win->captionBtn[CB_SYSTEM_MENU].rect = {rc.x, rc.y, tabDy, tabDy};
        win->captionBtn[CB_SYSTEM_MENU].visible = true;
        rc.x += tabDy;
        rc.dx -= tabDy;

        win->captionBtn[CB_MENU].rect = {rc.x, rc.y, tabDy, tabDy};
        win->captionBtn[CB_MENU].visible = true;
        rc.x += tabDy;
        rc.dx -= tabDy;

        // leave a gap between the tab bar and the minimize button
        rc.dx -= DpiScale(win->hwndFrame, kTabsButtonGapDip);

        DeferWinPosHelper dh;
        dh.SetWindowPos(win->tabsCtrl->hwnd, nullptr, rc.x, rc.y, rc.dx, tabDy, SWP_NOZORDER);
        dh.End();
    }

    UpdateTabWidth(win);

    for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
        if (win->captionBtn[i].visible) {
            RECT r = ToRECT(win->captionBtn[i].rect);
            InvalidateRect(win->hwndFrame, &r, FALSE);
        }
    }
}

static void DrawCaptionMenuSeparator(MainWindow* win, HDC hdc) {
    ButtonInfo& sysBtn = win->captionBtn[CB_SYSTEM_MENU];
    ButtonInfo& menuBtn = win->captionBtn[CB_MENU];
    if (!sysBtn.visible || !menuBtn.visible) {
        return;
    }
    COLORREF sepCol = AccentColor(ThemeChromeBackgroundColor(), 40);
    int x = sysBtn.rect.x + sysBtn.rect.dx;
    int pad = sysBtn.rect.dy / 4;
    int y1 = sysBtn.rect.y + pad;
    int y2 = sysBtn.rect.y + sysBtn.rect.dy - pad;
    HPEN pen = CreatePen(PS_SOLID, 1, sepCol);
    HGDIOBJ oldPen = SelectObject(hdc, pen);
    MoveToEx(hdc, x, y1, nullptr);
    LineTo(hdc, x, y2);
    SelectObject(hdc, oldPen);
    DeleteObject(pen);
}

constexpr int kCaptionChromeGlyphDip = 10;

static CaptionSysButtonKind CaptionSysButtonKindFromId(int button) {
    switch (button) {
        case CB_MINIMIZE:
            return CaptionSysButtonKind::Minimize;
        case CB_MAXIMIZE:
            return CaptionSysButtonKind::Maximize;
        case CB_RESTORE:
            return CaptionSysButtonKind::Restore;
        case CB_CLOSE:
            return CaptionSysButtonKind::Close;
    }
    return CaptionSysButtonKind::Close;
}

static void DrawCaptionButton(MainWindow* win, HDC hdc, ButtonInfo* bi) {
    int button = bi->id;
    if (!bi->visible) {
        return;
    }
    Rect rButton = bi->rect;
    Rect rc = rButton;

    bool isSysButton = (button == CB_MINIMIZE || button == CB_MAXIMIZE || button == CB_RESTORE || button == CB_CLOSE);

    int stateId;
    if (bi->pressed) {
        stateId = CBS_PUSHED;
    } else if (bi->highlighted) {
        stateId = CBS_HOT;
    } else if (bi->inactive) {
        stateId = CBS_INACTIVE;
    } else {
        stateId = CBS_NORMAL;
    }

    Graphics gfx(hdc);
    gfx.SetSmoothingMode(Gdiplus::SmoothingModeNone);

    if (isSysButton) {
        COLORREF bgc = ThemeChromeBackgroundColor();
        bool isClose = (button == CB_CLOSE);
        bool isHot = (stateId == CBS_HOT);
        bool isPushed = (stateId == CBS_PUSHED);
        bool isInactive = (stateId == CBS_INACTIVE);

        Color iconCol;
        if (isInactive) {
            iconCol = CaptionInactiveIconColor(bgc, ThemeWindowTextColor());
        } else if (isClose && (isHot || isPushed)) {
            iconCol = Color(255, 255, 255);
        } else {
            iconCol = CaptionSysButtonIconColor(bgc, ThemeWindowTextColor());
        }
        COLORREF iconColRef = RGB(iconCol.GetR(), iconCol.GetG(), iconCol.GetB());

        {
            Graphics gfxBg(hdc);
            gfxBg.SetSmoothingMode(Gdiplus::SmoothingModeNone);
            SolidBrush bgBrNormal(GdiRgbFromCOLORREF(bgc));
            gfxBg.FillRectangle(&bgBrNormal, rButton.x, rButton.y, rButton.dx, rButton.dy);

            if (!isInactive && (isHot || isPushed)) {
                COLORREF hotBgCol;
                if (isClose) {
                    hotBgCol = isPushed ? CaptionClosePushedBg() : CaptionCloseHotBg();
                } else {
                    hotBgCol = isPushed ? CaptionSysButtonPushedBg(bgc) : CaptionSysButtonHotBg(bgc);
                }
                SolidBrush bgBr(GdiRgbFromCOLORREF(hotBgCol));
                gfxBg.FillRectangle(&bgBr, rButton.x, rButton.y, rButton.dx, rButton.dy);
            }
        }

        int iconPx = DpiScale(win->hwndFrame, kCaptionChromeGlyphDip);
        DrawCaptionSysButtonGlyph(hdc, CaptionSysButtonKindFromId(button), rc, iconColRef, iconPx);
    } else if (button == CB_MENU) {
        COLORREF bgc = ThemeChromeBackgroundColor();
        SolidBrush bgBrMenu(GdiRgbFromCOLORREF(bgc));
        gfx.FillRectangle(&bgBrMenu, rButton.x, rButton.y, rButton.dx, rButton.dy);

        if (win->isMenuOpen) {
            stateId = CBS_PUSHED;
        }
        bool isHot = (stateId == CBS_HOT) || win->isMenuOpen;
        bool isPushed = (stateId == CBS_PUSHED) || win->isMenuOpen;
        bool isInactive = (stateId == CBS_INACTIVE);
        if (!isInactive && (isHot || isPushed)) {
            COLORREF hotBg = isPushed ? CaptionSysButtonPushedBg(bgc) : CaptionSysButtonHotBg(bgc);
            SolidBrush hotBr(GdiRgbFromCOLORREF(hotBg));
            gfx.FillRectangle(&hotBr, rButton.x, rButton.y, rButton.dx, rButton.dy);
        }

        // Tabler menu-2 proportions (24x24 viewBox), 1px stroke to match toolbar icons
        COLORREF tc = ThemeWindowTextColor();
        Color menuIconCol;
        if (isInactive) {
            menuIconCol = CaptionInactiveIconColor(bgc, tc);
        } else {
            menuIconCol = Color(GetRValue(tc), GetGValue(tc), GetBValue(tc));
        }
        Pen p(menuIconCol, 1.0f);
        p.SetLineCap(Gdiplus::LineCapRound, Gdiplus::LineCapRound, Gdiplus::DashCapRound);
        float iconScale = (float)rc.dy * 0.55f / 24.0f;
        float cx = (float)rc.x + (float)rc.dx / 2.0f;
        float cy = (float)rc.y + (float)rc.dy / 2.0f;
        float x1 = cx - 8.0f * iconScale;
        float x2 = cx + 8.0f * iconScale;
        float y0 = cy - 6.0f * iconScale;
        float gap = 6.0f * iconScale;
        gfx.DrawLine(&p, x1, y0, x2, y0);
        gfx.DrawLine(&p, x1, y0 + gap, x2, y0 + gap);
        gfx.DrawLine(&p, x1, y0 + 2.0f * gap, x2, y0 + 2.0f * gap);
    } else if (button == CB_SYSTEM_MENU) {
        SolidBrush bgBrSys(GdiRgbFromCOLORREF(ThemeChromeBackgroundColor()));
        gfx.FillRectangle(&bgBrSys, rButton.x, rButton.y, rButton.dx, rButton.dy);
        int xIcon = GetSystemMetrics(SM_CXSMICON);
        int yIcon = GetSystemMetrics(SM_CYSMICON);
        HICON hIcon = (HICON)GetClassLongPtr(win->hwndFrame, GCLP_HICONSM);
        int x = rButton.x + (rButton.dx - xIcon) / 2;
        int y = rButton.y + (rButton.dy - yIcon) / 2;
        DrawIconEx(hdc, x, y, hIcon, xIcon, yIcon, 0, nullptr, DI_NORMAL);
    }
}

static WCHAR gMenuAccelPressed = 0;

static bool SetMaximizedClientRectToWorkArea(RECT* r) {
    HMONITOR monitor = MonitorFromRect(r, MONITOR_DEFAULTTONEAREST);
    MONITORINFO mi{};
    mi.cbSize = sizeof(mi);
    if (!monitor || !GetMonitorInfoW(monitor, &mi)) {
        return false;
    }

    *r = mi.rcWork;
    return true;
}

// Win11 corner radius is ~8 DIP; leave these client pixels unpainted so DWM
// rounded corners show through (custom caption fills chrome elsewhere).
static void ExcludeClientBottomCornerClip(HDC hdc, HWND hwnd) {
    if (IsZoomed(hwnd)) {
        return;
    }
    RECT cr;
    GetClientRect(hwnd, &cr);
    int r = DpiScale(hwnd, 8);
    if (r < 1) {
        r = 1;
    }
    ExcludeClipRect(hdc, cr.left, cr.bottom - 1, cr.right, cr.bottom);
    ExcludeClipRect(hdc, cr.left, cr.bottom - r, cr.left + r, cr.bottom);
    ExcludeClipRect(hdc, cr.right - r, cr.bottom - r, cr.right, cr.bottom);
}

static LRESULT CustomCaptionFrameProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp, bool* callDef, MainWindow* win) {
    switch (msg) {
        case WM_SETTINGCHANGE:
            if (wp == SPI_SETNONCLIENTMETRICS) {
                RelayoutCaption(win);
            }
            break;

        case WM_NCPAINT: {
            // paint the 1px NC strips at top/bottom with the correct color
            HDC hdc = GetWindowDC(hwnd);
            if (hdc) {
                Rect wr = WindowRect(hwnd);
                // window DC is in window coordinates (origin at top-left of window)
                HBRUSH br = CreateSolidBrush(ThemeChromeBackgroundColor());
                RECT rcTop = {0, 0, wr.dx, 1};
                FillRect(hdc, &rcTop, br);
                if (!IsZoomed(hwnd)) {
                    RECT rcBottom = {0, wr.dy - 1, wr.dx, wr.dy};
                    FillRect(hdc, &rcBottom, br);
                }
                DeleteObject(br);
                ReleaseDC(hwnd, hdc);
            }
            *callDef = false;
            return 0;
        }

        case WM_PAINT: {
            if (win->isFullScreen || win->presentation) {
                break; // no custom caption painting in fullscreen
            }
            PAINTSTRUCT ps;
            HDC hdc = BeginPaint(hwnd, &ps);
            LogRedraw("WM_PAINT", hwnd, &ps.rcPaint);

            Rect cr = win->captionRect;
            // captionArea spans from (0,0) to include the border on top/left
            Rect captionArea = {0, 0, cr.x + cr.dx, cr.y + cr.dy};
            DoubleBuffer buffer(hwnd, captionArea);
            HDC memDC = buffer.GetDC();
            {
                HBRUSH brCap = CreateSolidBrush(ThemeChromeBackgroundColor());
                RECT rcFill = ToRECT(captionArea);
                FillRect(memDC, &rcFill, brCap);
                DeleteObject(brCap);
            }
            for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
                DrawCaptionButton(win, memDC, &win->captionBtn[i]);
            }
            DrawCaptionMenuSeparator(win, memDC);
            buffer.Flush(hdc);

            // paint the 3px border outside the caption area
            // (WS_CLIPCHILDREN prevents painting over child windows)
            {
                RECT rcCaption = ToRECT(captionArea);
                ExcludeClipRect(hdc, rcCaption.left, rcCaption.top, rcCaption.right, rcCaption.bottom);
                ExcludeClientBottomCornerClip(hdc, hwnd);
                HBRUSH brBorder = CreateSolidBrush(ThemeChromeBackgroundColor());
                FillRect(hdc, &ps.rcPaint, brBorder);
                DeleteObject(brBorder);
            }

            EndPaint(hwnd, &ps);
            *callDef = false;
            return 0;
        }

        case WM_NCACTIVATE:
            for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
                win->captionBtn[i].inactive = wp == FALSE;
                if (wp == FALSE) {
                    win->captionBtn[i].highlighted = false;
                    win->captionBtn[i].pressed = false;
                }
            }
            if (!IsIconic(hwnd)) {
                RECT rc = ToRECT(win->captionRect);
                if (!ThemeUsesDarkChrome()) {
                    rc.bottom += NON_CLIENT_BAND;
                }
                uint flags = RDW_ERASE | RDW_INVALIDATE | RDW_UPDATENOW;
                RedrawWindow(hwnd, &rc, nullptr, flags);
                *callDef = false;
                return TRUE;
            }
            break;

        case WM_TIMER:
            if (wp == DO_NOT_REOPEN_MENU_TIMER_ID) {
                KillTimer(hwnd, DO_NOT_REOPEN_MENU_TIMER_ID);
                *callDef = false;
                return 0;
            }
            if (wp == kFindStatusAnimateTimerId) {
                FindStatusAnimateTimerFired(win);
                *callDef = false;
                return 0;
            }
            if (wp == kFindStatusCompleteFlashTimerId) {
                FindStatusCompleteFlashTimerFired(win);
                *callDef = false;
                return 0;
            }
            if (wp == kHomeSearchDebounceTimerId) {
                KillTimer(hwnd, kHomeSearchDebounceTimerId);
                if (win->IsCurrentTabAbout()) {
                    HomePageApplySearchFilter(win);
                }
                *callDef = false;
                return 0;
            }
            break;

        case WM_THEMECHANGED:
            break;

        case WM_NCCALCSIZE: {
            RECT* r = wp == TRUE ? &((NCCALCSIZE_PARAMS*)lp)->rgrc[0] : (RECT*)lp;
            bool isFullScreen = win->isFullScreen || win->presentation;
            if (IsZoomed(hwnd) && !isFullScreen) {
                // The maximized outer frame extends beyond the work area. Global
                // system metrics can still describe the old monitor during a
                // cross-DPI move, leaving an unpainted row at the screen edge.
                // The proposed rect identifies the destination monitor reliably.
                if (!SetMaximizedClientRectToWorkArea(r)) {
                    int frameX = GetSystemMetrics(SM_CXFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER);
                    int frameY = GetSystemMetrics(SM_CYFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER);
                    r->left += frameX;
                    r->top += frameY;
                    r->right -= frameX;
                    r->bottom -= frameY;
                }
            } else if (!isFullScreen) {
                // keep 1px non-client area at top so DWM preserves content
                // during resize (returning 0 makes DWM clear the surface)
                r->top += 1;
                // bottom NC band lets DWM draw rounded corners above the taskbar edge
                r->bottom -= NON_CLIENT_BAND;
            }
            *callDef = false;
            return 0;
        }

        case WM_NCHITTEST: {
            int x = GET_X_LPARAM(lp);
            int y = GET_Y_LPARAM(lp);
            RECT wrc;
            GetWindowRect(hwnd, &wrc);

            // use a larger hit-test area than the visible border for easier resizing
            if (!IsZoomed(hwnd) && !win->isFullScreen && !win->presentation) {
                int b = kFrameResizeHitTest;
                bool onLeft = (x - wrc.left) < b;
                bool onRight = (wrc.right - x) <= b;
                bool onTop = (y - wrc.top) < b;
                bool onBottom = (wrc.bottom - y) <= b;

                if (onTop && onLeft) {
                    *callDef = false;
                    return HTTOPLEFT;
                }
                if (onTop && onRight) {
                    *callDef = false;
                    return HTTOPRIGHT;
                }
                if (onBottom && onLeft) {
                    *callDef = false;
                    return HTBOTTOMLEFT;
                }
                if (onBottom && onRight) {
                    *callDef = false;
                    return HTBOTTOMRIGHT;
                }
                if (onLeft) {
                    *callDef = false;
                    return HTLEFT;
                }
                if (onRight) {
                    *callDef = false;
                    return HTRIGHT;
                }
                if (onTop) {
                    *callDef = false;
                    return HTTOP;
                }
                if (onBottom) {
                    *callDef = false;
                    return HTBOTTOM;
                }
            }

            {
                Point ptClient{x, y};
                HwndScreenToClient(hwnd, ptClient);
                int btnIdx = CaptionButtonAt(win, ptClient);
                if (btnIdx >= 0) {
                    if (btnIdx == CB_MAXIMIZE || btnIdx == CB_RESTORE) {
                        *callDef = false;
                        return HTMAXBUTTON;
                    }
                    *callDef = false;
                    return HTCLIENT;
                }
            }

            {
                Point pt{x, y};
                Rect rClient = MapRectToWindow(ClientRect(hwnd), hwnd, HWND_DESKTOP);
                Rect rCaption = MapRectToWindow(win->captionRect, hwnd, HWND_DESKTOP);
                if (rClient.Contains(pt) && pt.y < rCaption.y + rCaption.dy) {
                    *callDef = false;
                    return HTCAPTION;
                }
            }
        } break;

        case WM_NCLBUTTONDOWN:
            if (wp == HTMAXBUTTON) {
                *callDef = false;
                return 0;
            }
            break;

        case WM_NCLBUTTONUP:
            if (wp == HTMAXBUTTON) {
                WPARAM cmd = IsZoomed(hwnd) ? SC_RESTORE : SC_MAXIMIZE;
                PostMessageW(hwnd, WM_SYSCOMMAND, cmd, 0);
                *callDef = false;
                return 0;
            }
            break;

        case WM_NCMOUSEMOVE: {
            int btnIdx = IsZoomed(hwnd) ? CB_RESTORE : CB_MAXIMIZE;
            if (wp == HTMAXBUTTON && CaptionButtonsActive(win)) {
                if (!win->captionBtn[btnIdx].highlighted) {
                    win->captionBtn[btnIdx].highlighted = true;
                    RepaintButton(hwnd, btnIdx, win);
                }
            } else {
                if (win->captionBtn[btnIdx].highlighted) {
                    win->captionBtn[btnIdx].highlighted = false;
                    RepaintButton(hwnd, btnIdx, win);
                }
            }
        } break;

        case WM_NCMOUSELEAVE:
            ClearAllHighlights(win);
            break;

        case WM_MOUSEMOVE: {
            Point ptm{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)};
            int btnIdx = CaptionButtonAt(win, ptm);
            bool allowHover = CaptionButtonsActive(win);
            for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
                bool shouldHighlight = allowHover && (i == btnIdx);
                if (win->captionBtn[i].highlighted != shouldHighlight) {
                    win->captionBtn[i].highlighted = shouldHighlight;
                    RepaintButton(hwnd, i, win);
                }
            }
            if (btnIdx >= 0 && allowHover) {
                TrackMouseLeave(hwnd);
            }
        } break;

        case WM_MOUSELEAVE:
            ClearAllHighlights(win);
            break;

        case WM_LBUTTONDOWN: {
            Point ptd{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)};
            int btnIdx = CaptionButtonAt(win, ptd);
            if (btnIdx >= 0 && CaptionButtonsActive(win)) {
                win->captionBtn[btnIdx].pressed = true;
                RepaintButton(hwnd, btnIdx, win);
                SetCapture(hwnd);
                *callDef = false;
                return 0;
            }
        } break;

        case WM_LBUTTONUP: {
            if (GetCapture() == hwnd) {
                ReleaseCapture();
            }
            Point ptu{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)};
            int btnIdx = CaptionButtonAt(win, ptu);
            for (int i = CB_BTN_FIRST; i < CB_BTN_COUNT; i++) {
                if (win->captionBtn[i].pressed) {
                    win->captionBtn[i].pressed = false;
                    RepaintButton(hwnd, i, win);
                    if (i == btnIdx) {
                        HandleCaptionClick(win, i);
                    }
                }
            }
            *callDef = false;
            return 0;
        }

        case WM_LBUTTONDBLCLK: {
            Point ptdc{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)};
            int btnIdx = CaptionButtonAt(win, ptdc);
            if (btnIdx == CB_SYSTEM_MENU) {
                PostMessageW(hwnd, WM_SYSCOMMAND, SC_CLOSE, 0);
                *callDef = false;
                return 0;
            }
        } break;

        case WM_NCRBUTTONUP:
            if (wp == HTCAPTION) {
                HMENU menu = GetUpdatedSystemMenu(hwnd, true);
                uint flags = TPM_RIGHTBUTTON | TPM_NONOTIFY | TPM_RETURNCMD;
                if (GetSystemMetrics(SM_MENUDROPALIGNMENT)) {
                    flags |= TPM_RIGHTALIGN;
                }
                WPARAM cmd = TrackPopupMenu(menu, flags, GET_X_LPARAM(lp), GET_Y_LPARAM(lp), 0, hwnd, nullptr);
                if (cmd) {
                    PostMessageW(hwnd, WM_SYSCOMMAND, cmd, 0);
                }
                *callDef = false;
                return 0;
            }
            break;

        case WM_SYSCOMMAND:
            if (wp == SC_KEYMENU) {
                if (IsShowingMenuBarRebar(win)) {
                    // activate the rebar menu bar directly
                    ActivateMenuBarByAccel(win, (WCHAR)lp);
                    *callDef = false;
                    return 0;
                }
                gMenuAccelPressed = (WCHAR)lp;
                if (' ' == gMenuAccelPressed) {
                    auto pos = str::FindChar(_TRA("&Window"), '&');
                    if (pos) {
                        char c = pos[1];
                        gMenuAccelPressed = (WCHAR)c;
                    }
                }
                HandleCaptionClick(win, CB_MENU);
                *callDef = false;
                return 0;
            }
            break;

        case WM_INITMENUPOPUP:
            // apply dark mode to popup menu window
            if (UseDarkModeLib() && DarkMode::isEnabled()) {
                HWND hMenuWnd = FindWindow(UNDOCUMENTED_MENU_CLASS_NAME, nullptr);
                if (hMenuWnd) {
                    DarkMode::setDarkTitleBarEx(hMenuWnd, false);
                }
            }
            if (gMenuAccelPressed) {
                HWND hMenu = FindWindow(UNDOCUMENTED_MENU_CLASS_NAME, nullptr);
                if (hMenu) {
                    if ('a' <= gMenuAccelPressed && gMenuAccelPressed <= 'z') {
                        gMenuAccelPressed -= 'a' - 'A';
                    }
                    if ('A' <= gMenuAccelPressed && gMenuAccelPressed <= 'Z') {
                        PostMessageW(hMenu, WM_KEYDOWN, gMenuAccelPressed, 0);
                    } else {
                        PostMessageW(hMenu, WM_CHAR, gMenuAccelPressed, 0);
                    }
                }
                gMenuAccelPressed = 0;
            }
            break;

        case WM_SYSCOLORCHANGE:
            break;
    }

    *callDef = true;
    return 0;
}

// --- End caption code ---

HWND gLastActiveFrameHwnd = nullptr;

static constexpr UINT WM_TTS_EVENT = WM_APP + 0x421;
static constexpr UINT WM_MAIN_WINDOW_DPI_SETTLED = WM_APP + 0x422;

static WindowTab* gReadAloudSourceTab = nullptr;
static WindowTab* gReadAloudSessionTab = nullptr;

static void ReadAloudShowNotif(WindowTab* tab, const char* msg);

static bool ReadAloudSpeakChunk(WindowTab* tab, const char* errMsg);
static void ReadAloudSaveVoicePref(const char* voiceId);
static void ReadAloudRestartSpeakingFromCurrentPosition(WindowTab* tab);
static TempStr TtsLangIdToLocaleNameTemp(const char* lang);

// voices are grouped by how they sound and whether they need internet:
// local natural (best, offline), local legacy (robotic SAPI "Desktop" voices),
// online and online multilingual (both need internet)
enum class TtsVoiceGroup {
    LocalNatural,
    LocalLegacy,
    Online,
    OnlineMultilingual,
};

static TtsVoiceGroup GetTtsVoiceGroup(const char* name) {
    if (str::ContainsI(name, "Multilingual")) {
        return TtsVoiceGroup::OnlineMultilingual;
    }
    if (str::ContainsI(name, "Online")) {
        return TtsVoiceGroup::Online;
    }
    if (str::ContainsI(name, "Natural")) {
        return TtsVoiceGroup::LocalNatural;
    }
    return TtsVoiceGroup::LocalLegacy;
}

//--- smart bilingual modes: pick a Chinese or English voice per chunk (local or online)

enum class SmartBilingualKind {
    Local,
    Online,
};

static char* gSmartBilingualZhVoiceId = nullptr;
static char* gSmartBilingualEnVoiceId = nullptr;
static bool gSmartBilingualResolveAttempted = false;
static SmartBilingualKind gSmartBilingualResolvedKind = SmartBilingualKind::Local;

static void InvalidateSmartBilingualVoiceCache() {
    str::FreePtr(&gSmartBilingualZhVoiceId);
    str::FreePtr(&gSmartBilingualEnVoiceId);
    gSmartBilingualResolveAttempted = false;
}

static bool TryGetSmartBilingualKind(const char* voiceId, SmartBilingualKind* out) {
    if (str::Eq(voiceId, kTtsSmartBilingualVoiceId)) {
        if (out) {
            *out = SmartBilingualKind::Local;
        }
        return true;
    }
    if (str::Eq(voiceId, kTtsSmartOnlineBilingualVoiceId)) {
        if (out) {
            *out = SmartBilingualKind::Online;
        }
        return true;
    }
    return false;
}

static bool IsSmartBilingualVoicePref() {
    return gGlobalPrefs && TryGetSmartBilingualKind(gGlobalPrefs->readAloudVoiceId, nullptr);
}

static bool IsSmartBilingualKindActive(SmartBilingualKind kind) {
    SmartBilingualKind active;
    return gGlobalPrefs && TryGetSmartBilingualKind(gGlobalPrefs->readAloudVoiceId, &active) && active == kind;
}

static char** SmartBilingualZhPref(SmartBilingualKind kind) {
    if (!gGlobalPrefs) {
        return nullptr;
    }
    return kind == SmartBilingualKind::Online ? &gGlobalPrefs->readAloudSmartOnlineVoiceZh
                                              : &gGlobalPrefs->readAloudSmartVoiceZh;
}

static char** SmartBilingualEnPref(SmartBilingualKind kind) {
    if (!gGlobalPrefs) {
        return nullptr;
    }
    return kind == SmartBilingualKind::Online ? &gGlobalPrefs->readAloudSmartOnlineVoiceEn
                                              : &gGlobalPrefs->readAloudSmartVoiceEn;
}

static bool IsLocalTtsVoice(const TtsVoiceInfo& voice) {
    TtsVoiceGroup group = GetTtsVoiceGroup(voice.name);
    return group == TtsVoiceGroup::LocalNatural || group == TtsVoiceGroup::LocalLegacy;
}

static bool IsOnlineTtsVoice(const TtsVoiceInfo& voice) {
    return GetTtsVoiceGroup(voice.name) == TtsVoiceGroup::Online;
}

static bool IsSmartBilingualVoiceMatch(const TtsVoiceInfo& voice, SmartBilingualKind kind) {
    return kind == SmartBilingualKind::Online ? IsOnlineTtsVoice(voice) : IsLocalTtsVoice(voice);
}

static bool VoiceLangStartsWith(const TtsVoiceInfo& voice, const char* prefix) {
    TempStr locale = TtsLangIdToLocaleNameTemp(voice.lang);
    return str::StartsWithI(locale, prefix);
}

// loads zh/en voice ids from settings; returns true if at least one is configured
static bool ResolveSmartBilingualVoices(SmartBilingualKind kind) {
    if (gSmartBilingualResolveAttempted && gSmartBilingualResolvedKind == kind) {
        return gSmartBilingualZhVoiceId || gSmartBilingualEnVoiceId;
    }
    InvalidateSmartBilingualVoiceCache();
    gSmartBilingualResolveAttempted = true;
    gSmartBilingualResolvedKind = kind;

    char** zhPref = SmartBilingualZhPref(kind);
    char** enPref = SmartBilingualEnPref(kind);
    if (zhPref && !str::IsEmpty(*zhPref)) {
        gSmartBilingualZhVoiceId = str::Dup(*zhPref);
    }
    if (enPref && !str::IsEmpty(*enPref)) {
        gSmartBilingualEnVoiceId = str::Dup(*enPref);
    }

    return gSmartBilingualZhVoiceId || gSmartBilingualEnVoiceId;
}

static bool TtsSmartBilingualAvailable(SmartBilingualKind kind) {
    Vec<TtsVoiceInfo> voices = TtsGetVoices();
    bool haveZh = false;
    bool haveEn = false;
    for (TtsVoiceInfo& voice : voices) {
        if (!IsSmartBilingualVoiceMatch(voice, kind)) {
            continue;
        }
        if (VoiceLangStartsWith(voice, "zh")) {
            haveZh = true;
        } else if (VoiceLangStartsWith(voice, "en")) {
            haveEn = true;
        }
    }
    TtsFreeVoices(voices);
    return haveZh || haveEn;
}

// fills empty zh/en prefs with the first matching voice for each language
static void AutoFillSmartBilingualVoicePrefsIfEmpty(SmartBilingualKind kind) {
    char** zhPref = SmartBilingualZhPref(kind);
    char** enPref = SmartBilingualEnPref(kind);
    if (!zhPref || !enPref) {
        return;
    }

    bool needZh = str::IsEmpty(*zhPref);
    bool needEn = str::IsEmpty(*enPref);
    if (!needZh && !needEn) {
        return;
    }

    Vec<TtsVoiceInfo> voices = TtsGetVoices();
    for (TtsVoiceInfo& voice : voices) {
        if (!IsSmartBilingualVoiceMatch(voice, kind)) {
            continue;
        }
        if (needZh && VoiceLangStartsWith(voice, "zh")) {
            str::ReplaceWithCopy(zhPref, voice.id);
            needZh = false;
        } else if (needEn && VoiceLangStartsWith(voice, "en")) {
            str::ReplaceWithCopy(enPref, voice.id);
            needEn = false;
        }
        if (!needZh && !needEn) {
            break;
        }
    }
    TtsFreeVoices(voices);
}

static void ReadAloudApplySmartBilingualVoice(SmartBilingualKind kind, bool isZh, const char* voiceId) {
    char** pref = isZh ? SmartBilingualZhPref(kind) : SmartBilingualEnPref(kind);
    if (!pref || str::IsEmpty(voiceId)) {
        return;
    }

    if (str::Eq(*pref, voiceId)) {
        return;
    }

    str::ReplaceWithCopy(pref, voiceId);
    InvalidateSmartBilingualVoiceCache();
    if (IsSmartBilingualKindActive(kind)) {
        ReadAloudRestartSpeakingFromCurrentPosition(gReadAloudSourceTab);
    }
}

struct Dialog_ReadAloudSmartVoices_Data {
    Vec<char*> ownedVoiceIds;
    SmartBilingualKind kind = SmartBilingualKind::Local;
    char* originalZhVoiceId = nullptr;
    char* originalEnVoiceId = nullptr;
};

static void FreeReadAloudSmartVoiceComboIds(Dialog_ReadAloudSmartVoices_Data* data) {
    if (!data) {
        return;
    }
    for (char* id : data->ownedVoiceIds) {
        str::Free(id);
    }
    data->ownedVoiceIds.Reset();
}

static void FreeReadAloudSmartVoiceDialogData(Dialog_ReadAloudSmartVoices_Data* data) {
    if (!data) {
        return;
    }
    FreeReadAloudSmartVoiceComboIds(data);
    str::FreePtr(&data->originalZhVoiceId);
    str::FreePtr(&data->originalEnVoiceId);
}

static void ReadAloudRestoreSmartBilingualVoicesFromDialog(Dialog_ReadAloudSmartVoices_Data* data) {
    if (!data) {
        return;
    }

    char** zhPref = SmartBilingualZhPref(data->kind);
    char** enPref = SmartBilingualEnPref(data->kind);
    bool changed = false;
    const char* originalZh = data->originalZhVoiceId ? data->originalZhVoiceId : "";
    const char* originalEn = data->originalEnVoiceId ? data->originalEnVoiceId : "";

    if (zhPref && !str::Eq(*zhPref, originalZh)) {
        str::ReplaceWithCopy(zhPref, originalZh);
        changed = true;
    }
    if (enPref && !str::Eq(*enPref, originalEn)) {
        str::ReplaceWithCopy(enPref, originalEn);
        changed = true;
    }
    if (!changed) {
        return;
    }

    InvalidateSmartBilingualVoiceCache();
    if (IsSmartBilingualKindActive(data->kind)) {
        ReadAloudRestartSpeakingFromCurrentPosition(gReadAloudSourceTab);
    }
}

static void FillReadAloudSmartVoiceCombo(HWND combo, Vec<TtsVoiceInfo>& voices, const char* langPrefix,
                                         const char* selectedId, Dialog_ReadAloudSmartVoices_Data* data) {
    if (!combo || !data) {
        return;
    }
    SendMessageW(combo, CB_RESETCONTENT, 0, 0);

    int maxDropWidth = 0;
    HDC hdc = GetDC(combo);
    HFONT hFont = (HFONT)SendMessageW(combo, WM_GETFONT, 0, 0);
    HFONT hOld = hFont && hdc ? (HFONT)SelectObject(hdc, hFont) : nullptr;

    for (TtsVoiceInfo& voice : voices) {
        if (!IsSmartBilingualVoiceMatch(voice, data->kind)) {
            continue;
        }
        if (!VoiceLangStartsWith(voice, langPrefix)) {
            continue;
        }

        TempStr detail = str::FormatTemp("%s - %s", voice.name, TtsLangIdToLocaleNameTemp(voice.lang));
        int idx = (int)SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)ToWStrTemp(detail));
        if (idx < 0) {
            continue;
        }

        if (hdc) {
            WCHAR* wdetail = ToWStrTemp(detail);
            SIZE sz{};
            GetTextExtentPoint32W(hdc, wdetail, (int)str::Len(wdetail), &sz);
            if ((int)sz.cx + DpiScale(combo, 24) > maxDropWidth) {
                maxDropWidth = (int)sz.cx + DpiScale(combo, 24);
            }
        }

        char* id = str::Dup(voice.id);
        data->ownedVoiceIds.Append(id);
        SendMessageW(combo, CB_SETITEMDATA, idx, (LPARAM)id);
        if (str::Eq(voice.id, selectedId)) {
            SendMessageW(combo, CB_SETCURSEL, idx, 0);
        }
    }

    if (hOld && hdc) {
        SelectObject(hdc, hOld);
    }
    if (hdc) {
        ReleaseDC(combo, hdc);
    }

    if (maxDropWidth > 0) {
        SendMessageW(combo, CB_SETDROPPEDWIDTH, maxDropWidth, 0);
    }
}

static const char* ReadAloudSmartVoiceIdFromCombo(HWND combo) {
    if (!combo) {
        return nullptr;
    }
    int idx = (int)SendMessageW(combo, CB_GETCURSEL, 0, 0);
    if (idx < 0) {
        return nullptr;
    }
    return (const char*)SendMessageW(combo, CB_GETITEMDATA, idx, 0);
}

static constexpr float kReadAloudSpeedPresets[] = {0.25f, 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};

static bool ReadAloudSpeakingRatesEqual(float a, float b) {
    float diff = a - b;
    if (diff < 0) {
        diff = -diff;
    }
    return diff < 0.01f;
}

static float ReadAloudClampSpeakingRate(float rate) {
    return rate > 0 ? rate : 1.0f;
}

// Center the label+combo rows in the dialog; keep OK/Cancel right-aligned from the RC.
static void LayoutReadAloudSmartVoiceRows(HWND hDlg) {
    HWND hwndLabels[2] = {GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_EN_LABEL),
                          GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_ZH_LABEL)};
    HWND hwndCombos[2] = {GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_EN), GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_ZH)};
    if (!hwndLabels[0] || !hwndLabels[1] || !hwndCombos[0] || !hwndCombos[1]) {
        return;
    }

    RECT rcClient{};
    GetClientRect(hDlg, &rcClient);
    int clientW = rcClient.right - rcClient.left;

    int labelW = 0;
    HDC hdc = GetDC(hDlg);
    HFONT hFont = (HFONT)SendMessageW(hDlg, WM_GETFONT, 0, 0);
    HFONT hOld = hFont && hdc ? (HFONT)SelectObject(hdc, hFont) : nullptr;
    for (HWND hwndLabel : hwndLabels) {
        TempWStr text = HwndGetTextWTemp(hwndLabel);
        if (!text || !hdc) {
            continue;
        }
        SIZE sz{};
        GetTextExtentPoint32W(hdc, text, (int)str::Len(text), &sz);
        if ((int)sz.cx > labelW) {
            labelW = (int)sz.cx;
        }
    }
    if (hOld && hdc) {
        SelectObject(hdc, hOld);
    }
    if (hdc) {
        ReleaseDC(hDlg, hdc);
    }

    labelW += DpiScale(hDlg, 4);
    int gap = DpiScale(hDlg, 6);

    RECT rcCombo{};
    GetWindowRect(hwndCombos[0], &rcCombo);
    MapWindowPoints(nullptr, hDlg, (POINT*)&rcCombo, 2);
    int comboW = rcCombo.right - rcCombo.left;

    int blockW = labelW + gap + comboW;
    int left = (clientW - blockW) / 2;
    int minMargin = DpiScale(hDlg, 8);
    if (left < minMargin) {
        left = minMargin;
    }

    for (int i = 0; i < 2; i++) {
        RECT rcC{};
        GetWindowRect(hwndCombos[i], &rcC);
        MapWindowPoints(nullptr, hDlg, (POINT*)&rcC, 2);

        // Align label to the closed combo field (not the dropdown list height).
        int comboY = rcC.top;
        int comboH = rcC.bottom - rcC.top;
        COMBOBOXINFO cbi{};
        cbi.cbSize = sizeof(cbi);
        if (GetComboBoxInfo(hwndCombos[i], &cbi)) {
            RECT rcItem = cbi.rcItem;
            MapWindowPoints(hwndCombos[i], hDlg, (POINT*)&rcItem, 2);
            comboY = rcItem.top;
            comboH = rcItem.bottom - rcItem.top;
        }

        // Same top/height as the combo, with SS_CENTERIMAGE so text sits on the midline.
        LONG_PTR style = GetWindowLongPtr(hwndLabels[i], GWL_STYLE);
        SetWindowLongPtr(hwndLabels[i], GWL_STYLE, style | SS_RIGHT | SS_CENTERIMAGE);
        SetWindowPos(hwndLabels[i], nullptr, left, comboY, labelW, comboH, SWP_NOZORDER | SWP_NOACTIVATE);
        SetWindowPos(hwndCombos[i], nullptr, left + labelW + gap, rcC.top, comboW, rcC.bottom - rcC.top,
                     SWP_NOZORDER | SWP_NOACTIVATE);
    }
}

static INT_PTR CALLBACK Dialog_ReadAloudSmartVoices_Proc(HWND hDlg, UINT msg, WPARAM wp, LPARAM lp) {
    Dialog_ReadAloudSmartVoices_Data* data = nullptr;

    switch (msg) {
        case WM_INITDIALOG:
            data = (Dialog_ReadAloudSmartVoices_Data*)lp;
            SetWindowLongPtr(hDlg, GWLP_USERDATA, (LONG_PTR)data);
            if (UseDarkModeLib()) {
                DarkMode::setDarkWndSafe(hDlg);
            }
            UpdateWindowCaptionTheme(hDlg);

            HwndSetText(hDlg, data->kind == SmartBilingualKind::Online ? _TRA("Online smart bilingual settings")
                                                                       : _TRA("Local smart bilingual settings"));
            HwndSetDlgItemText(hDlg, IDC_READ_ALOUD_SMART_ZH_LABEL, _TRA("Chinese voice"));
            HwndSetDlgItemText(hDlg, IDC_READ_ALOUD_SMART_EN_LABEL, _TRA("English voice"));
            HwndSetDlgItemText(hDlg, IDOK, _TRA("OK"));
            HwndSetDlgItemText(hDlg, IDCANCEL, _TRA("Cancel"));

            {
                char** zhPref = SmartBilingualZhPref(data->kind);
                char** enPref = SmartBilingualEnPref(data->kind);
                data->originalZhVoiceId = str::Dup(zhPref && *zhPref ? *zhPref : "");
                data->originalEnVoiceId = str::Dup(enPref && *enPref ? *enPref : "");

                Vec<TtsVoiceInfo> voices = TtsGetVoices();
                const char* zhSel = zhPref ? *zhPref : nullptr;
                const char* enSel = enPref ? *enPref : nullptr;
                FillReadAloudSmartVoiceCombo(GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_ZH), voices, "zh", zhSel, data);
                FillReadAloudSmartVoiceCombo(GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_EN), voices, "en", enSel, data);
                TtsFreeVoices(voices);
            }

            LayoutReadAloudSmartVoiceRows(hDlg);
            CenterDialog(hDlg);
            HwndSetFocus(GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_EN));
            return FALSE;

        case WM_COMMAND:
            switch (LOWORD(wp)) {
                case IDOK:
                    data = (Dialog_ReadAloudSmartVoices_Data*)GetWindowLongPtr(hDlg, GWLP_USERDATA);
                    ReadAloudApplySmartBilingualVoice(
                        data->kind, true, ReadAloudSmartVoiceIdFromCombo(GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_ZH)));
                    ReadAloudApplySmartBilingualVoice(
                        data->kind, false, ReadAloudSmartVoiceIdFromCombo(GetDlgItem(hDlg, IDC_READ_ALOUD_SMART_EN)));
                    SaveSettings();
                    EndDialog(hDlg, IDOK);
                    return TRUE;

                case IDCANCEL:
                    data = (Dialog_ReadAloudSmartVoices_Data*)GetWindowLongPtr(hDlg, GWLP_USERDATA);
                    ReadAloudRestoreSmartBilingualVoicesFromDialog(data);
                    EndDialog(hDlg, IDCANCEL);
                    return TRUE;

                case IDC_READ_ALOUD_SMART_ZH:
                case IDC_READ_ALOUD_SMART_EN:
                    if (HIWORD(wp) == CBN_SELCHANGE) {
                        data = (Dialog_ReadAloudSmartVoices_Data*)GetWindowLongPtr(hDlg, GWLP_USERDATA);
                        bool isZh = LOWORD(wp) == IDC_READ_ALOUD_SMART_ZH;
                        HWND combo = GetDlgItem(hDlg, LOWORD(wp));
                        ReadAloudApplySmartBilingualVoice(data->kind, isZh, ReadAloudSmartVoiceIdFromCombo(combo));
                    }
                    return TRUE;
            }
            break;

        case WM_CLOSE:
            data = (Dialog_ReadAloudSmartVoices_Data*)GetWindowLongPtr(hDlg, GWLP_USERDATA);
            ReadAloudRestoreSmartBilingualVoicesFromDialog(data);
            EndDialog(hDlg, IDCANCEL);
            return TRUE;
    }

    return FALSE;
}

static void ShowReadAloudSmartVoiceDialog(MainWindow* win, SmartBilingualKind kind) {
    if (!win) {
        return;
    }

    Dialog_ReadAloudSmartVoices_Data data;
    data.kind = kind;
    CreateAppDialogBox(IDD_DIALOG_READ_ALOUD_SMART_VOICES, win->hwndFrame, Dialog_ReadAloudSmartVoices_Proc,
                       (LPARAM)&data);
    FreeReadAloudSmartVoiceDialogData(&data);
}

enum class ReadAloudLang {
    Unknown,
    Zh,
    En,
};

static bool ResolveActiveSmartBilingualVoices() {
    SmartBilingualKind kind;
    if (!gGlobalPrefs || !TryGetSmartBilingualKind(gGlobalPrefs->readAloudVoiceId, &kind)) {
        return false;
    }
    return ResolveSmartBilingualVoices(kind);
}

static const char* ReadAloudSmartVoiceForLang(ReadAloudLang lang) {
    if (!ResolveActiveSmartBilingualVoices()) {
        return nullptr;
    }
    if (lang == ReadAloudLang::Zh) {
        return gSmartBilingualZhVoiceId ? gSmartBilingualZhVoiceId : gSmartBilingualEnVoiceId;
    }
    if (lang == ReadAloudLang::En) {
        return gSmartBilingualEnVoiceId ? gSmartBilingualEnVoiceId : gSmartBilingualZhVoiceId;
    }
    return gSmartBilingualZhVoiceId ? gSmartBilingualZhVoiceId : gSmartBilingualEnVoiceId;
}

static bool ReadAloudSmartBilingualHasBothVoices() {
    return ResolveActiveSmartBilingualVoices() && gSmartBilingualZhVoiceId && gSmartBilingualEnVoiceId;
}

static float ReadAloudRateForLang(ReadAloudLang lang) {
    if (!gGlobalPrefs) {
        return 1.0f;
    }
    float zhRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateZh);
    float enRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateEn);
    if (lang == ReadAloudLang::En) {
        return enRate;
    }
    if (lang == ReadAloudLang::Zh) {
        return zhRate;
    }
    // Unknown: in smart bilingual with only one voice, follow that voice's language.
    if (IsSmartBilingualVoicePref() && ResolveActiveSmartBilingualVoices()) {
        if (gSmartBilingualEnVoiceId && !gSmartBilingualZhVoiceId) {
            return enRate;
        }
    }
    return zhRate;
}

static ReadAloudLang ReadAloudLangFromVoiceId(const char* voiceId) {
    if (str::IsEmpty(voiceId)) {
        return ReadAloudLang::Unknown;
    }
    Vec<TtsVoiceInfo> voices = TtsGetVoices();
    ReadAloudLang lang = ReadAloudLang::Unknown;
    for (TtsVoiceInfo& voice : voices) {
        if (!str::Eq(voice.id, voiceId)) {
            continue;
        }
        if (VoiceLangStartsWith(voice, "zh")) {
            lang = ReadAloudLang::Zh;
        } else if (VoiceLangStartsWith(voice, "en")) {
            lang = ReadAloudLang::En;
        }
        break;
    }
    TtsFreeVoices(voices);
    return lang;
}

static void ReadAloudApplyRateForLang(ReadAloudLang lang) {
    float rate = ReadAloudRateForLang(lang);
    if (!ReadAloudSpeakingRatesEqual(TtsGetSpeakingRate(), rate)) {
        TtsSetSpeakingRate(rate);
    }
}

static int ReadAloudSpeedPresetIndex(float rate) {
    for (int i = 0; i < (int)dimof(kReadAloudSpeedPresets); i++) {
        if (ReadAloudSpeakingRatesEqual(rate, kReadAloudSpeedPresets[i])) {
            return i;
        }
    }
    return -1;
}

static const char* ReadAloudSpeedPresetShortLabel(int index) {
    static const char* labels[] = {"0.25×", "0.5×", "0.75×", "1.0×", "1.25×", "1.5×", "2.0×"};
    if (index < 0 || index >= (int)dimof(labels)) {
        return "1.0×";
    }
    return labels[index];
}

static TempStr ReadAloudSpeedLabel(float rate) {
    int index = ReadAloudSpeedPresetIndex(rate);
    if (index >= 0) {
        return str::DupTemp(ReadAloudSpeedPresetShortLabel(index));
    }
    return str::FormatTemp("%.2f×", rate);
}

static bool ReadAloudIsCjkPunctuationCp(char32_t cp) {
    // CJK/fullwidth punctuation only — not letters; treat as neutral in bilingual chunking
    return (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFF00 && cp <= 0xFF0F) || (cp >= 0xFF1A && cp <= 0xFF20) ||
           (cp >= 0xFF3B && cp <= 0xFF40) || (cp >= 0xFF5B && cp <= 0xFF65);
}

static bool ReadAloudIsCjkLetterCp(char32_t cp) {
    if (ReadAloudIsCjkPunctuationCp(cp)) {
        return false;
    }
    // Han, kana, hangul, compatibility ideographs, halfwidth katakana
    return (cp >= 0x2E80 && cp <= 0x9FFF) || (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xAC00 && cp <= 0xD7AF) ||
           (cp >= 0xFF66 && cp <= 0xFF9D);
}

static bool ReadAloudIsLatinLetterCp(char32_t cp) {
    return (cp >= 'A' && cp <= 'Z') || (cp >= 'a' && cp <= 'z') || (cp >= 0xC0 && cp <= 0x24F) ||
           (cp >= 0xFF21 && cp <= 0xFF3A) || (cp >= 0xFF41 && cp <= 0xFF5A);
}

// Short English runs embedded in Chinese (e.g. "App", "Windows 11") use the Chinese
// voice; switch to English only for a long run or a complete short sentence.
// CJK/fullwidth punctuation in English text (，．？) stays neutral and does not
// trigger a Chinese voice chunk.
static constexpr int kReadAloudMinEnWordsToSwitch = 5;

static bool ReadAloudIsSentenceEndCp(char32_t cp) {
    return cp == '.' || cp == '!' || cp == '?' || cp == 0x3002 || cp == 0xFF0E || cp == 0xFF01 || cp == 0xFF1F;
}

struct ReadAloudLatinRunInfo {
    int words = 0;
    bool hitCjk = false;
    bool hasSentenceEnd = false;
};

// Scans a consecutive Latin run from p until CJK or end of text.
static ReadAloudLatinRunInfo ReadAloudAnalyzeLatinRun(const char* p) {
    ReadAloudLatinRunInfo info;
    bool inWord = false;
    while (*p) {
        char32_t cp = 0;
        if (!ReadAloudDecodeUtf8One(p, &cp)) {
            break;
        }
        if (ReadAloudIsCjkLetterCp(cp)) {
            info.hitCjk = true;
            break;
        }
        if (ReadAloudIsLatinLetterCp(cp)) {
            if (!inWord) {
                inWord = true;
                info.words++;
            }
        } else {
            inWord = false;
            if (ReadAloudIsSentenceEndCp(cp)) {
                info.hasSentenceEnd = true;
            }
        }
    }
    return info;
}

// English-primary text (no CJK ahead) always uses the English voice. Embedded runs
// switch only for a long passage or a multi-word sentence ending in . ! ?
static bool ReadAloudLatinRunWarrantsEnglishVoice(const ReadAloudLatinRunInfo& info) {
    if (!info.hitCjk) {
        return true;
    }
    if (info.words >= kReadAloudMinEnWordsToSwitch) {
        return true;
    }
    return info.hasSentenceEnd && info.words >= 2;
}

static const char* ReadAloudSkipLatinRun(const char* p) {
    char32_t cp = 0;
    while (*p) {
        const char* prev = p;
        if (!ReadAloudDecodeUtf8One(p, &cp)) {
            break;
        }
        if (ReadAloudIsCjkLetterCp(cp)) {
            return prev;
        }
    }
    return p;
}

// finds where the chunk starting at `start` should end so that it can be spoken by a
// single voice, and which language that is. respects maxLen (cut at codepoint boundary,
// preferring a space for latin text)
static int ReadAloudFindBilingualChunkEnd(const char* text, int start, int maxLen, ReadAloudLang* langOut) {
    int textLen = str::Leni(text);
    *langOut = ReadAloudLang::Unknown;
    if (start >= textLen) {
        return textLen;
    }

    ReadAloudLang lang = ReadAloudLang::Unknown;
    const char* base = text;
    const char* p = text + start;
    const char* chunkEnd = nullptr; // set when the language changes

    while (*p) {
        const char* cpStart = p;
        if ((int)(cpStart - base) - start >= maxLen) {
            chunkEnd = cpStart;
            break;
        }

        char32_t cp = 0;
        if (!ReadAloudDecodeUtf8One(p, &cp)) {
            break;
        }

        bool isCjk = ReadAloudIsCjkLetterCp(cp);
        bool isLatin = ReadAloudIsLatinLetterCp(cp);
        if (!isCjk && !isLatin) {
            continue; // neutral: digits, spaces, latin/CJK punctuation follow surrounding language
        }

        if (lang == ReadAloudLang::Unknown) {
            if (isCjk) {
                lang = ReadAloudLang::Zh;
            } else {
                ReadAloudLatinRunInfo run = ReadAloudAnalyzeLatinRun(cpStart);
                lang = ReadAloudLatinRunWarrantsEnglishVoice(run) ? ReadAloudLang::En : ReadAloudLang::Zh;
            }
            continue;
        }

        if (lang == ReadAloudLang::Zh && isLatin) {
            ReadAloudLatinRunInfo run = ReadAloudAnalyzeLatinRun(cpStart);
            if (ReadAloudLatinRunWarrantsEnglishVoice(run)) {
                chunkEnd = cpStart;
                break;
            }
            // absorb the short run: skip past it so we don't re-count for every letter
            p = ReadAloudSkipLatinRun(cpStart);
            continue;
        }

        if (lang == ReadAloudLang::En && isCjk) {
            chunkEnd = cpStart;
            break;
        }
    }

    int end;
    if (chunkEnd) {
        end = (int)(chunkEnd - base);
    } else {
        end = textLen;
    }

    // when cut by maxLen mid-text, prefer to break at a space (helps latin text;
    // Chinese has no spaces so the codepoint boundary cut above is the best we can do)
    if (end < textLen && end - start >= maxLen) {
        int spaceEnd = end;
        while (spaceEnd > start && text[spaceEnd] != ' ') {
            spaceEnd--;
        }
        if (spaceEnd > start) {
            end = spaceEnd;
        }
    }

    *langOut = lang;
    return end;
}

static void ReadAloudApplyVoiceFromSettings() {
    if (!gGlobalPrefs) {
        return;
    }
    const char* voiceId = gGlobalPrefs->readAloudVoiceId;
    if (TryGetSmartBilingualKind(voiceId, nullptr)) {
        return;
    }
    TtsSetVoiceById(str::IsEmpty(voiceId) ? "" : voiceId);
    ReadAloudApplyRateForLang(ReadAloudLangFromVoiceId(voiceId));
}

static void ReadAloudRestartSpeakingFromCurrentPosition(WindowTab* tab) {
    if (!tab || !TtsIsSpeaking() || str::IsEmpty(tab->readAloudText)) {
        return;
    }

    TtsProcessEvents();
    int posInChunk = TtsGetSpokenPosUtf8();
    if (posInChunk < 0) {
        posInChunk = 0;
    }
    tab->readAloudChunkEnd = tab->readAloudChunkStart + posInChunk;
    TtsStop();
    TtsProcessEvents();
    ReadAloudApplyVoiceFromSettings();
    ReadAloudSpeakChunk(tab, _TRA("No text available to read aloud"));
}

static void ReadAloudSaveVoicePref(const char* voiceId) {
    if (!gGlobalPrefs) {
        return;
    }

    const char* newVoiceId = voiceId ? voiceId : "";
    const char* curPref = gGlobalPrefs->readAloudVoiceId ? gGlobalPrefs->readAloudVoiceId : "";
    if (str::Eq(curPref, newVoiceId)) {
        return;
    }

    SmartBilingualKind smartKind;
    if (TryGetSmartBilingualKind(newVoiceId, &smartKind)) {
        AutoFillSmartBilingualVoicePrefsIfEmpty(smartKind);
        InvalidateSmartBilingualVoiceCache();
        if (!ResolveSmartBilingualVoices(smartKind)) {
            return;
        }
    }

    str::ReplaceWithCopy(&gGlobalPrefs->readAloudVoiceId, newVoiceId);
    SaveSettings();

    ReadAloudRestartSpeakingFromCurrentPosition(gReadAloudSourceTab);
}

static const char* ReadAloudSpeedPresetLabel(int index) {
    switch (index) {
        case 0:
            return _TRA("Slowest (0.25×)");
        case 1:
            return _TRA("Very slow (0.5×)");
        case 2:
            return _TRA("Slow (0.75×)");
        case 3:
            return _TRA("Normal (1.0×)");
        case 4:
            return _TRA("Fast (1.25×)");
        case 5:
            return _TRA("Faster (1.5×)");
        case 6:
            return _TRA("Fastest (2.0×)");
        default:
            return "";
    }
}

// WinRT speech synthesis is too slow for whole-document requests; speak in chunks.
static constexpr int kReadAloudMaxChunkLen = 1024;

static int ReadAloudFindChunkEnd(const char* text, int start, int maxLen) {
    int textLen = str::Leni(text);
    if (start >= textLen) {
        return textLen;
    }

    int end = start + maxLen;
    if (end >= textLen) {
        return textLen;
    }

    while (end > start && text[end] != ' ') {
        end--;
    }
    if (end <= start) {
        end = start + maxLen;
        if (end > textLen) {
            end = textLen;
        }
    }
    return end;
}

static bool ReadAloudHasMoreDocumentPages(WindowTab* tab) {
    if (!tab || tab->readAloudBuiltEndPage <= 0) {
        return false;
    }
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        return false;
    }
    return tab->readAloudBuiltEndPage < dm->PageCount();
}

static bool ReadAloudEnsureDocumentTextExtended(WindowTab* tab) {
    if (!tab || tab->readAloudBuiltEndPage <= 0) {
        return false;
    }
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        return false;
    }

    int pageCount = dm->PageCount();
    if (tab->readAloudBuiltEndPage >= pageCount) {
        return false;
    }

    int textLen = str::Leni(tab->readAloudText);
    int remaining = textLen - tab->readAloudChunkEnd;
    if (remaining >= kReadAloudMaxChunkLen * 2) {
        return false;
    }

    int nextStart = tab->readAloudBuiltEndPage + 1;
    int nextEnd = nextStart + kReadAloudBuildPagesPerBatch - 1;
    if (nextEnd > pageCount) {
        nextEnd = pageCount;
    }

    if (!tab->readAloudHighlight) {
        tab->readAloudHighlight = new ReadAloudHighlightMap{};
    }

    if (!ReadAloudHighlightAppendDocumentPages(dm, nextStart, nextEnd, tab->readAloudHighlight, &tab->readAloudText)) {
        return false;
    }

    tab->readAloudBuiltEndPage = nextEnd;
    return true;
}

static bool ReadAloudHasMoreChunks(WindowTab* tab) {
    if (!tab || str::IsEmpty(tab->readAloudText)) {
        return ReadAloudHasMoreDocumentPages(tab);
    }
    if (tab->readAloudChunkEnd < str::Leni(tab->readAloudText)) {
        return true;
    }
    return ReadAloudHasMoreDocumentPages(tab);
}

static void ReadAloudFinishSession(WindowTab* tab, MainWindow* win) {
    if (!tab) {
        return;
    }

    if (tab->win) {
        ReadAloudHighlightTimerStop(tab->win);
        InvalidateRect(tab->win->hwndCanvas, nullptr, FALSE);
    }
    str::FreePtr(&tab->readAloudText);
    tab->readAloudResumePos = -1;
    tab->readAloudChunkStart = 0;
    tab->readAloudChunkEnd = 0;
    tab->readAloudBuiltEndPage = 0;
    tab->readAloudStartPage = 0;
    tab->readAloudStartGlyph = 0;
    if (tab->readAloudHighlight) {
        ReadAloudHighlightFree(tab->readAloudHighlight);
        delete tab->readAloudHighlight;
        tab->readAloudHighlight = nullptr;
    }
    tab->readAloudHighlightBase = 0;
    tab->readAloudAutoScroll = false;
    tab->readAloudAutoScrollHold = false;
    tab->readAloudAutoScrollHoldPageNo = -1;
    tab->readAloudAutoScrollHoldLineY = -1.f;
    tab->readAloudScope = 0;
    ReadAloudClearSourceTab();
    if (gReadAloudSessionTab == tab) {
        gReadAloudSessionTab = nullptr;
    }
    if (win) {
        ToolbarUpdateStateForWindow(win, true);
    }
}

struct DeferredReadAloudResyncData {
    MainWindow* win = nullptr;
    WindowTab* tab = nullptr;
};

static void DeferredReadAloudResync(DeferredReadAloudResyncData* d) {
    AutoDelete del(d);
    if (!IsMainWindowValid(d->win) || !d->tab) {
        return;
    }
    bool textRelocated = false;
    if (!RefreshReadAloudHighlightAfterLayoutChange(d->tab, d->win, &textRelocated)) {
        return;
    }
    if (textRelocated && TtsIsSpeaking() && GetReadAloudSourceTab() == d->tab) {
        TtsStop();
        TtsProcessEvents();
        ReadAloudSpeakChunk(d->tab, _TRA("No text available to read aloud"));
    }
}

static void ResyncReadAloudAfterLayoutChange(WindowTab* tab, MainWindow* win) {
    if (!tab || str::IsEmpty(tab->readAloudText) || !tab->readAloudHighlight) {
        return;
    }
    bool textRelocated = false;
    if (!RefreshReadAloudHighlightAfterLayoutChange(tab, win, &textRelocated)) {
        return;
    }
    if (textRelocated && TtsIsSpeaking() && GetReadAloudSourceTab() == tab) {
        TtsStop();
        TtsProcessEvents();
        ReadAloudSpeakChunk(tab, _TRA("No text available to read aloud"));
    }
}

void RemapAnchorsAfterReflow(WindowTab* tab, MainWindow* win) {
    if (!tab) {
        return;
    }
    RefreshTextSelectionAfterLayoutChange(tab, win);
    ResyncReadAloudAfterLayoutChange(tab, win);
    EbookAnnotationsInvalidateLayoutCaches(tab);
}

static void ScheduleReadAloudResyncAfterLayoutChange(MainWindow* win, WindowTab* tab) {
    if (!tab || str::IsEmpty(tab->readAloudText) || !tab->readAloudHighlight) {
        return;
    }
    auto d = new DeferredReadAloudResyncData;
    d->win = win;
    d->tab = tab;
    uitask::Post(MkFunc0<DeferredReadAloudResyncData>(DeferredReadAloudResync, d), "ResyncReadAloudAfterLayout");
}

static void ReadAloudPrepareRestart(WindowTab* tab, MainWindow* win) {
    if (!tab) {
        return;
    }

    TtsProcessEvents();
    if (TtsIsSpeaking()) {
        TtsStop();
    }
    TtsProcessEvents();
    ReadAloudFinishSession(tab, win);
}

static bool ReadAloudSpeakChunk(WindowTab* tab, const char* errMsg) {
    if (!tab) {
        return false;
    }

    ReadAloudEnsureDocumentTextExtended(tab);

    if (str::IsEmpty(tab->readAloudText)) {
        if (!ReadAloudEnsureDocumentTextExtended(tab) || str::IsEmpty(tab->readAloudText)) {
            return false;
        }
    }

    int start = tab->readAloudChunkEnd;
    int textLen = str::Leni(tab->readAloudText);
    if (start >= textLen) {
        if (ReadAloudEnsureDocumentTextExtended(tab)) {
            textLen = str::Leni(tab->readAloudText);
        }
        if (start >= textLen) {
            return false;
        }
    }

    int end;
    ReadAloudLang rateLang = ReadAloudLang::Unknown;
    if (IsSmartBilingualVoicePref() && ResolveActiveSmartBilingualVoices()) {
        ReadAloudLang lang = ReadAloudLang::Unknown;
        if (ReadAloudSmartBilingualHasBothVoices()) {
            end = ReadAloudFindBilingualChunkEnd(tab->readAloudText, start, kReadAloudMaxChunkLen, &lang);
        } else {
            end = ReadAloudFindChunkEnd(tab->readAloudText, start, kReadAloudMaxChunkLen);
        }
        const char* wantVoice = ReadAloudSmartVoiceForLang(lang);
        if (wantVoice && !str::EqI(TtsGetVoiceId(), wantVoice)) {
            TtsSetVoiceById(wantVoice);
        }
        rateLang = lang;
    } else {
        end = ReadAloudFindChunkEnd(tab->readAloudText, start, kReadAloudMaxChunkLen);
        rateLang = ReadAloudLangFromVoiceId(TtsGetVoiceId());
    }
    ReadAloudApplyRateForLang(rateLang);
    if (start >= end) {
        return false;
    }

    int chunkLen = end - start;
    TempStr chunk = str::DupTemp(tab->readAloudText + start, (size_t)chunkLen);

    if (!TtsSpeakUtf8(chunk)) {
        ReadAloudShowNotif(tab, errMsg);
        return false;
    }

    tab->readAloudChunkStart = start;
    tab->readAloudChunkEnd = end;
    ToolbarUpdateStateForWindow(tab->win, true);
    InvalidateRect(tab->win->hwndCanvas, nullptr, FALSE);
    return true;
}

// Global speed menu sets Chinese or English rate separately.
static void ReadAloudSaveSpeakingRatesPrefs(float zhRate, float enRate) {
    if (!gGlobalPrefs) {
        return;
    }
    zhRate = ReadAloudClampSpeakingRate(zhRate);
    enRate = ReadAloudClampSpeakingRate(enRate);
    if (ReadAloudSpeakingRatesEqual(gGlobalPrefs->readAloudSpeakingRateZh, zhRate) &&
        ReadAloudSpeakingRatesEqual(gGlobalPrefs->readAloudSpeakingRateEn, enRate)) {
        return;
    }
    gGlobalPrefs->readAloudSpeakingRateZh = zhRate;
    gGlobalPrefs->readAloudSpeakingRateEn = enRate;
    // Keep legacy field in sync as a fallback average for older settings readers.
    gGlobalPrefs->readAloudSpeakingRate = (zhRate + enRate) / 2.0f;
    ReadAloudApplyRateForLang(ReadAloudLangFromVoiceId(TtsGetVoiceId()));
    SaveSettings();
    ReadAloudRestartSpeakingFromCurrentPosition(gReadAloudSourceTab);
}

void ReadAloudUpdateSpeakingRatesRealtime(float zhRate, float enRate) {
    if (!gGlobalPrefs) {
        return;
    }
    zhRate = ReadAloudClampSpeakingRate(zhRate);
    enRate = ReadAloudClampSpeakingRate(enRate);
    if (ReadAloudSpeakingRatesEqual(gGlobalPrefs->readAloudSpeakingRateZh, zhRate) &&
        ReadAloudSpeakingRatesEqual(gGlobalPrefs->readAloudSpeakingRateEn, enRate)) {
        return;
    }
    gGlobalPrefs->readAloudSpeakingRateZh = zhRate;
    gGlobalPrefs->readAloudSpeakingRateEn = enRate;
    gGlobalPrefs->readAloudSpeakingRate = (zhRate + enRate) / 2.0f;
    ReadAloudApplyRateForLang(ReadAloudLangFromVoiceId(TtsGetVoiceId()));
    SaveSettings();
    ReadAloudRestartSpeakingFromCurrentPosition(gReadAloudSourceTab);
}

static void ReadAloudSaveSpeakingRatePref(bool isZh, float rate) {
    float zhRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateZh);
    float enRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateEn);
    if (isZh) {
        zhRate = rate;
    } else {
        enRate = rate;
    }
    ReadAloudSaveSpeakingRatesPrefs(zhRate, enRate);
}

// Text cleanup for speech
static bool IsReadAloudLowerAscii(char c) {
    return c >= 'a' && c <= 'z';
}

static bool IsReadAloudLineBreak(char c) {
    return c == '\r' || c == '\n';
}

static bool IsReadAloudHorizontalSpace(char c) {
    return c == ' ' || c == '\t';
}

static bool ReadAloudAppendChar(char** dst, size_t* len, size_t* cap, char c) {
    if (!dst || !len || !cap) {
        return false;
    }

    if (*len + 1 >= *cap) {
        size_t newCap = (*cap == 0) ? 256 : (*cap * 2);
        char* newDst = (char*)realloc(*dst, newCap);
        if (!newDst) {
            free(*dst);
            *dst = nullptr;
            *len = 0;
            *cap = 0;
            return false;
        }

        *dst = newDst;
        *cap = newCap;
    }

    (*dst)[*len] = c;
    (*len)++;
    (*dst)[*len] = 0;
    return true;
}

static TempStr CleanReadAloudTextTemp(const char* text) {
    if (str::IsEmpty(text)) {
        return nullptr;
    }

    char* out = nullptr;
    size_t outLen = 0;
    size_t outCap = 0;

    const char* s = text;
    bool lastWasSpace = false;

    while (*s) {
        char c = *s;

        // Remove likely soft hyphenation caused by PDF line wrapping:
        // "cap-\nturing" -> "capturing"
        //
        // Conservative rule: only join lowercase ASCII on both sides.
        // This avoids damaging many intentional hyphen cases.
        if (c == '-' && IsReadAloudLineBreak(s[1])) {
            const char* after = s + 1;

            while (IsReadAloudLineBreak(*after)) {
                after++;
            }

            bool prevIsLower = s > text && IsReadAloudLowerAscii(s[-1]);
            bool nextIsLower = IsReadAloudLowerAscii(*after);

            if (prevIsLower && nextIsLower) {
                s = after;
                lastWasSpace = false;
                continue;
            }
        }

        // Convert extracted visual line breaks into spaces.
        if (IsReadAloudLineBreak(c)) {
            int lineBreaks = 0;

            while (IsReadAloudLineBreak(*s)) {
                if (*s == '\n') {
                    lineBreaks++;
                }
                s++;
            }

            while (IsReadAloudHorizontalSpace(*s)) {
                s++;
            }

            bool joinLine = false;
            if (lineBreaks == 1 && outLen > 0) {
                joinLine = ReadAloudShouldJoinAtWrappedLine(out, (int)outLen, s);
            }

            if (!joinLine && !lastWasSpace && outLen > 0) {
                if (!ReadAloudAppendChar(&out, &outLen, &outCap, ' ')) {
                    return nullptr;
                }
                lastWasSpace = true;
            } else if (joinLine) {
                lastWasSpace = false;
            }

            // Keep a slightly stronger pause for paragraph breaks.
            if (lineBreaks >= 2) {
                if (!ReadAloudAppendChar(&out, &outLen, &outCap, ' ')) {
                    return nullptr;
                }
            }

            continue;
        }

        // Collapse spaces and tabs.
        if (IsReadAloudHorizontalSpace(c)) {
            if (!lastWasSpace && outLen > 0) {
                if (!ReadAloudAppendChar(&out, &outLen, &outCap, ' ')) {
                    return nullptr;
                }
                lastWasSpace = true;
            }

            s++;
            continue;
        }

        if (!ReadAloudAppendChar(&out, &outLen, &outCap, c)) {
            return nullptr;
        }

        lastWasSpace = false;
        s++;
    }

    TempStr res = str::DupTemp(out);
    free(out);
    return res;
}

// Read-aloud lifetime and commands
static void ReadAloudSetSourceTab(WindowTab* tab) {
    gReadAloudSourceTab = tab;
}

static void ReadAloudClearSourceTab() {
    gReadAloudSourceTab = nullptr;
}

static void StopReadAloudIfSourceTab(WindowTab* tab) {
    if (!tab || gReadAloudSourceTab != tab) {
        return;
    }

    if (TtsIsSpeaking()) {
        TtsStop();
    }

    if (tab->win) {
        ReadAloudHighlightTimerStop(tab->win);
        InvalidateRect(tab->win->hwndCanvas, nullptr, FALSE);
    }
    ReadAloudClearSourceTab();
}

static void StopReadAloudIfSourceWindow(MainWindow* win) {
    if (!win || !gReadAloudSourceTab || gReadAloudSourceTab->win != win) {
        return;
    }

    if (TtsIsSpeaking()) {
        TtsStop();
    }

    ReadAloudClearSourceTab();
}

// reset "Continue reading" state, called when its document goes away
static void ResetReadAloudStateForTab(WindowTab* tab) {
    if (!tab) {
        return;
    }
    StopReadAloudIfSourceTab(tab);
    str::FreePtr(&tab->readAloudText);
    tab->readAloudResumePos = -1;
    if (tab->win) {
        ReadAloudHighlightTimerStop(tab->win);
    }
    if (tab->readAloudHighlight) {
        ReadAloudHighlightFree(tab->readAloudHighlight);
        delete tab->readAloudHighlight;
        tab->readAloudHighlight = nullptr;
    }
    tab->readAloudHighlightBase = 0;
    tab->readAloudChunkStart = 0;
    tab->readAloudChunkEnd = 0;
    tab->readAloudBuiltEndPage = 0;
    tab->readAloudStartPage = 0;
    tab->readAloudStartGlyph = 0;
    tab->readAloudAutoScroll = false;
    tab->readAloudAutoScrollHold = false;
    tab->readAloudAutoScrollHoldPageNo = -1;
    tab->readAloudAutoScrollHoldLineY = -1.f;
    tab->readAloudScope = 0;
    if (gReadAloudSessionTab == tab) {
        gReadAloudSessionTab = nullptr;
    }
}

// stop reading and remember where we stopped so that "Continue reading"
// can pick up from there
static void ReadAloudStopRememberPos() {
    // drain pending word-boundary events for an accurate position
    TtsProcessEvents();
    WindowTab* tab = gReadAloudSourceTab;
    if (tab && TtsIsSpeaking()) {
        int pos = TtsGetSpokenPosUtf8();
        if (pos >= 0) {
            int absPos = tab->readAloudHighlightBase + tab->readAloudChunkStart + pos;
            int maxPos = tab->readAloudHighlightBase + str::Leni(tab->readAloudText);
            if (absPos > 0 && absPos < maxPos) {
                tab->readAloudResumePos = absPos;
            }
        }
    }
    TtsStop();
    ReadAloudClearSourceTab();
    if (tab && tab->win) {
        ReadAloudHighlightTimerStop(tab->win);
        InvalidateRect(tab->win->hwndCanvas, nullptr, FALSE);
    }
}

static void ReadAloudPlaybackStop() {
    WindowTab* tab = gReadAloudSessionTab;
    if (!tab) {
        tab = GetReadAloudSourceTab();
    }
    if (!tab) {
        return;
    }
    if (TtsIsSpeaking()) {
        TtsStop();
    }
    ReadAloudFinishSession(tab, tab->win);
}

static void ReadAloudShowNotif(WindowTab* tab, const char* msg) {
    NotificationCreateArgs args;
    args.hwndParent = tab->win->hwndCanvas;
    args.msg = msg;
    args.timeoutMs = 2000;
    ShowNotification(args);
}

// remembers cleaned text on the tab and starts speaking it in TTS-sized chunks
static void ReadAloudStartText(WindowTab* tab, const char* cleaned, ReadAloudHighlightMap* newMap, int highlightBase,
                               const char* errMsg) {
    if (str::IsEmpty(cleaned)) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    if (newMap) {
        if (!tab->readAloudHighlight) {
            tab->readAloudHighlight = new ReadAloudHighlightMap{};
        }
        ReadAloudHighlightFree(tab->readAloudHighlight);
        if (newMap->len > 0 && newMap->locs) {
            *tab->readAloudHighlight = *newMap;
            newMap->locs = nullptr;
            newMap->len = 0;
            newMap->cap = 0;
        }
    } else if (highlightBase == 0 && tab->readAloudHighlight) {
        ReadAloudHighlightFree(tab->readAloudHighlight);
        delete tab->readAloudHighlight;
        tab->readAloudHighlight = nullptr;
    }

    str::ReplaceWithCopy(&tab->readAloudText, cleaned);
    tab->readAloudHighlightBase = highlightBase;
    tab->readAloudChunkStart = 0;
    tab->readAloudChunkEnd = 0;
    tab->readAloudResumePos = -1;
    tab->readAloudAutoScroll = true;
    tab->readAloudAutoScrollHold = true;
    tab->readAloudAutoScrollHoldPageNo = -1;
    tab->readAloudAutoScrollHoldLineY = -1.f;
    gReadAloudSessionTab = tab;
    ReadAloudSetSourceTab(tab);
    ReadAloudHighlightTimerStart(tab->win);

    if (!ReadAloudSpeakChunk(tab, errMsg)) {
        ReadAloudFinishSession(tab, tab->win);
        return;
    }
}

static void ReadAloudStartFromViewportTop(WindowTab* tab, const char* errMsg) {
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    int startPage = 0;
    int startGlyph = 0;
    if (!ReadAloudGetViewportStart(dm, &startPage, &startGlyph)) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    StrBuilder cleaned;
    ReadAloudHighlightMap map{};
    int pageCount = dm->PageCount();
    int endPage = startPage + kReadAloudBuildPagesPerBatch - 1;
    if (endPage > pageCount) {
        endPage = pageCount;
    }
    if (!ReadAloudHighlightBuildFromDocument(dm, startPage, startGlyph, endPage, &map, cleaned)) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    tab->readAloudBuiltEndPage = endPage;
    tab->readAloudStartPage = startPage;
    tab->readAloudStartGlyph = startGlyph;
    ReadAloudStartText(tab, cleaned.Get(), &map, 0, errMsg);
}

static void ReadAloudStartFromSelection(WindowTab* tab, const char* errMsg) {
    DisplayModel* dm = tab->AsFixed();
    if (!dm || dm->textSelection->result.len <= 0) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    StrBuilder cleaned;
    ReadAloudHighlightMap map{};
    if (!ReadAloudHighlightBuildFromTextSelection(dm->textSelection, &map, cleaned)) {
        bool isTextOnlySelection = false;
        TempStr text = GetSelectedTextTemp(tab, "\r\n", isTextOnlySelection);
        TempStr cleanedStr = CleanReadAloudTextTemp(text);
        tab->readAloudBuiltEndPage = 0;
        tab->readAloudStartPage = 0;
        tab->readAloudStartGlyph = 0;
        ReadAloudStartText(tab, cleanedStr, nullptr, 0, errMsg);
        return;
    }

    tab->readAloudBuiltEndPage = 0;
    tab->readAloudStartPage = 0;
    tab->readAloudStartGlyph = 0;
    ReadAloudStartText(tab, cleaned.Get(), &map, 0, errMsg);
}

static void ReadAloudInTab(WindowTab* tab) {
    if (!tab || !tab->win) {
        return;
    }

    if (!HasPermission(Perm::CopySelection)) {
        ReadAloudShowNotif(tab, _TRA("Copying text is not allowed"));
        return;
    }

    ReadAloudPrepareRestart(tab, tab->win);

    bool isTextOnlySelection = false;
    TempStr text = GetSelectedTextTemp(tab, "\r\n", isTextOnlySelection);

    if (!str::IsEmpty(text) && isTextOnlySelection) {
        tab->readAloudScope = WindowTab::ReadAloudScopeSmart;
        ReadAloudStartFromSelection(tab, _TRA("No text available to read aloud"));
    } else {
        tab->readAloudScope = WindowTab::ReadAloudScopeSmart;
        ReadAloudStartFromViewportTop(tab, _TRA("No text available to read aloud"));
    }
}

static void ReadAloudStartFromPageGlyph(WindowTab* tab, int startPage, int startGlyph, const char* errMsg) {
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    StrBuilder cleaned;
    ReadAloudHighlightMap map{};
    int pageCount = dm->PageCount();
    int endPage = startPage + kReadAloudBuildPagesPerBatch - 1;
    if (endPage > pageCount) {
        endPage = pageCount;
    }
    if (!ReadAloudHighlightBuildFromDocument(dm, startPage, startGlyph, endPage, &map, cleaned)) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    tab->readAloudBuiltEndPage = endPage;
    tab->readAloudStartPage = startPage;
    tab->readAloudStartGlyph = startGlyph;
    ReadAloudStartText(tab, cleaned.Get(), &map, 0, errMsg);
}

static void ReadAloudStartFromCursor(WindowTab* tab, Point screenPt, const char* errMsg) {
    DisplayModel* dm = tab->AsFixed();
    if (!dm) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    int startPage = 0;
    int startGlyph = 0;
    if (!ReadAloudGetCursorStart(dm, screenPt, &startPage, &startGlyph)) {
        ReadAloudShowNotif(tab, errMsg);
        return;
    }

    ReadAloudStartFromPageGlyph(tab, startPage, startGlyph, errMsg);
}

static void ReadAloudFromCursorInTab(WindowTab* tab, Point screenPt) {
    if (!tab || !tab->win) {
        return;
    }

    if (!HasPermission(Perm::CopySelection)) {
        return;
    }

    tab->readAloudScope = WindowTab::ReadAloudScopeCursor;
    ReadAloudStartFromCursor(tab, screenPt, _TRA("No text available to read aloud"));
}

static void ReadAloudFromViewportTopInTab(WindowTab* tab) {
    if (!tab || !tab->win) {
        return;
    }

    if (!HasPermission(Perm::CopySelection)) {
        return;
    }

    tab->readAloudScope = WindowTab::ReadAloudScopeViewport;
    ReadAloudStartFromViewportTop(tab, _TRA("No text available to read aloud"));
}

static void ReadAloudSelectionInTab(WindowTab* tab) {
    if (!tab || !tab->win) {
        return;
    }

    if (!HasPermission(Perm::CopySelection)) {
        return;
    }

    tab->readAloudScope = WindowTab::ReadAloudScopeSelection;
    ReadAloudStartFromSelection(tab, _TRA("No text available to read aloud"));
}

bool CanContinueReadAloud(WindowTab* tab) {
    if (!tab || str::IsEmpty(tab->readAloudText)) {
        return false;
    }
    int pos = tab->readAloudResumePos;
    int maxPos = tab->readAloudHighlightBase + str::Leni(tab->readAloudText);
    return pos > 0 && pos < maxPos;
}

void ReadAloudPauseRememberPos() {
    if (TtsIsSpeaking()) {
        ReadAloudStopRememberPos();
    }
}

void ReadAloudContinueInTab(WindowTab* tab) {
    if (!CanContinueReadAloud(tab) || !tab->win) {
        return;
    }

    int resumeInText = tab->readAloudResumePos - tab->readAloudHighlightBase;
    tab->readAloudChunkEnd = resumeInText;
    tab->readAloudChunkStart = resumeInText;
    tab->readAloudResumePos = -1;
    tab->readAloudAutoScroll = true;
    tab->readAloudAutoScrollHold = true;
    tab->readAloudAutoScrollHoldPageNo = -1;
    tab->readAloudAutoScrollHoldLineY = -1.f;
    ReadAloudSetSourceTab(tab);
    ReadAloudHighlightTimerStart(tab->win);

    if (!ReadAloudSpeakChunk(tab, _TRA("No text available to read aloud"))) {
        ReadAloudFinishSession(tab, tab->win);
        return;
    }
}

WindowTab* GetReadAloudSourceTab() {
    return gReadAloudSourceTab;
}

// toggle read-aloud from a double-click on an empty spot of the canvas:
// speaking => pause, paused => continue, otherwise start reading from the
// first text line at or below the clicked point
void ReadAloudToggleAtPoint(MainWindow* win, Point screenPt) {
    if (!win) {
        return;
    }
    WindowTab* tab = win->CurrentTab();
    if (!tab) {
        return;
    }

    if (TtsIsSpeaking()) {
        ReadAloudStopRememberPos();
        ToolbarUpdateStateForWindow(win, true);
        return;
    }
    if (CanContinueReadAloud(tab)) {
        ReadAloudContinueInTab(tab);
        return;
    }

    if (!HasPermission(Perm::CopySelection)) {
        ReadAloudShowNotif(tab, _TRA("Copying text is not allowed"));
        return;
    }

    SuspendFindEngineAccess(win);

    DisplayModel* dm = win->AsFixed();
    int startPage = 0;
    int startGlyph = 0;
    if (!dm || !ReadAloudGetStartBelowPoint(dm, screenPt, &startPage, &startGlyph)) {
        ReadAloudShowNotif(tab, _TRA("No text available to read aloud"));
        return;
    }

    ReadAloudPrepareRestart(tab, win);
    tab->readAloudScope = WindowTab::ReadAloudScopeCursor;
    ReadAloudStartFromPageGlyph(tab, startPage, startGlyph, _TRA("No text available to read aloud"));
}

// Voice selection menu
static TempStr TtsLangIdToLocaleNameTemp(const char* lang) {
    if (str::IsEmpty(lang)) {
        return str::DupTemp("unknown");
    }

    // Windows.Media.SpeechSynthesis voices report a locale name like "en-US",
    // SAPI voices a hex language id like "409"
    if (str::FindChar(lang, '-')) {
        return str::DupTemp(lang);
    }

    char* end = nullptr;
    unsigned long langId = strtoul(lang, &end, 16);
    if (end == lang || langId == 0) {
        return str::DupTemp(lang);
    }

    WCHAR localeName[LOCALE_NAME_MAX_LENGTH] = {};
    int len = LCIDToLocaleName((LCID)langId, localeName, dimof(localeName), 0);
    if (len <= 0) {
        return str::DupTemp(lang);
    }

    return ToUtf8Temp(localeName);
}

// appends voices of the given group, separated by language; command ids encode the
// index into the TtsGetVoices() vector so display order doesn't matter for selection.
// returns the number of items added, sets containsCurrent if the current voice is in this group
static int AppendTtsVoiceGroupItems(HMENU menu, Vec<TtsVoiceInfo>& voices, TtsVoiceGroup group,
                                    const char* currentVoiceId, bool* containsCurrent) {
    const char* lastLang = nullptr;
    int nAdded = 0;
    int idx = -1;
    for (TtsVoiceInfo& voice : voices) {
        idx++;
        UINT cmd = CmdTtsVoiceFirst + (UINT)idx;
        if (cmd > CmdTtsVoiceLast) {
            break;
        }
        if (GetTtsVoiceGroup(voice.name) != group) {
            continue;
        }

        const char* lang = str::IsEmpty(voice.lang) ? "" : voice.lang;
        if (lastLang && !str::EqI(lastLang, lang)) {
            AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
        }

        UINT flags = MF_STRING;
        if (str::Eq(voice.id, currentVoiceId)) {
            flags |= MF_CHECKED;
            if (containsCurrent) {
                *containsCurrent = true;
            }
        }

        TempStr localeName = TtsLangIdToLocaleNameTemp(voice.lang);
        TempStr label = str::FormatTemp("%s - %s", voice.name, localeName);
        AppendMenuW(menu, flags, cmd, ToWStrTemp(label));

        lastLang = lang;
        nAdded++;
    }
    return nAdded;
}

// appends an "online voices" style submenu; checked when the current voice is inside
static bool AppendTtsVoiceSubmenu(HMENU voiceMenu, Vec<TtsVoiceInfo>& voices, TtsVoiceGroup group,
                                  const char* currentVoiceId, const char* title, bool separatorBefore) {
    HMENU sub = CreatePopupMenu();
    if (!sub) {
        return false;
    }
    bool containsCurrent = false;
    int n = AppendTtsVoiceGroupItems(sub, voices, group, currentVoiceId, &containsCurrent);
    if (n == 0) {
        DestroyMenu(sub);
        return false;
    }
    RemoveBadMenuSeparators(sub);
    if (separatorBefore) {
        AppendMenuW(voiceMenu, MF_SEPARATOR, 0, nullptr);
    }
    UINT flags = MF_POPUP | MF_STRING;
    if (containsCurrent) {
        flags |= MF_CHECKED;
    }
    AppendMenuW(voiceMenu, flags, (UINT_PTR)sub, ToWStrTemp(title));
    return true;
}

static void BuildReadAloudVoiceMenuItems(HMENU voiceMenu) {
    if (!voiceMenu) {
        return;
    }

    // the saved pref, not the engine voice: in smart bilingual mode the engine
    // voice changes per chunk but the menu should show the smart mode as selected
    const char* currentVoiceId = TtsGetVoiceId();
    if (gGlobalPrefs && gGlobalPrefs->readAloudVoiceId) {
        currentVoiceId = gGlobalPrefs->readAloudVoiceId;
    }
    SmartBilingualKind activeSmartKind;
    bool isSmartBilingual = TryGetSmartBilingualKind(currentVoiceId, &activeSmartKind);

    UINT defaultFlags = MF_STRING;
    if (str::IsEmpty(currentVoiceId)) {
        defaultFlags |= MF_CHECKED;
    }

    AppendMenuW(voiceMenu, defaultFlags, CmdTtsVoiceDefault, ToWStrTemp(_TRA("System default")));

    Vec<TtsVoiceInfo> voices = TtsGetVoices();
    if (TtsSmartBilingualAvailable(SmartBilingualKind::Local)) {
        UINT smartFlags = MF_STRING;
        if (isSmartBilingual && activeSmartKind == SmartBilingualKind::Local) {
            smartFlags |= MF_CHECKED;
        }
        AppendMenuW(voiceMenu, smartFlags, CmdTtsVoiceSmartBilingual,
                    ToWStrTemp(_TRA("Local smart bilingual (English + Chinese)")));
        AppendMenuW(voiceMenu, MF_STRING, CmdTtsSmartBilingualSettings,
                    ToWStrTemp(_TRA("Local smart bilingual settings...")));
    }
    if (TtsSmartBilingualAvailable(SmartBilingualKind::Online)) {
        AppendMenuW(voiceMenu, MF_SEPARATOR, 0, nullptr);
        UINT smartFlags = MF_STRING;
        if (isSmartBilingual && activeSmartKind == SmartBilingualKind::Online) {
            smartFlags |= MF_CHECKED;
        }
        AppendMenuW(voiceMenu, smartFlags, CmdTtsVoiceSmartOnlineBilingual,
                    ToWStrTemp(_TRA("Online smart bilingual (English + Chinese)")));
        AppendMenuW(voiceMenu, MF_STRING, CmdTtsSmartOnlineBilingualSettings,
                    ToWStrTemp(_TRA("Online smart bilingual settings...")));
    }

    // individual online voices are chosen via online smart bilingual settings;
    // multilingual voices remain listed for direct selection
    AppendTtsVoiceSubmenu(voiceMenu, voices, TtsVoiceGroup::OnlineMultilingual, currentVoiceId,
                          _TRA("Online multilingual voices"), true);

    TtsFreeVoices(voices);
    RemoveBadMenuSeparators(voiceMenu);
}

static void AppendReadAloudSpeedPresetItems(HMENU menu, UINT cmdFirst, float currentRate) {
    for (int i = 0; i < (int)dimof(kReadAloudSpeedPresets); i++) {
        UINT flags = MF_STRING;
        if (ReadAloudSpeakingRatesEqual(currentRate, kReadAloudSpeedPresets[i])) {
            flags |= MF_CHECKED;
        }
        AppendMenuW(menu, flags, cmdFirst + (UINT)i, ToWStrTemp(ReadAloudSpeedPresetLabel(i)));
    }
}

static void BuildReadAloudSpeedMenuItems(HMENU speedMenu) {
    if (!speedMenu || !gGlobalPrefs) {
        return;
    }

    int presetCount = (int)dimof(kReadAloudSpeedPresets);
    ReportIf(presetCount != (int)(CmdTtsSpeedZhLast - CmdTtsSpeedZhFirst + 1));
    ReportIf(presetCount != (int)(CmdTtsSpeedEnLast - CmdTtsSpeedEnFirst + 1));

    float zhRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateZh);
    float enRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateEn);

    HMENU zhMenu = CreatePopupMenu();
    HMENU enMenu = CreatePopupMenu();
    if (!zhMenu || !enMenu) {
        if (zhMenu) {
            DestroyMenu(zhMenu);
        }
        if (enMenu) {
            DestroyMenu(enMenu);
        }
        return;
    }
    AppendReadAloudSpeedPresetItems(zhMenu, CmdTtsSpeedZhFirst, zhRate);
    AppendReadAloudSpeedPresetItems(enMenu, CmdTtsSpeedEnFirst, enRate);

    bool zhIsCustom = (ReadAloudSpeedPresetIndex(zhRate) < 0);
    bool enIsCustom = (ReadAloudSpeedPresetIndex(enRate) < 0);

    AppendMenuW(zhMenu, MF_SEPARATOR, 0, nullptr);
    UINT zhCustomFlags = MF_STRING | (zhIsCustom ? MF_CHECKED : 0);
    AppendMenuW(zhMenu, zhCustomFlags, CmdTtsSpeedZhCustom, ToWStrTemp(_TRA("Custom...")));
    AppendMenuW(enMenu, MF_SEPARATOR, 0, nullptr);
    UINT enCustomFlags = MF_STRING | (enIsCustom ? MF_CHECKED : 0);
    AppendMenuW(enMenu, enCustomFlags, CmdTtsSpeedEnCustom, ToWStrTemp(_TRA("Custom...")));

    TempStr zhTitle = str::FormatTemp("%s (%s)", _TRA("Chinese voice"), ReadAloudSpeedLabel(zhRate));
    TempStr enTitle = str::FormatTemp("%s (%s)", _TRA("English voice"), ReadAloudSpeedLabel(enRate));
    AppendMenuW(speedMenu, MF_POPUP | MF_STRING, (UINT_PTR)enMenu, ToWStrTemp(enTitle));
    AppendMenuW(speedMenu, MF_POPUP | MF_STRING, (UINT_PTR)zhMenu, ToWStrTemp(zhTitle));
}

static bool ReadAloudIsPointOnCanvas(MainWindow* win, Point pt) {
    if (!win || !win->hwndCanvas) {
        return false;
    }
    if (pt.x < 0 || pt.y < 0) {
        return false;
    }
    return ClientRect(win->hwndCanvas).Contains(pt);
}

static void ReadAloudUpdateMenuCursorPoint(MainWindow* win, bool useContextMenuCursorPoint) {
    if (!win) {
        return;
    }

    DisplayModel* dm = win->AsFixed();
    if (!dm) {
        win->contextMenuPtValid = false;
        return;
    }

    if (useContextMenuCursorPoint) {
        win->readAloudMenuFromContext = true;
        win->contextMenuPtValid = ReadAloudCanReadFromCursor(dm, win->contextMenuPt);
        return;
    }

    win->readAloudMenuFromContext = false;

    if (!win->hwndCanvas) {
        win->contextMenuPtValid = false;
        return;
    }

    // Opening the menu bar or toolbar dropdown usually leaves the mouse outside
    // the canvas, so prefer the last left-click on text, then last hover position.
    if (win->readAloudLastClickTextPtValid && ReadAloudCanReadFromCursor(dm, win->readAloudLastClickTextPt)) {
        win->contextMenuPt = win->readAloudLastClickTextPt;
        win->contextMenuPtValid = true;
        return;
    }

    if (win->readAloudLastTextPtValid && ReadAloudCanReadFromCursor(dm, win->readAloudLastTextPt)) {
        win->contextMenuPt = win->readAloudLastTextPt;
        win->contextMenuPtValid = true;
        return;
    }

    Point candidates[2] = {win->dragPrevPos, HwndGetCursorPos(win->hwndCanvas)};
    for (Point pt : candidates) {
        if (!ReadAloudIsPointOnCanvas(win, pt)) {
            continue;
        }
        if (ReadAloudCanReadFromCursor(dm, pt)) {
            win->contextMenuPt = pt;
            win->contextMenuPtValid = true;
            return;
        }
    }

    win->contextMenuPt =
        ReadAloudIsPointOnCanvas(win, win->dragPrevPos) ? win->dragPrevPos : HwndGetCursorPos(win->hwndCanvas);
    win->contextMenuPtValid = false;
}

static bool ReadAloudResolveReadFromCursorPoint(MainWindow* win, Point* ptOut) {
    if (!win || !ptOut) {
        return false;
    }

    DisplayModel* dm = win->AsFixed();
    if (!dm) {
        return false;
    }

    if (win->readAloudMenuFromContext && win->contextMenuPtValid &&
        ReadAloudCanReadFromCursor(dm, win->contextMenuPt)) {
        *ptOut = win->contextMenuPt;
        return true;
    }

    if (win->readAloudLastClickTextPtValid && ReadAloudCanReadFromCursor(dm, win->readAloudLastClickTextPt)) {
        *ptOut = win->readAloudLastClickTextPt;
        return true;
    }

    ReadAloudUpdateMenuCursorPoint(win, false);
    if (!win->contextMenuPtValid) {
        return false;
    }

    *ptOut = win->contextMenuPt;
    return true;
}

static void BuildReadAloudMenuItems(HMENU menu, MainWindow* win, bool useContextMenuCursorPoint) {
    if (win) {
        ReadAloudUpdateMenuCursorPoint(win, useContextMenuCursorPoint);
    }

    WindowTab* currTab = win ? win->CurrentTab() : nullptr;
    bool isSpeaking = TtsIsSpeaking();
    bool canContinue = CanContinueReadAloud(currTab);
    bool hasSelection =
        currTab && win->showSelection && currTab->selectionOnPage && currTab->selectionOnPage->size() > 0;
    bool canReadFromCursor = win && win->contextMenuPtValid;

    if (isSpeaking) {
        AppendMenuW(menu, MF_STRING, CmdTtsMenuPauseReading, ToWStrTemp(_TRA("Pause Reading")));
        AppendMenuW(menu, MF_STRING, CmdTtsMenuStopReading, ToWStrTemp(_TRA("Stop Reading")));
        AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
    } else if (canContinue) {
        AppendMenuW(menu, MF_STRING, CmdTtsMenuContinueReading, ToWStrTemp(_TRA("Continue Reading")));
        AppendMenuW(menu, MF_STRING, CmdTtsMenuStopReading, ToWStrTemp(_TRA("Stop Reading")));
        AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
    }
    AppendMenuW(menu, MF_STRING, CmdTtsMenuReadCurrentPage, ToWStrTemp(_TRA("Start Reading From Top")));
    AppendMenuW(menu, canReadFromCursor ? MF_STRING : MF_STRING | MF_GRAYED, CmdTtsMenuReadFromCursor,
                ToWStrTemp(_TRA("Start Reading From Cursor Position")));
    AppendMenuW(menu, hasSelection ? MF_STRING : MF_STRING | MF_GRAYED, CmdTtsMenuReadSelection,
                ToWStrTemp(_TRA("Start Reading Selection")));
    AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);

    HMENU voiceMenu = CreatePopupMenu();
    if (voiceMenu) {
        BuildReadAloudVoiceMenuItems(voiceMenu);
        AppendMenuW(menu, MF_POPUP | MF_STRING, (UINT_PTR)voiceMenu, ToWStrTemp(_TRA("Voice")));
    }

    HMENU speedMenu = CreatePopupMenu();
    if (speedMenu) {
        BuildReadAloudSpeedMenuItems(speedMenu);
        AppendMenuW(menu, MF_POPUP | MF_STRING, (UINT_PTR)speedMenu, ToWStrTemp(_TRA("Speed")));
    }
}

void RebuildReadAloudMenu(MainWindow* win, HMENU menu, bool useContextMenuCursorPoint) {
    if (!menu || !win) {
        return;
    }
    MenuEmpty(menu);
    BuildReadAloudMenuItems(menu, win, useContextMenuCursorPoint);
    RemoveBadMenuSeparators(menu);
    // Voice and Speed are created dynamically after the menubar was marked.
    // Mark this rebuilt tree too, otherwise nested popups use the system
    // white background and selection color instead of the active theme.
    MarkMenuOwnerDraw(menu, false, true);
}

static bool HandleReadAloudMenuSelection(MainWindow* win, UINT selected) {
    if (!win || selected == 0) {
        return false;
    }

    WindowTab* currTab = win->CurrentTab();

    if (selected == CmdTtsMenuPauseReading) {
        ReadAloudStopRememberPos();
        ToolbarUpdateStateForWindow(win, true);
    } else if (selected == CmdTtsMenuStopReading) {
        ReadAloudPlaybackStop();
    } else if (selected == CmdTtsMenuReadCurrentPage) {
        if (currTab) {
            ReadAloudPrepareRestart(currTab, win);
            ReadAloudFromViewportTopInTab(currTab);
        }
    } else if (selected == CmdTtsMenuReadFromCursor) {
        if (currTab) {
            Point readPt;
            if (ReadAloudResolveReadFromCursorPoint(win, &readPt)) {
                ReadAloudPrepareRestart(currTab, win);
                ReadAloudFromCursorInTab(currTab, readPt);
            }
        }
    } else if (selected == CmdTtsMenuContinueReading) {
        if (TtsIsSpeaking()) {
            TtsStop();
        }
        ReadAloudContinueInTab(currTab);
    } else if (selected == CmdTtsMenuReadSelection) {
        if (currTab) {
            ReadAloudPrepareRestart(currTab, win);
            ReadAloudSelectionInTab(currTab);
        }
    } else if (selected == CmdTtsVoiceDefault) {
        ReadAloudSaveVoicePref("");
    } else if (selected == CmdTtsVoiceSmartBilingual) {
        ReadAloudSaveVoicePref(kTtsSmartBilingualVoiceId);
    } else if (selected == CmdTtsSmartBilingualSettings) {
        ShowReadAloudSmartVoiceDialog(win, SmartBilingualKind::Local);
    } else if (selected == CmdTtsVoiceSmartOnlineBilingual) {
        ReadAloudSaveVoicePref(kTtsSmartOnlineBilingualVoiceId);
    } else if (selected == CmdTtsSmartOnlineBilingualSettings) {
        ShowReadAloudSmartVoiceDialog(win, SmartBilingualKind::Online);
    } else if (selected >= CmdTtsVoiceFirst && selected <= CmdTtsVoiceLast) {
        Vec<TtsVoiceInfo> voices = TtsGetVoices();
        int voiceIndex = (int)(selected - CmdTtsVoiceFirst);
        if (voiceIndex >= 0 && voiceIndex < voices.Size()) {
            ReadAloudSaveVoicePref(voices[voiceIndex].id);
        }
        TtsFreeVoices(voices);
    } else if (selected >= CmdTtsSpeedZhFirst && selected <= CmdTtsSpeedZhLast) {
        int speedIndex = (int)(selected - CmdTtsSpeedZhFirst);
        if (speedIndex >= 0 && speedIndex < (int)dimof(kReadAloudSpeedPresets)) {
            ReadAloudSaveSpeakingRatePref(true, kReadAloudSpeedPresets[speedIndex]);
        }
    } else if (selected >= CmdTtsSpeedEnFirst && selected <= CmdTtsSpeedEnLast) {
        int speedIndex = (int)(selected - CmdTtsSpeedEnFirst);
        if (speedIndex >= 0 && speedIndex < (int)dimof(kReadAloudSpeedPresets)) {
            ReadAloudSaveSpeakingRatePref(false, kReadAloudSpeedPresets[speedIndex]);
        }
    } else if (selected == CmdTtsSpeedZhCustom || selected == CmdTtsSpeedEnCustom) {
        float zhRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateZh);
        float enRate = ReadAloudClampSpeakingRate(gGlobalPrefs->readAloudSpeakingRateEn);
        bool focusChinese = selected == CmdTtsSpeedZhCustom;
        if (Dialog_ReadAloudSpeed(win->hwndFrame, &enRate, &zhRate, focusChinese)) {
            ReadAloudSaveSpeakingRatesPrefs(zhRate, enRate);
        }
    }
    return false;
}

static void ShowReadAloudPopupMenuAt(MainWindow* win, int x, int y) {
    if (!win) {
        return;
    }

    HMENU menu = CreatePopupMenu();
    if (!menu) {
        return;
    }

    BuildReadAloudMenuItems(menu, win, false);
    MarkMenuOwnerDraw(menu, false);

    SetForegroundWindow(win->hwndFrame);
    UINT selected =
        (UINT)TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, x, y, 0, win->hwndFrame, nullptr);

    FreeMenuOwnerDrawInfoData(menu);
    DestroyMenu(menu);

    if (selected == 0) {
        return;
    }
    HandleReadAloudMenuSelection(win, selected);
}

bool HandleReadAloudMenuCommand(MainWindow* win, int cmdId) {
    if (cmdId == CmdTtsVoiceDefault || cmdId == CmdTtsVoiceSmartBilingual || cmdId == CmdTtsSmartBilingualSettings ||
        cmdId == CmdTtsVoiceSmartOnlineBilingual || cmdId == CmdTtsSmartOnlineBilingualSettings ||
        (cmdId >= CmdTtsMenuReadCurrentPage && cmdId <= CmdTtsMenuStopReading) ||
        (cmdId >= CmdTtsVoiceFirst && cmdId <= CmdTtsVoiceLast) ||
        (cmdId >= CmdTtsSpeedZhFirst && cmdId <= CmdTtsSpeedZhLast) ||
        (cmdId >= CmdTtsSpeedEnFirst && cmdId <= CmdTtsSpeedEnLast) || cmdId == CmdTtsSpeedZhCustom ||
        cmdId == CmdTtsSpeedEnCustom) {
        HandleReadAloudMenuSelection(win, (UINT)cmdId);
        return true;
    }
    return false;
}

static void ShowTtsVoiceMenu(MainWindow* win, NMTOOLBARW* nmtb) {
    if (!win || !nmtb || nmtb->iItem != CmdReadAloud) {
        return;
    }

    RECT rc{};
    SendMessageW(nmtb->hdr.hwndFrom, TB_GETRECT, CmdReadAloud, (LPARAM)&rc);
    MapWindowPoints(nmtb->hdr.hwndFrom, HWND_DESKTOP, (POINT*)&rc, 2);

    ShowReadAloudPopupMenuAt(win, rc.left, rc.bottom);
}

static int DpiForSidebarHwnd(MainWindow* win, HWND hwnd) {
    (void)hwnd;
    if (win && win->frameDpi > 0) {
        return RoundUp(win->frameDpi, 4);
    }
    if (win && win->hwndFrame) {
        return DpiGet(win->hwndFrame);
    }
    return 96;
}

static HWND SidebarDpiHwnd(HWND hwndBox, TreeView* treeView) {
    if (hwndBox) {
        return hwndBox;
    }
    return treeView ? treeView->hwnd : nullptr;
}

static void RelayoutSidebarContainers(MainWindow* win) {
    if (win->tocVisible && win->hwndTocBox) {
        SendMessageW(win->hwndTocBox, WM_SIZE, 0, 0);
    }
    if (win->hwndFavBox && IsWindowVisible(win->hwndFavBox)) {
        SendMessageW(win->hwndFavBox, WM_SIZE, 0, 0);
    }
}

static void ApplyTreeFontForDpi(HWND hwndTree, HFONT treeFont, int dpi) {
    if (!hwndTree) {
        return;
    }
    // Do not force the light "Explorer" theme here: that resets TOC/favorites
    // scrollbars to white under dark chrome. RefreshSidebarDpiFonts re-applies
    // the correct theme via UpdateControlsColors afterwards.
    HwndSetTreeFontForDpi(hwndTree, treeFont, dpi);
}

static bool ApplyDpiFontsToSidebar(MainWindow* win) {
    int tocDpi = DpiForSidebarHwnd(win, SidebarDpiHwnd(win->hwndTocBox, win->tocTreeView));
    int favDpi = DpiForSidebarHwnd(win, SidebarDpiHwnd(win->hwndFavBox, win->favTreeView));
    int frameDpi = win->frameDpi > 0 ? win->frameDpi : DpiGet(win->hwndFrame);
    HFONT homeSearchFont = GetAppMenuFontForDpi(frameDpi);

    bool tocDpiNeedsApply = win->tocDpiRecreatePending || win->tocSidebarDpi <= 0 || win->tocSidebarDpi != tocDpi;
    bool favDpiNeedsApply = win->favDpiRecreatePending || win->favSidebarDpi <= 0 || win->favSidebarDpi != favDpi;
    bool homeSearchNeedsApply = win->hwndHomeSearch && HwndGetFont(win->hwndHomeSearch) != homeSearchFont;
    if (!tocDpiNeedsApply && !favDpiNeedsApply && !homeSearchNeedsApply) {
        return false;
    }

    HFONT tocTreeFont = GetAppTreeFontForDpi(tocDpi);
    HFONT tocLabelFont = GetAppSidebarLabelFontForDpi(tocDpi);
    HFONT favTreeFont = GetAppTreeFontForDpi(favDpi);
    HFONT favLabelFont = GetAppSidebarLabelFontForDpi(favDpi);

    bool tocDpiChanged = win->tocDpiRecreatePending || (win->tocSidebarDpi > 0 && win->tocSidebarDpi != tocDpi);
    bool favDpiChanged = win->favDpiRecreatePending || (win->favSidebarDpi > 0 && win->favSidebarDpi != favDpi);
    bool didApply = false;

    if (tocDpiChanged && win->tocFilterEdit) {
        ReCreateTocFilterEdit(win, tocTreeFont);
        didApply = true;
    }
    if (win->tocTreeView && win->tocTreeView->hwnd) {
        if (tocDpiChanged) {
            ReCreateTocTreeView(win, tocTreeFont, tocDpi);
            didApply = true;
        } else if (tocDpiNeedsApply) {
            ApplyTreeFontForDpi(win->tocTreeView->hwnd, tocTreeFont, tocDpi);
            didApply = true;
        }
        win->tocSidebarDpi = tocDpi;
        win->tocDpiRecreatePending = false;
    }
    if (win->favTreeView && win->favTreeView->hwnd) {
        if (favDpiChanged) {
            ReCreateFavTreeView(win, favTreeFont, favDpi);
            didApply = true;
        } else if (favDpiNeedsApply) {
            ApplyTreeFontForDpi(win->favTreeView->hwnd, favTreeFont, favDpi);
            didApply = true;
        }
        win->favSidebarDpi = favDpi;
        win->favDpiRecreatePending = false;
    }
    if (tocDpiNeedsApply && win->tocLabelWithClose) {
        win->tocLabelWithClose->SetFont(tocLabelFont);
        win->tocLabelWithClose->Layout();
        didApply = true;
    }
    if (favDpiNeedsApply && win->favLabelWithClose) {
        win->favLabelWithClose->SetFont(favLabelFont);
        win->favLabelWithClose->Layout();
        didApply = true;
    }
    if (tocDpiNeedsApply && win->tocFilterEdit && !tocDpiChanged) {
        win->tocFilterEdit->SetFont(tocTreeFont);
        HwndSetFont(win->tocFilterEdit->hwnd, tocTreeFont);
        didApply = true;
    }
    if (homeSearchNeedsApply) {
        HwndSetFont(win->hwndHomeSearch, homeSearchFont);
        didApply = true;
    }
    return didApply;
}

static bool RefreshSidebarDpiFonts(MainWindow* win) {
    if (!ApplyDpiFontsToSidebar(win)) {
        return false;
    }
    // ApplyDpiFontsToSidebar may recreate or restyle the tree; restore TOC/fav
    // dark-mode scrollbar theme (same path as LoadTocTree / theme toggle).
    UpdateControlsColors(win);
    RelayoutSidebarContainers(win);
    if (win->hwndTocBox && IsWindowVisible(win->hwndTocBox)) {
        RedrawWindow(win->hwndTocBox, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
    }
    if (win->hwndFavBox && IsWindowVisible(win->hwndFavBox)) {
        RedrawWindow(win->hwndFavBox, nullptr, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN);
    }
    return true;
}

// InvalidateUiFonts() deletes shared HFONTs used by all windows. After a DPI
// refresh on one window (e.g. ShowMainWindow for a tab dragged to a new
// window), other windows must rebuild chrome controls that still reference
// the old font handles.
static void RefreshWindowChromeAfterFontInvalidate(MainWindow* win) {
    if (!win || !win->hwndFrame) {
        return;
    }

    bool menuRebarVisible = IsShowingMenuBarRebar(win);
    if (menuRebarVisible) {
        DestroyMenuBarRebar(win);
    }

    ReCreateToolbar(win);

    if (menuRebarVisible && IsMenubarVisible()) {
        CreateMenuBarRebar(win);
        ShowMenuBarRebar(win);
    }

    if (win->tabsCtrl) {
        win->tabsCtrl->SetFont(GetAppFontForHwnd(win->hwndFrame));
        win->tabsCtrl->LayoutTabs();
    }

    RefreshSidebarDpiFonts(win);
    RelayoutWindow(win);
    win->RedrawAll(true);
}

static void RefreshOtherWindowsChromeAfterFontInvalidate(MainWindow* exceptWin) {
    for (MainWindow* other : gWindows) {
        if (other != exceptWin) {
            RefreshWindowChromeAfterFontInvalidate(other);
        }
    }
}

static void ApplySidebarDpiMovePreview(MainWindow* win) {
    int dpi = win->frameDpi > 0 ? win->frameDpi : DpiGet(win->hwndFrame);
    HFONT treeFont = GetAppTreeFontForDpi(dpi);
    HFONT labelFont = GetAppSidebarLabelFontForDpi(dpi);

    if (win->tocTreeView && win->tocTreeView->hwnd) {
        HwndSetTreeFontForDpi(win->tocTreeView->hwnd, treeFont, dpi);
        win->tocDpiRecreatePending = true;
    }
    if (win->favTreeView && win->favTreeView->hwnd) {
        HwndSetTreeFontForDpi(win->favTreeView->hwnd, treeFont, dpi);
        win->favDpiRecreatePending = true;
    }
    if (win->tocLabelWithClose) {
        win->tocLabelWithClose->SetFont(labelFont);
        win->tocLabelWithClose->Layout();
    }
    if (win->favLabelWithClose) {
        win->favLabelWithClose->SetFont(labelFont);
        win->favLabelWithClose->Layout();
    }
    if (win->tocFilterEdit) {
        win->tocFilterEdit->SetFont(treeFont);
        HwndSetFont(win->tocFilterEdit->hwnd, treeFont);
    }
    if (win->hwndHomeSearch) {
        HwndSetFont(win->hwndHomeSearch, GetAppMenuFontForDpi(dpi));
    }
    RelayoutSidebarContainers(win);
}

static void ApplyMainWindowDpiMovePreview(MainWindow* win, HWND hwnd) {
    HideSelectionToolbar(win);

    bool menuRebarVisible = IsShowingMenuBarRebar(win);
    if (menuRebarVisible) {
        DestroyMenuBarRebar(win);
    }
    ReCreateToolbar(win);
    if (menuRebarVisible && IsMenubarVisible()) {
        CreateMenuBarRebar(win);
        ShowMenuBarRebar(win);
    }

    if (win->tabsCtrl) {
        win->tabsCtrl->SetFont(GetAppFontForDpi(win->frameDpi));
        win->tabsCtrl->LayoutTabs();
    }
    ApplySidebarDpiMovePreview(win);

    CloseEditAnnotationsWindowsForDpiMove(win);
    CloseEbookAnnotationsWindowsForDpiMove(win);

    win->lastLayoutState = {};
    RelayoutCaption(win);
    RelayoutFrame(win);
    UpdateOverlayScrollbarPositions(win);

    if (win->tabsInTitlebar && !win->captionRect.IsEmpty()) {
        RECT r = ToRECT(win->captionRect);
        RedrawWindow(hwnd, &r, nullptr, RDW_ERASE | RDW_INVALIDATE);
    }
}

static void ApplyMainWindowDpiChromeRefresh(MainWindow* win, HWND hwnd) {
    if (!win || !hwnd) {
        return;
    }

    logf("ApplyMainWindowDpiChromeRefresh: dpi=%d\n", win->frameDpi);

    InvalidateUiFonts();
    HideSelectionToolbar(win);

    bool menuRebarVisible = IsShowingMenuBarRebar(win);
    if (menuRebarVisible) {
        DestroyMenuBarRebar(win);
    }

    ReCreateToolbar(win);

    if (menuRebarVisible && IsMenubarVisible()) {
        CreateMenuBarRebar(win);
        ShowMenuBarRebar(win);
    }

    if (win->tabsCtrl) {
        win->tabsCtrl->SetFont(GetAppFontForHwnd(hwnd));
        win->tabsCtrl->LayoutTabs();
    }

    win->lastLayoutState = {};
    RelayoutCaption(win);
    RelayoutFrame(win);
    RefreshSidebarDpiFonts(win);
    UpdateOverlayScrollbarPositions(win);
    FindBarReposition(win);
    FindWindowReposition(win);

    if (win->tabsInTitlebar && !win->captionRect.IsEmpty()) {
        RECT r = ToRECT(win->captionRect);
        RedrawWindow(hwnd, &r, nullptr, RDW_ERASE | RDW_INVALIDATE | RDW_UPDATENOW);
    }

    win->RedrawAll(true);

    RefreshOtherWindowsChromeAfterFontInvalidate(win);
    CloseEditAnnotationsWindowsForDpiMove(win);
    CloseEbookAnnotationsWindowsForDpiMove(win);
    ReopenEditAnnotationsWindowsAfterDpiMove(win);
    ReopenEbookAnnotationsWindowsAfterDpiMove(win);

    WindowTab* currentTab = win->CurrentTab();
    if (currentTab) {
        ResyncReadAloudAfterLayoutChange(currentTab, win);
    }
    for (WindowTab* tab : win->Tabs()) {
        if (tab != currentTab) {
            ScheduleReadAloudResyncAfterLayoutChange(win, tab);
        }
    }
}

static void OnMainWindowDpiChanged(MainWindow* win, HWND hwnd, const RECT* suggestedRect, int explicitDpi, bool force) {
    int dpi;
    if (explicitDpi > 0) {
        // Trust WM_DPICHANGED wParam; monitor queries can lag during cross-monitor drags.
        dpi = RoundUp(explicitDpi, 4);
    } else {
        dpi = DpiGet(hwnd);
    }
    bool dpiChanged = dpi != win->frameDpi;
    if (suggestedRect) {
        SetWindowPos(hwnd, nullptr, suggestedRect->left, suggestedRect->top, suggestedRect->right - suggestedRect->left,
                     suggestedRect->bottom - suggestedRect->top, SWP_NOZORDER | SWP_NOACTIVATE);
    }

    if (!force && dpi == win->frameDpi && !suggestedRect) {
        return;
    }
    win->frameDpi = dpi;
    logf("OnMainWindowDpiChanged: dpi=%d\n", dpi);
    if (win->deferDpiChromeRefresh && !force) {
        win->dpiChromeRefreshPending = true;
        if (dpiChanged) {
            ApplyMainWindowDpiMovePreview(win, hwnd);
        }
        logf("OnMainWindowDpiChanged: applied move preview; deferred full refresh\n");
        return;
    }
    ApplyMainWindowDpiChromeRefresh(win, hwnd);
}

static void FinishDeferredMainWindowDpiRefresh(MainWindow* win, HWND hwnd) {
    if (!win) {
        return;
    }
    win->deferDpiChromeRefresh = false;
    if (!win->dpiChromeRefreshPending) {
        return;
    }
    win->dpiChromeRefreshPending = false;

    // Keep the DPI chosen by Windows' last WM_DPICHANGED. Re-selecting a
    // monitor here would introduce a second, app-specific crossing threshold.
    int dpi = win->frameDpi > 0 ? win->frameDpi : DpiGet(hwnd);
    OnMainWindowDpiChanged(win, hwnd, nullptr, dpi, true);
}

LRESULT CALLBACK WndProcSumatraFrame(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
    MainWindow* win = FindMainWindowByHwnd(hwnd);

    // DbgLogMsg("frame:", hwnd, msg, wp, lp);
    // detect when an external host (e.g. Total Commander's lister) embeds us
    // by reparenting our window as WS_CHILD
    bool isChildWindow = IsWindowStyleSet(hwnd, WS_CHILD);
    if (win && !gMyWindowWasEmbedded && isChildWindow) {
        logf("Detected window embedded in another window\n");
        gMyWindowWasEmbedded = true;
        str::ReplaceWithCopy(&gGlobalPrefs->scrollbars, "windows");
        SetTabsInTitlebar(win, false);
        DestroyMenuBarRebar(win);
        SetMenu(hwnd, nullptr);
        UpdateTabWidth(win);
        RelayoutWindow(win);
    }
    if (win && win->tabsInTitlebar) {
        bool callDefault = true;
        LRESULT res = CustomCaptionFrameProc(hwnd, msg, wp, lp, &callDefault, win);
        if (!callDefault) {
            return res;
        }
    }

    LRESULT res = TryReflectMessages(hwnd, msg, wp, lp);
    if (res) {
        return res;
    }

    switch (msg) {
        case WM_CREATE:
            TtsSetNotifyWindow(hwnd, WM_TTS_EVENT, 0, 0);
            goto InitMouseWheelInfo;

        case WM_ENTERSIZEMOVE:
            if (win) {
                win->deferDpiChromeRefresh = true;
                win->dpiChromeRefreshPending = false;
                CloseEditAnnotationsWindowsForDpiMove(win);
                CloseEbookAnnotationsWindowsForDpiMove(win);
            }
            return 0;

        case WM_EXITSIZEMOVE:
            if (win) {
                if (win->dpiChromeRefreshPending) {
                    if (!PostMessageW(hwnd, WM_MAIN_WINDOW_DPI_SETTLED, 0, 0)) {
                        FinishDeferredMainWindowDpiRefresh(win, hwnd);
                    }
                } else {
                    win->deferDpiChromeRefresh = false;
                    ReopenEditAnnotationsWindowsAfterDpiMove(win);
                    ReopenEbookAnnotationsWindowsAfterDpiMove(win);
                }
                DockOpenEditAnnotationsWindows(win);
                DockOpenEbookAnnotationsWindows(win);
            }
            return 0;

        case WM_MAIN_WINDOW_DPI_SETTLED:
            FinishDeferredMainWindowDpiRefresh(win, hwnd);
            return 0;

        case WM_DPICHANGED:
            if (win) {
                int dpi = LOWORD(wp);
                OnMainWindowDpiChanged(win, hwnd, (RECT*)lp, dpi);
            }
            return 0;

        case WM_DISPLAYCHANGE:
            if (win) {
                OnMainWindowDpiChanged(win, hwnd, nullptr);
            }
            break;

        case WM_SIZE:
            if (win && SIZE_MINIMIZED != wp) {
                RememberDefaultWindowPosition(win);
                int dx = LOWORD(lp);
                int dy = HIWORD(lp);
                // dbglog::LogF("dx: %d, dy: %d", dx, dy);
                FrameOnSize(win, dx, dy);
                DockOpenEditAnnotationsWindows(win);
                DockOpenEbookAnnotationsWindows(win);
            }
            break;

        case WM_GETMINMAXINFO:
            return OnFrameGetMinMaxInfo((MINMAXINFO*)lp);

        case WM_MOVE:
            if (win) {
                RememberDefaultWindowPosition(win);
                UpdateOverlayScrollbarPositions(win);
                DockOpenEditAnnotationsWindows(win);
                DockOpenEbookAnnotationsWindows(win);
                // keep the floating find bar anchored over the search icon
                FindBarReposition(win);
                FindWindowReposition(win);
            }
            break;

        case WM_INITMENUPOPUP:
            // apply dark mode to popup menu window
            if (UseDarkModeLib() && DarkMode::isEnabled()) {
                HWND hMenuWnd = FindWindow(UNDOCUMENTED_MENU_CLASS_NAME, nullptr);
                if (hMenuWnd) {
                    DarkMode::setDarkTitleBarEx(hMenuWnd, false);
                }
            }
            // TODO: should I just build the menu from scratch every time?
            if (win) {
                UpdateAppMenu(win, (HMENU)wp);
            }
            if (ThemeColorizeControls() || ThemeUsesDarkChrome()) {
                HMENU hPopup = (HMENU)wp;
                MENUITEMINFOW mii{};
                mii.cbSize = sizeof(mii);
                mii.fMask = MIIM_FTYPE;
                int n = GetMenuItemCount(hPopup);
                if (n <= 0 || !GetMenuItemInfoW(hPopup, 0, TRUE, &mii) || !(mii.fType & MFT_OWNERDRAW)) {
                    MarkMenuOwnerDraw(hPopup, false);
                }
            }
            break;

        case WM_HOTKEY:
            if (wp == kScreenshotHotkeyId) {
                TakeScreenshots();
                return 0;
            }
            break;

        case WM_NOTIFY: {
            NMHDR* hdr = (NMHDR*)lp;
            if (win && hdr && hdr->hwndFrom == win->hwndToolbar && hdr->code == TBN_DROPDOWN) {
                ShowTtsVoiceMenu(win, (NMTOOLBARW*)lp);
                return TBDDRET_DEFAULT;
            }
            break;
        }

        case WM_COMMAND:
            return FrameOnCommand(win, hwnd, msg, wp, lp);

        case WM_MEASUREITEM:
            if (ShouldOwnerDrawMenus()) {
                MenuCustomDrawMesureItem(hwnd, (MEASUREITEMSTRUCT*)lp);
                return TRUE;
            }
            break;

        case WM_DRAWITEM:
            if (ShouldOwnerDrawMenus()) {
                MenuCustomDrawItem(hwnd, (DRAWITEMSTRUCT*)lp);
                return TRUE;
            }
            break;

        case WM_ACTIVATE:
            if (wp != WA_INACTIVE) {
                gLastActiveFrameHwnd = hwnd;
            }
            break;

        case WM_APPCOMMAND:
            // both keyboard and mouse drivers should produce WM_APPCOMMAND
            // messages for their special keys, so handle these here and return
            // TRUE so as to not make them bubble up further
            switch (GET_APPCOMMAND_LPARAM(lp)) {
                case APPCOMMAND_BROWSER_BACKWARD:
                    HwndSendCommand(hwnd, CmdNavigateBack);
                    return TRUE;
                case APPCOMMAND_BROWSER_FORWARD:
                    HwndSendCommand(hwnd, CmdNavigateForward);
                    return TRUE;
                case APPCOMMAND_BROWSER_REFRESH:
                    HwndSendCommand(hwnd, CmdReloadDocument);
                    return TRUE;
                case APPCOMMAND_BROWSER_SEARCH:
                    HwndSendCommand(hwnd, CmdFindFirst);
                    return TRUE;
                case APPCOMMAND_BROWSER_FAVORITES:
                    HwndSendCommand(hwnd, CmdToggleBookmarks);
                    return TRUE;
            }
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_CHAR:
            if (win && !win->isBeingClosed) {
                FrameOnChar(win, wp, lp);
            }
            break;

        case WM_KEYDOWN:
            if (win && !win->isBeingClosed) {
                FrameOnKeydown(win, wp, lp);
            }
            break;

        case WM_SYSKEYUP:
            // pressing and releasing the Alt key focuses the menu even if
            // the wheel has been used for scrolling horizontally, so we
            // have to suppress that effect explicitly in this situation
            if (VK_MENU == wp && gSupressNextAltMenuTrigger) {
                gSupressNextAltMenuTrigger = false;
                return 0;
            }
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_SYSCHAR:
            if (win && FrameOnSysChar(win, wp)) {
                return 0;
            }
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_MENUSELECT:
            if (win) {
                UpdateCustomMenuBarMenuSelect(win, wp, lp);
            }
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_SYSCOMMAND:
            // temporarily show the menu bar if it has been hidden
            if (wp == SC_KEYMENU && win && !IsMenubarVisible()) {
                ToggleMenuBar(win, true);
            }
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_ENTERMENULOOP:
            gOverlayScrollbarSuppressThick = true;
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_EXITMENULOOP:
            gOverlayScrollbarSuppressThick = false;
            // hide the menu bar again if it was shown only temporarily
            if (!wp && win && !IsMenubarVisible()) {
                SetMenu(hwnd, nullptr);
            }
            return DefWindowProc(hwnd, msg, wp, lp);

        case WM_CONTEXTMENU: {
            // opening the context menu with a keyboard doesn't call the canvas'
            // WM_CONTEXTMENU, as it never has the focus (mouse right-clicks are
            // handled as expected)
            int x = GET_X_LPARAM(lp);
            int y = GET_Y_LPARAM(lp);
            if (win && (x == -1) && (y == -1) && !HwndIsFocused(win->tocTreeView->hwnd)) {
                return SendMessageW(win->hwndCanvas, WM_CONTEXTMENU, wp, lp);
            }
            return DefWindowProc(hwnd, msg, wp, lp);
        }

        case WM_SETTINGCHANGE:
            if (UseDarkModeLib() && DarkMode::handleSettingChange(lp)) {
                SetCurrentThemeFromSettings();
                return 0;
            }
        InitMouseWheelInfo:
            UpdateDeltaPerLine();

            if (win) {
                // in tablets it's possible to rotate the screen. if we're
                // in full screen, resize our window to match new screen size
                if (win->presentation) {
                    EnterFullScreen(win, true);
                } else if (win->isFullScreen) {
                    EnterFullScreen(win, false);
                }
            }

            return 0;

        case WM_SYSCOLORCHANGE:
            if (gGlobalPrefs->useSysColors) {
                UpdateDocumentColors();
            }
            break;

        case WM_MOUSEWHEEL:
        case WM_MOUSEHWHEEL:
            if (!win || !win->IsDocLoaded()) {
                break;
            }
            if (win->AsChm()) {
                return win->AsChm()->PassUIMsg(msg, wp, lp);
            }
            ReportIf(!win->AsFixed());
            // Pass the message to the canvas' window procedure
            // (required since the canvas itself never has the focus and thus
            // never receives WM_MOUSEWHEEL messages)
            return SendMessageW(win->hwndCanvas, msg, wp, lp);

        case WM_CLOSE: {
            if (!win) {
                logf("WM_CLOSE to 0x%p, but didn't find MainWindow for it\n", hwnd);
                ExitProcessAfterShutdown();
                return 0;
            }
            if (CanCloseWindow(win)) {
                CloseWindow(win, true, false);
            }
            return 0;
        }

        case WM_DESTROY: {
            // WM_DESTROY is generated by windows when close button is pressed
            // or if we explicitly call DestroyWindow().
            // It might be sent as a result of File\Close, in which
            // case CloseWindow() has already been called.
            // It's also sent when a parent window (e.g. Total Commander's lister)
            // destroys our embedded window.
            UnregisterScreenshotHotkey(hwnd);
            FreeMenuOwnerDrawInfoData(GetMenu(hwnd));
            if (win) {
                CloseWindow(win, true, true);
            }
        } break;

        case WM_ENDSESSION:
            // TODO: check for unfinished print jobs in WM_QUERYENDSESSION?
            SaveSettings();
            gDontSaveSettings = true;
            if (wp == TRUE) {
                CloseWindow(win, true, true);
            }
            return 0;

        case WM_DDE_INITIATE:
            if (gPluginMode) {
                break;
            }
            return OnDDEInitiate(hwnd, wp, lp);
        case WM_DDE_EXECUTE:
            return OnDDExecute(hwnd, wp, lp);
        case WM_DDE_REQUEST:
            return OnDDERequest(hwnd, wp, lp);
        case WM_DDE_TERMINATE:
            return OnDDETerminate(hwnd, wp, lp);

        case WM_COPYDATA:
            return OnCopyData(hwnd, wp, lp);

        case WM_TIMER:
            if (win && win->stressTest) {
                OnStressTestTimer(win, (int)wp);
            }
            break;

        case WM_TTS_EVENT:
            TtsProcessEvents();

            if (TtsIsSpeaking() && gReadAloudSourceTab && gReadAloudSourceTab->win) {
                InvalidateRect(gReadAloudSourceTab->win->hwndCanvas, nullptr, FALSE);
            }

            if (TtsConsumeChunkFinished() && gReadAloudSourceTab) {
                WindowTab* raTab = gReadAloudSourceTab;
                if (ReadAloudHasMoreChunks(raTab)) {
                    if (!ReadAloudSpeakChunk(raTab, _TRA("No text available to read aloud"))) {
                        ReadAloudFinishSession(raTab, win);
                    }
                } else if (ReadAloudEnsureDocumentTextExtended(raTab) && ReadAloudHasMoreChunks(raTab)) {
                    if (!ReadAloudSpeakChunk(raTab, _TRA("No text available to read aloud"))) {
                        ReadAloudFinishSession(raTab, win);
                    }
                } else {
                    ReadAloudFinishSession(raTab, win);
                }
            }

            return 0;

        case WM_MOUSEACTIVATE:
            if (win && win->presentation && hwnd != GetForegroundWindow()) {
                return MA_ACTIVATEANDEAT;
            }
            return MA_ACTIVATE;

        case WM_ERASEBKGND:
            // not sure why it's needed but it causes
            // flash of caption area in choco theme when resizing sidebar
#if 0
            LogRedraw("WM_ERASEBKGND", hwnd);
            if (win && win->tabsInTitlebar && !IsCurrentThemeDefault()) {
                HDC hdc = (HDC)wp;
                RECT rc;
                GetClientRect(hwnd, &rc);
                HBRUSH br = CreateSolidBrush(ThemeMainWindowBackgroundColor());
                FillRect(hdc, &rc, br);
                DeleteObject(br);
                if (!win->captionRect.IsEmpty()) {
                    RECT rcCaption = ToRECT(win->captionRect);
                    HBRUSH brCaption = CreateSolidBrush(ThemeChromeBackgroundColor());
                    FillRect(hdc, &rcCaption, brCaption);
                    DeleteObject(brCaption);
                }
            }
#endif
            return TRUE;

        default:
            return DefWindowProc(hwnd, msg, wp, lp);
    }
    return 0;
}

static TempStr GetFileSizeAsStrTemp(const char* path) {
    i64 fileSize = file::GetSize(path);
    return str::FormatFileSizeTemp(fileSize);
}

void GetProgramInfo(StrBuilder& s) {
    s.AppendFmt("Crash file: %s\r\n", gCrashFilePath);

    TempStr exePath = GetSelfExePathTemp();
    auto fileSizeExe = GetFileSizeAsStrTemp(exePath);
    s.AppendFmt("Exe: %s %s\r\n", exePath, fileSizeExe);
    if (IsDllBuild()) {
        // show the size of the dll so that we can verify it's the
        // correct size for the given version
        char* dir = path::GetDirTemp(exePath);
        char* dllPath = path::JoinTemp(dir, "libmupdf.dll");
        auto fileSizeDll = GetFileSizeAsStrTemp(dllPath);
        s.AppendFmt("Dll: %s %s\r\n", dllPath, fileSizeDll);
    }
    TempStr signer = GetExecutableSignerTemp(exePath);
    s.AppendFmt("Signer: %s\r\n", signer ? signer : "(not signed)");
    if (builtOn != nullptr) {
        s.AppendFmt("BuiltOn: %s\n", builtOn);
    }
    const char* exeType = IsDllBuild() ? "dll" : "static";
    const char* instType = IsRunningInPortableMode() ? "portable" : "installed";
    s.AppendFmt("ExeType: %s, %s\r\n", exeType, instType);
    s.AppendFmt("Ver: %s", currentVersion);
    if (gIsPreReleaseBuild) {
        s.AppendFmt(" pre-release");
    }
    if (IsProcess64()) {
        s.Append(" 64-bit");
    } else {
        s.Append(" 32-bit");
        if (IsRunningInWow64()) {
            s.Append(" Wow64");
        }
    }
    if (gIsDebugBuild) {
        if (!str::Find(s.Get(), " (dbg)")) {
            s.Append(" (dbg)");
        }
    }
    if (gPluginMode) {
        s.Append(" [plugin]");
    }
    s.Append("\r\n");

    if (gitCommidId != nullptr) {
        s.AppendFmt("Git: %s (https://github.com/sumatrapdfreader/sumatrapdf/commit/%s)\r\n", gitCommidId, gitCommidId);
    }
}

bool CrashHandlerCanUseNet() {
    return HasPermission(Perm::InternetAccess);
}

void ShowCrashHandlerMessage() {
    log("ShowCrashHandlerMessage\n");
    // don't show a message box in restricted use, as the user most likely won't be
    // able to do anything about it anyway and it's up to the application provider
    // to fix the unexpected behavior (of which for a restricted set of documents
    // there should be much less, anyway)
    if (!CanAccessDisk()) {
        log("ShowCrashHandlerMessage: skipping beacuse !CanAccessDisk()\n");
        return;
    }

#if 0
    int res = MsgBox(nullptr, _TRA("Sorry, that shouldn't have happened!\n\nPlease press 'Cancel', if you want to help us fix the cause of this crash."), _TRA("SumatraPDF crashed"), MB_ICONERROR | MB_OKCANCEL | MbRtlReadingMaybe());
    if (IDCANCEL == res) {
        LaunchBrowser(CRASH_REPORT_URL);
    }
#endif

    const char* msg = "We're sorry, SumatraPDF crashed.\n\nPress 'Cancel' to see crash report.";
    uint flags = MB_ICONERROR | MB_OK | MB_OKCANCEL | MbRtlReadingMaybe();
    flags |= MB_SETFOREGROUND | MB_TOPMOST;

    int res = MsgBox(nullptr, msg, "SumatraPDF crashed", flags);
    if (IDCANCEL != res) {
        log("ShowCrashHandlerMessage: res != IDCANCEL\n");
        return;
    }
    if (!gCrashFilePath) {
        log("ShowCrashHandlerMessage: !gCrashFilePath\n");
        return;
    }
    LaunchFileIfExists(gCrashFilePath);
    auto url = "https://www.sumatrapdfreader.org/docs/Submit-crash-report.html";
    LaunchFileShell(url, nullptr, "open");
}

void ShutdownCleanup() {
    gAllowedFileTypes.Reset();
    gAllowedLinkProtocols.Reset();
}
