diff --git a/src/manager/CMakeLists.txt b/src/manager/CMakeLists.txt index a9e3b64..04eb25d 100644 --- a/src/manager/CMakeLists.txt +++ b/src/manager/CMakeLists.txt @@ -15,6 +15,9 @@ add_executable(tenbox-manager WIN32 ${CMAKE_SOURCE_DIR}/src/manager/ui/llm_proxy_dialog.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/settings_dialog.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/win32_display_panel.cpp + ${CMAKE_SOURCE_DIR}/src/manager/ui/win32_fullscreen_window.cpp + ${CMAKE_SOURCE_DIR}/src/manager/ui/win32_floating_toolbar.cpp + ${CMAKE_SOURCE_DIR}/src/manager/ui/win32_fullscreen_osd.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/info_tab.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/console_tab.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/vm_listview.cpp diff --git a/src/manager/app_settings.cpp b/src/manager/app_settings.cpp index 3542d0d..4c47137 100644 --- a/src/manager/app_settings.cpp +++ b/src/manager/app_settings.cpp @@ -109,6 +109,15 @@ AppSettings LoadSettings(const std::string& data_dir) { if (w.contains("width")) s.window.width = w["width"].get(); if (w.contains("height")) s.window.height = w["height"].get(); } + if (j.contains("fullscreen_toolbar") && j["fullscreen_toolbar"].is_object()) { + auto& ft = j["fullscreen_toolbar"]; + if (ft.contains("snap_edge")) s.fullscreen_toolbar.snap_edge = ft["snap_edge"].get(); + if (ft.contains("offset")) s.fullscreen_toolbar.offset = ft["offset"].get(); + if (ft.contains("pinned")) s.fullscreen_toolbar.pinned = ft["pinned"].get(); + } + if (j.contains("fullscreen_monitor_index") && j["fullscreen_monitor_index"].is_number()) { + s.fullscreen_monitor_index = j["fullscreen_monitor_index"].get(); + } if (j.contains("show_toolbar") && j["show_toolbar"].is_boolean()) { s.show_toolbar = j["show_toolbar"].get(); } @@ -199,6 +208,14 @@ void SaveSettings(const std::string& data_dir, const AppSettings& s) { j["window"] = w; j["show_toolbar"] = s.show_toolbar; j["close_to_tray"] = s.close_to_tray; + { + json ft; + ft["snap_edge"] = s.fullscreen_toolbar.snap_edge; + ft["offset"] = s.fullscreen_toolbar.offset; + ft["pinned"] = s.fullscreen_toolbar.pinned; + j["fullscreen_toolbar"] = ft; + } + j["fullscreen_monitor_index"] = s.fullscreen_monitor_index; j["vm_paths"] = vm_paths_json; if (!s.vm_storage_dir.empty()) j["vm_storage_dir"] = s.vm_storage_dir; diff --git a/src/manager/app_settings.h b/src/manager/app_settings.h index 01d895a..1adafac 100644 --- a/src/manager/app_settings.h +++ b/src/manager/app_settings.h @@ -19,6 +19,12 @@ struct WindowGeometry { int width = 1024, height = 680; }; +struct FullscreenToolbarState { + int snap_edge = 0; // 0=top, 1=bottom, 2=left, 3=right + int offset = -1; // pixel offset along edge, -1 = centered + bool pinned = false; +}; + enum class LlmApiType : uint8_t { kOpenAiCompletions = 0, // POST /v1/chat/completions // Future: @@ -41,6 +47,8 @@ struct LlmProxySettings { struct AppSettings { WindowGeometry window; + FullscreenToolbarState fullscreen_toolbar; + int fullscreen_monitor_index = 0; // last monitor used for fullscreen std::vector vm_paths; bool show_toolbar = true; bool close_to_tray = true; // X button hides to system tray instead of quitting diff --git a/src/manager/i18n.cpp b/src/manager/i18n.cpp index 011c5aa..4505764 100644 --- a/src/manager/i18n.cpp +++ b/src/manager/i18n.cpp @@ -95,6 +95,15 @@ static const std::unordered_map kStringsEn = { {S::kCpuMemoryChangeWarning, "CPU / Memory changes require VM to be stopped"}, {S::kDisplayHintCaptured, "Full input capture (system keys) | Press Right Alt to release"}, {S::kDisplayHintNormal, "Click to capture system keys"}, + {S::kMenuFullscreen, "&Fullscreen"}, + {S::kToolbarFullscreen, "Fullscreen"}, + {S::kFullscreenExit, "Exit Fullscreen"}, + {S::kFullscreenPin, "Pin"}, + {S::kFullscreenUnpin, "Unpin"}, + {S::kFullscreenDpiZoom, "Zoom"}, + {S::kFullscreenSwitchVm, "Switch VM"}, + {S::kFullscreenMoveToMonitor, "Move to Monitor"}, + {S::kFullscreenFindToolbar, "Find toolbar at top center of screen"}, {S::kMenuView, "View"}, {S::kMenuViewToolbar, "Toolbar"}, {S::kMenuHelp, "Help"}, @@ -355,6 +364,15 @@ static const std::unordered_map kStringsZhCN = { {S::kCpuMemoryChangeWarning, "更改 CPU/内存需要先停止虚拟机"}, {S::kDisplayHintCaptured, "已捕获全部输入(含系统键)| 按右 Alt 释放"}, {S::kDisplayHintNormal, "点击以捕获系统键"}, + {S::kMenuFullscreen, "全屏(&F)"}, + {S::kToolbarFullscreen, "全屏"}, + {S::kFullscreenExit, "退出全屏"}, + {S::kFullscreenPin, "固定"}, + {S::kFullscreenUnpin, "取消固定"}, + {S::kFullscreenDpiZoom, "缩放"}, + {S::kFullscreenSwitchVm, "切换虚拟机"}, + {S::kFullscreenMoveToMonitor, "移动到显示器"}, + {S::kFullscreenFindToolbar, "可在屏幕顶部中间找回工具栏"}, {S::kMenuView, "视图"}, {S::kMenuViewToolbar, "工具栏"}, {S::kMenuHelp, "帮助"}, diff --git a/src/manager/i18n.h b/src/manager/i18n.h index ab9d772..4a3b95a 100644 --- a/src/manager/i18n.h +++ b/src/manager/i18n.h @@ -325,6 +325,17 @@ enum class S { kTrayHide, kSettingsCloseToTray, + // Fullscreen + kMenuFullscreen, + kToolbarFullscreen, + kFullscreenExit, + kFullscreenPin, + kFullscreenUnpin, + kFullscreenDpiZoom, + kFullscreenSwitchVm, + kFullscreenMoveToMonitor, + kFullscreenFindToolbar, + kCount // Must be last }; diff --git a/src/manager/resource.h b/src/manager/resource.h index a2fe2bd..caf78b9 100644 --- a/src/manager/resource.h +++ b/src/manager/resource.h @@ -15,3 +15,10 @@ #define IDB_TOOLBAR_PORT_FORWARDS 109 #define IDB_TOOLBAR_DPI_ZOOM 110 #define IDB_TOOLBAR_LLM_PROXY 111 +#define IDB_TOOLBAR_FULLSCREEN 112 +#define IDB_FS_EXIT 113 +#define IDB_FS_PIN 114 +#define IDB_FS_UNPIN 115 +#define IDB_FS_ZOOM 116 +#define IDB_FS_ZOOM_ACTIVE 117 +#define IDB_TOOLBAR_FULLSCREEN_EXIT 118 diff --git a/src/manager/resources/fs_exit.bmp b/src/manager/resources/fs_exit.bmp new file mode 100644 index 0000000..07a4111 Binary files /dev/null and b/src/manager/resources/fs_exit.bmp differ diff --git a/src/manager/resources/fs_pin.bmp b/src/manager/resources/fs_pin.bmp new file mode 100644 index 0000000..bfcb4c1 Binary files /dev/null and b/src/manager/resources/fs_pin.bmp differ diff --git a/src/manager/resources/fs_unpin.bmp b/src/manager/resources/fs_unpin.bmp new file mode 100644 index 0000000..dac05a1 Binary files /dev/null and b/src/manager/resources/fs_unpin.bmp differ diff --git a/src/manager/resources/fs_zoom.bmp b/src/manager/resources/fs_zoom.bmp new file mode 100644 index 0000000..f7d5e10 Binary files /dev/null and b/src/manager/resources/fs_zoom.bmp differ diff --git a/src/manager/resources/fs_zoom_active.bmp b/src/manager/resources/fs_zoom_active.bmp new file mode 100644 index 0000000..491185b Binary files /dev/null and b/src/manager/resources/fs_zoom_active.bmp differ diff --git a/src/manager/resources/fullscreen.bmp b/src/manager/resources/fullscreen.bmp new file mode 100644 index 0000000..9020831 Binary files /dev/null and b/src/manager/resources/fullscreen.bmp differ diff --git a/src/manager/resources/fullscreen_exit.bmp b/src/manager/resources/fullscreen_exit.bmp new file mode 100644 index 0000000..2cf26cc Binary files /dev/null and b/src/manager/resources/fullscreen_exit.bmp differ diff --git a/src/manager/toolbar.rc b/src/manager/toolbar.rc index b2a112d..b83e830 100644 --- a/src/manager/toolbar.rc +++ b/src/manager/toolbar.rc @@ -18,6 +18,13 @@ IDB_TOOLBAR_SHARED_FOLDERS BITMAP "resources/shared_folders.bmp" IDB_TOOLBAR_PORT_FORWARDS BITMAP "resources/port_forwards.bmp" IDB_TOOLBAR_DPI_ZOOM BITMAP "resources/dpi_zoom.bmp" IDB_TOOLBAR_LLM_PROXY BITMAP "resources/llm_proxy.bmp" +IDB_TOOLBAR_FULLSCREEN BITMAP "resources/fullscreen.bmp" +IDB_FS_EXIT BITMAP "resources/fs_exit.bmp" +IDB_FS_PIN BITMAP "resources/fs_pin.bmp" +IDB_FS_UNPIN BITMAP "resources/fs_unpin.bmp" +IDB_FS_ZOOM BITMAP "resources/fs_zoom.bmp" +IDB_FS_ZOOM_ACTIVE BITMAP "resources/fs_zoom_active.bmp" +IDB_TOOLBAR_FULLSCREEN_EXIT BITMAP "resources/fullscreen_exit.bmp" // Version information VS_VERSION_INFO VERSIONINFO diff --git a/src/manager/ui/win32_display_panel.cpp b/src/manager/ui/win32_display_panel.cpp index d03b21c..a5b8074 100644 --- a/src/manager/ui/win32_display_panel.cpp +++ b/src/manager/ui/win32_display_panel.cpp @@ -5,6 +5,8 @@ #include #include +extern HWND g_main_hwnd; + static const wchar_t* kDisplayPanelClass = L"TenBoxDisplayPanel"; static bool g_class_registered = false; @@ -237,6 +239,14 @@ void DisplayPanel::SetVisible(bool visible) { if (hwnd_) ShowWindow(hwnd_, visible ? SW_SHOW : SW_HIDE); } +void DisplayPanel::Reparent(HWND new_parent) { + if (!hwnd_) return; + SetParent(hwnd_, new_parent); + RECT rc; + GetClientRect(new_parent, &rc); + SetBounds(0, 0, rc.right, rc.bottom); +} + void DisplayPanel::CalcDisplayRect(int cw, int ch, RECT* out) const { if (fb_width_ == 0 || fb_height_ == 0 || cw <= 0 || ch <= 0) { *out = {0, 0, cw, ch}; @@ -334,7 +344,7 @@ void DisplayPanel::OnPaint() { int pill_w = text_sz.cx + pad_x * 2; int pill_h = text_sz.cy + pad_y * 2; int pill_x = (rc.right - pill_w) / 2; - int pill_y = 6; + int pill_y = 6 + hint_offset_y_; RECT pill_rc = {pill_x, pill_y, pill_x + pill_w, pill_y + pill_h}; HBRUSH bg_brush = CreateSolidBrush(RGB(48, 48, 48)); @@ -484,6 +494,7 @@ void DisplayPanel::SetCaptured(bool captured) { if (hwnd_) SetTimer(hwnd_, kHintTimerId, kHintDurationMs, nullptr); } } else { + ReleaseAllModifiers(); UninstallKeyboardHook(); capture_hint_visible_ = false; if (hwnd_) KillTimer(hwnd_, kHintTimerId); @@ -522,19 +533,24 @@ LRESULT CALLBACK DisplayPanel::LowLevelKeyboardProc(int nCode, WPARAM wp, LPARAM return 1; } - // Distinguish left/right modifiers + // Distinguish left/right modifiers for evdev mapping if (vk == VK_CONTROL) vk = extended ? VK_RCONTROL : VK_LCONTROL; if (vk == VK_MENU) vk = extended ? VK_RMENU : VK_LMENU; if (vk == VK_SHIFT) { vk = (kb->scanCode == 0x36) ? VK_RSHIFT : VK_LSHIFT; } + // Forward to guest uint32_t evdev = VkToEvdev(vk); if (evdev && g_captured_panel->key_cb_) { g_captured_panel->key_cb_(evdev, pressed); } - // Swallow the key so the host OS does not act on it + // ESC passes through for long-press exit; everything else swallowed + if (vk == VK_ESCAPE) { + return CallNextHookEx(nullptr, nCode, wp, lp); + } + return 1; } return CallNextHookEx(nullptr, nCode, wp, lp); @@ -574,6 +590,19 @@ LRESULT CALLBACK DisplayPanel::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp case WM_MBUTTONUP: case WM_MOUSEMOVE: self->HandleMouse(msg, wp, lp); + // Request WM_MOUSELEAVE to detect mouse exiting the window + { + TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hwnd }; + TrackMouseEvent(&tme); + } + return 0; + + case WM_MOUSELEAVE: + // Mouse left the panel — release all held buttons to prevent stuck state + if (self && self->mouse_buttons_ != 0) { + self->mouse_buttons_ = 0; + if (self->pointer_cb_) self->pointer_cb_(self->last_abs_x_, self->last_abs_y_, 0); + } return 0; case WM_MOUSEWHEEL: @@ -584,6 +613,7 @@ LRESULT CALLBACK DisplayPanel::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp return 0; case WM_KILLFOCUS: + self->ReleaseAllModifiers(); self->SetCaptured(false); self->mouse_buttons_ = 0; return 0; diff --git a/src/manager/ui/win32_display_panel.h b/src/manager/ui/win32_display_panel.h index 6c87232..bea6b92 100644 --- a/src/manager/ui/win32_display_panel.h +++ b/src/manager/ui/win32_display_panel.h @@ -7,6 +7,11 @@ #include #include +// Custom messages posted from keyboard hook for fullscreen controls +#define WM_FS_TOGGLE (WM_APP + 10) +#define WM_FS_SWITCH_VM (WM_APP + 11) +#define WM_FS_SHOW_HINT (WM_APP + 12) + #define NOMINMAX #define WIN32_LEAN_AND_MEAN #include @@ -53,6 +58,11 @@ class DisplayPanel { // Move/resize the window. void SetBounds(int x, int y, int w, int h); + // Reparent to a new parent window. Adjusts style bits and fills parent client area. + void Reparent(HWND new_parent); + void SetHintOffsetY(int offset) { hint_offset_y_ = offset; } + void SetFullscreenMode(bool fs) { in_fullscreen_mode_ = fs; } + HWND Handle() const { return hwnd_; } void SetVisible(bool visible); @@ -101,6 +111,10 @@ class DisplayPanel { HCURSOR custom_cursor_ = nullptr; float dpi_zoom_factor_ = 1.0f; + int hint_offset_y_ = 0; + bool in_fullscreen_mode_ = false; + DWORD64 esc_press_tick_ = 0; + UINT_PTR esc_exit_timer_ = 0; KeyEventCallback key_cb_; PointerEventCallback pointer_cb_; diff --git a/src/manager/ui/win32_floating_toolbar.cpp b/src/manager/ui/win32_floating_toolbar.cpp new file mode 100644 index 0000000..57f5879 --- /dev/null +++ b/src/manager/ui/win32_floating_toolbar.cpp @@ -0,0 +1,631 @@ +#include "manager/ui/win32_floating_toolbar.h" +#include "manager/ui/win32_display_panel.h" // WM_FS_* defines +#include "manager/i18n.h" +#include "../resource.h" +#include +#include +#include + +namespace { +constexpr const wchar_t* kWndClass = L"TenBoxFloatingToolbar"; + +constexpr int kBarHeight = 62; +constexpr int kBtnH = 38; +constexpr int kPad = 10; +constexpr int kGripW = 56; +constexpr int kVmBtnMinW = 280; +constexpr int kIconBtnW = 40; +constexpr int kRightPadding = 14; +constexpr UINT_PTR kHideTimerId = 1; +constexpr DWORD kAutoHideMs = 1500; + +enum BtnId { kBtnDrag = 300, kBtnVm = 301, kBtnDpi = 302, kBtnPin = 303, kBtnExit = 304 }; +} + +HWND FloatingToolbar::Create(HINSTANCE hinst, HWND fullscreen_hwnd) { + WNDCLASSEXW wc{ sizeof(wc) }; + wc.lpfnWndProc = WndProc; + wc.hInstance = hinst; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.hbrBackground = reinterpret_cast(GetStockObject(WHITE_BRUSH)); + wc.lpszClassName = kWndClass; + RegisterClassExW(&wc); + + HWND hwnd = CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW, + kWndClass, L"", + WS_POPUP | WS_VISIBLE, + 100, 0, 500, kBarHeight, + fullscreen_hwnd, nullptr, hinst, nullptr); + if (!hwnd) return nullptr; + + auto* st = new ToolbarState(); + st->fullscreen_parent = fullscreen_hwnd; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(st)); + + // UI font — large size + LOGFONTW lf{}; + HFONT stock = reinterpret_cast(GetStockObject(DEFAULT_GUI_FONT)); + GetObjectW(stock, sizeof(lf), &lf); + lf.lfHeight = -28; // 21pt + lf.lfWeight = FW_NORMAL; + wcscpy_s(lf.lfFaceName, L"Segoe UI"); + HFONT ui_font = CreateFontIndirectW(&lf); + st->ui_font = ui_font; + + // Drag handle — TenBox icon + st->drag_handle = CreateWindowExW(0, WC_STATICW, L"", + WS_CHILD | WS_VISIBLE | SS_ICON | SS_CENTERIMAGE | SS_NOTIFY, + 0, 0, 0, 0, hwnd, reinterpret_cast(kBtnDrag), hinst, nullptr); + { + HICON icon = static_cast(LoadImageW(hinst, MAKEINTRESOURCEW(IDI_APP_ICON), + IMAGE_ICON, 54, 54, LR_SHARED)); + SendMessageW(st->drag_handle, STM_SETICON, reinterpret_cast(icon), 0); + } + + // VM dropdown button + st->btn_vm = CreateWindowExW(0, WC_BUTTONW, L"", + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON | BS_CENTER | BS_FLAT, + 0, 0, 0, 0, hwnd, reinterpret_cast(kBtnVm), hinst, nullptr); + SendMessage(st->btn_vm, WM_SETFONT, reinterpret_cast(ui_font), FALSE); + + // Zoom button (colored BMP) + st->btn_dpi = CreateWindowExW(0, WC_BUTTONW, L"", + WS_CHILD | WS_VISIBLE | BS_BITMAP, + 0, 0, 0, 0, hwnd, reinterpret_cast(kBtnDpi), hinst, nullptr); + SendMessageW(st->btn_dpi, BM_SETIMAGE, IMAGE_BITMAP, + reinterpret_cast(LoadBitmapW(hinst, MAKEINTRESOURCEW(IDB_FS_ZOOM)))); + + // Pin button (colored BMP, default pinned) + st->btn_pin = CreateWindowExW(0, WC_BUTTONW, L"", + WS_CHILD | WS_VISIBLE | BS_BITMAP, + 0, 0, 0, 0, hwnd, reinterpret_cast(kBtnPin), hinst, nullptr); + SendMessageW(st->btn_pin, BM_SETIMAGE, IMAGE_BITMAP, + reinterpret_cast(LoadBitmapW(hinst, MAKEINTRESOURCEW(IDB_FS_PIN)))); + + // Exit button (colored BMP) + st->btn_exit = CreateWindowExW(0, WC_BUTTONW, L"", + WS_CHILD | WS_VISIBLE | BS_BITMAP, + 0, 0, 0, 0, hwnd, reinterpret_cast(kBtnExit), hinst, nullptr); + SendMessageW(st->btn_exit, BM_SETIMAGE, IMAGE_BITMAP, + reinterpret_cast(LoadBitmapW(hinst, MAKEINTRESOURCEW(IDB_FS_EXIT)))); + + LayoutButtons(hwnd); + { + RECT pr; + GetWindowRect(fullscreen_hwnd, &pr); + st->free_pos.x = (pr.left + pr.right - st->tb_width) / 2; + st->free_pos.y = pr.top + 15; + } + UpdatePosition(hwnd); + + // Create tooltip control + HWND tt = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, + WS_POPUP | TTS_ALWAYSTIP, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, + hwnd, nullptr, hinst, nullptr); + SetWindowPos(tt, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + st->tooltip = tt; + + auto AddTool = [&](HWND btn, const wchar_t* text) { + TOOLINFOW ti{ sizeof(ti) }; + ti.uFlags = TTF_SUBCLASS | TTF_IDISHWND; + ti.hwnd = hwnd; + ti.uId = reinterpret_cast(btn); + ti.lpszText = const_cast(text); + SendMessageW(tt, TTM_ADDTOOLW, 0, reinterpret_cast(&ti)); + }; + + AddTool(st->btn_dpi, L"\U0001F50D 缩放 (DPI Zoom)"); + AddTool(st->btn_pin, L"\U0001F4CC 取消固定"); + AddTool(st->btn_exit, L"✕ 退出全屏 (或长按 ESC 2 秒)"); + AddTool(st->btn_vm, L"切换虚拟机"); + + AddTool(st->drag_handle, L"可拖拽至窗口边缘以吸附"); + + return hwnd; +} + +void FloatingToolbar::RestoreState(HWND hwnd, const settings::FullscreenToolbarState& state) { + auto* st = GetState(hwnd); + if (!st) return; + st->snap = static_cast(state.snap_edge); + st->offset = state.offset; + st->pinned = state.pinned; + if (st->pinned) { + KillAutoHideTimer(hwnd); + SendMessageW(st->btn_pin, BM_SETIMAGE, IMAGE_BITMAP, + reinterpret_cast(LoadBitmapW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(IDB_FS_PIN)))); + } + UpdatePosition(hwnd); +} + +settings::FullscreenToolbarState FloatingToolbar::SaveState(HWND hwnd) { + settings::FullscreenToolbarState s; + auto* st = GetState(hwnd); + if (!st) return s; + s.snap_edge = static_cast(st->snap); + s.offset = st->offset; + s.pinned = st->pinned; + return s; +} + +void FloatingToolbar::SetExitCallback(HWND hwnd, ExitCallback cb) { auto* st = GetState(hwnd); if (st) st->exit_cb = std::move(cb); } +void FloatingToolbar::SetPinCallback(HWND hwnd, PinCallback cb) { auto* st = GetState(hwnd); if (st) st->pin_cb = std::move(cb); } +void FloatingToolbar::SetSwitchCallback(HWND hwnd, SwitchCallback cb) { auto* st = GetState(hwnd); if (st) st->switch_cb = std::move(cb); } +void FloatingToolbar::SetDpiZoomCallback(HWND hwnd, DpiZoomCallback cb) { auto* st = GetState(hwnd); if (st) st->dpi_zoom_cb = std::move(cb); } + +void FloatingToolbar::SetVmInfo(HWND hwnd, const std::string& current_id, const std::string& name, + uint32_t width, uint32_t height, + const std::vector& running_ids, + const std::vector& running_names) { + auto* st = GetState(hwnd); + if (!st) return; + st->current_vm_id = current_id; + st->running_vm_ids = running_ids; + st->running_vm_names = running_names; + wchar_t buf[256]; + swprintf_s(buf, L" %hs (%u\x00d7%u) \x25be", name.c_str(), width, height); + SetWindowTextW(st->btn_vm, buf); + + // Measure text to size toolbar + HDC hdc = GetDC(st->btn_vm); + HFONT font = reinterpret_cast(SendMessageW(st->btn_vm, WM_GETFONT, 0, 0)); + HFONT old = static_cast(SelectObject(hdc, font)); + SIZE sz{}; + GetTextExtentPoint32W(hdc, buf, static_cast(wcslen(buf)), &sz); + SelectObject(hdc, old); + ReleaseDC(st->btn_vm, hdc); + + int vm_w = sz.cx + kPad * 2 + 10; + if (vm_w < kVmBtnMinW) vm_w = kVmBtnMinW; + int new_w = kPad + kGripW + 2 + vm_w + kPad + kIconBtnW * 3 + 6 + kRightPadding; + if (new_w < 420) new_w = 420; + st->tb_width = new_w; + + SetWindowPos(hwnd, nullptr, 0, 0, st->tb_width, kBarHeight, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + LayoutButtons(hwnd); + UpdatePosition(hwnd); + InvalidateRect(hwnd, nullptr, TRUE); // force full redraw, erase background +} + +void FloatingToolbar::SetDpiZoomState(HWND hwnd, bool enabled) { + auto* st = GetState(hwnd); + if (!st) return; + HBITMAP hbm = LoadBitmapW(GetModuleHandleW(nullptr), + MAKEINTRESOURCEW(enabled ? IDB_FS_ZOOM_ACTIVE : IDB_FS_ZOOM)); + SendMessageW(st->btn_dpi, BM_SETIMAGE, IMAGE_BITMAP, reinterpret_cast(hbm)); +} + +void FloatingToolbar::OnFullscreenDeactivated(HWND hwnd) { + ShowWindow(hwnd, SW_HIDE); +} + +void FloatingToolbar::OnFullscreenActivated(HWND hwnd) { + ShowBar(hwnd); +} + +FloatingToolbar::ToolbarState* FloatingToolbar::GetState(HWND hwnd) { + return reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); +} + +void FloatingToolbar::StartAutoHideTimer(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st) return; + // Pinned + free-floating: no auto-hide at all + if (st->pinned && !st->snapped) return; + KillAutoHideTimer(hwnd); + st->hide_timer_id = SetTimer(hwnd, kHideTimerId, kAutoHideMs, nullptr); +} + +void FloatingToolbar::KillAutoHideTimer(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st) return; + if (st->hide_timer_id) { KillTimer(hwnd, st->hide_timer_id); st->hide_timer_id = 0; } +} + +void FloatingToolbar::ShowBar(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st) return; + st->tab_mode = false; + // Restore full size + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, st->tb_width, st->tb_height, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW); + InvalidateRect(hwnd, nullptr, TRUE); + // Show all children + ShowWindow(st->drag_handle, SW_SHOW); + ShowWindow(st->btn_vm, SW_SHOW); + ShowWindow(st->btn_dpi, SW_SHOW); + ShowWindow(st->btn_pin, SW_SHOW); + ShowWindow(st->btn_exit, SW_SHOW); + LayoutButtons(hwnd); + UpdatePosition(hwnd); + StartAutoHideTimer(hwnd); +} + +void FloatingToolbar::HideBar(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st || st->dragging) return; + // Unpinned: always fully hide. Pinned: shrink to icon tab (only when snapped) + if (!st->pinned) { + ShowWindow(hwnd, SW_HIDE); + return; + } + if (!st->snapped) return; // pinned + free-floating: stay visible + st->tab_mode = true; + ShowWindow(st->btn_vm, SW_HIDE); + ShowWindow(st->btn_dpi, SW_HIDE); + ShowWindow(st->btn_pin, SW_HIDE); + ShowWindow(st->btn_exit, SW_HIDE); + ShowWindow(st->drag_handle, SW_SHOW); + // Fixed square tab size to fit the icon + const int kTabSize = kBarHeight; // 56px square + RECT pr; + GetWindowRect(st->fullscreen_parent, &pr); + int x, y; + switch (st->snap) { + case SnapEdge::Top: + x = (st->offset >= 0) ? pr.left + st->offset : pr.left + (pr.right - pr.left - kTabSize) / 2; + y = pr.top; break; + case SnapEdge::Bottom: + x = (st->offset >= 0) ? pr.left + st->offset : pr.left + (pr.right - pr.left - kTabSize) / 2; + y = pr.bottom - kTabSize; break; + case SnapEdge::Left: + x = pr.left; + y = (st->offset >= 0) ? pr.top + st->offset : pr.top + (pr.bottom - pr.top - kTabSize) / 2; + break; + case SnapEdge::Right: + x = pr.right - kTabSize; + y = (st->offset >= 0) ? pr.top + st->offset : pr.top + (pr.bottom - pr.top - kTabSize) / 2; + break; + } + // Fill icon in tab area + SetWindowPos(st->drag_handle, nullptr, 0, 0, kTabSize, kTabSize, SWP_NOZORDER); + SetWindowPos(hwnd, HWND_TOPMOST, x, y, kTabSize, kTabSize, SWP_NOACTIVATE); + InvalidateRect(hwnd, nullptr, TRUE); +} + +void FloatingToolbar::LayoutButtons(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st) return; + if (st->tb_width == 0) st->tb_width = 420; + int y = (kBarHeight - kBtnH) / 2; + + // Icon buttons anchored to right edge (fixed position) + int rx = st->tb_width - kRightPadding; + rx -= kIconBtnW; MoveWindow(st->btn_exit, rx, y, kIconBtnW, kBtnH, FALSE); + rx -= kIconBtnW + 2; MoveWindow(st->btn_pin, rx, y, kIconBtnW, kBtnH, FALSE); + rx -= kIconBtnW + 2; MoveWindow(st->btn_dpi, rx, y, kIconBtnW, kBtnH, FALSE); + + // Drag handle at left — taller to fill more vertical space + int lx = kPad; + int grip_h = kBarHeight - 10; + MoveWindow(st->drag_handle, lx, 5, kGripW, grip_h, FALSE); + lx += kGripW + 2; + + // VM button fills space between drag handle and icon buttons + int vm_w = rx - lx - kPad; + if (vm_w < kVmBtnMinW) vm_w = kVmBtnMinW; + MoveWindow(st->btn_vm, lx, y, vm_w, kBtnH, FALSE); + + st->tb_height = kBarHeight; + SetWindowPos(hwnd, nullptr, 0, 0, st->tb_width, kBarHeight, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); +} + +void FloatingToolbar::UpdatePosition(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st || !st->fullscreen_parent) return; + RECT pr; + GetWindowRect(st->fullscreen_parent, &pr); + if (!st->snapped) { + int x = st->free_pos.x, y = st->free_pos.y; + if (x < pr.left) x = pr.left; + if (y < pr.top) y = pr.top; + if (x + st->tb_width > pr.right) x = pr.right - st->tb_width; + if (y + st->tb_height > pr.bottom) y = pr.bottom - st->tb_height; + SetWindowPos(hwnd, HWND_TOPMOST, x, y, st->tb_width, st->tb_height, SWP_NOACTIVATE); + return; + } + int pw = pr.right - pr.left; + int ph = pr.bottom - pr.top; + int cx = pr.left + (pw - st->tb_width) / 2; + int x, y; + switch (st->snap) { + case SnapEdge::Top: + x = (st->offset >= 0) ? pr.left + st->offset : cx; + y = pr.top + 2; break; + case SnapEdge::Bottom: + x = (st->offset >= 0) ? pr.left + st->offset : cx; + y = pr.bottom - st->tb_height - 2; break; + case SnapEdge::Left: + x = pr.left + 2; + y = (st->offset >= 0) ? pr.top + st->offset : pr.top + (ph - st->tb_height) / 2; break; + case SnapEdge::Right: + x = pr.right - st->tb_width - 2; + y = (st->offset >= 0) ? pr.top + st->offset : pr.top + (ph - st->tb_height) / 2; break; + } + if (x < pr.left) x = pr.left; + if (x + st->tb_width > pr.right) x = pr.right - st->tb_width; + SetWindowPos(hwnd, HWND_TOPMOST, x, y, st->tb_width, st->tb_height, SWP_NOACTIVATE); +} + +void FloatingToolbar::SnapToNearestEdge(HWND hwnd) { + auto* st = GetState(hwnd); + if (!st || !st->fullscreen_parent) return; + RECT pr, tr; + GetWindowRect(st->fullscreen_parent, &pr); + GetWindowRect(hwnd, &tr); + // Measure from toolbar EDGES, not center + int d_top = tr.top - pr.top; + int d_bot = pr.bottom - tr.bottom; + int d_left = tr.left - pr.left; + int d_right = pr.right - tr.right; + int cx = (tr.left + tr.right) / 2; + int cy = (tr.top + tr.bottom) / 2; + const int kSnapDistTop = 8; + const int kSnapDistBot = 80; + const int kSnapDistLeft = 80; + const int kSnapDistRight = 80; + + int best_d = INT_MAX; + SnapEdge best = SnapEdge::Top; + int best_off = 0; + if (d_top <= kSnapDistTop && d_top < best_d) { + best_d = d_top; best = SnapEdge::Top; best_off = cx - pr.left; + } + if (d_bot <= kSnapDistBot && d_bot < best_d) { + best_d = d_bot; best = SnapEdge::Bottom; best_off = cx - pr.left; + } + if (d_left <= kSnapDistLeft && d_left < best_d) { + best_d = d_left; best = SnapEdge::Left; best_off = cy - pr.top; + } + if (d_right <= kSnapDistRight && d_right < best_d) { + best_d = d_right; best = SnapEdge::Right; best_off = cy - pr.top; + } + if (best_d < INT_MAX) { + st->snap = best; + st->offset = best_off; + st->snapped = true; + } else { + st->free_pos.x = tr.left; + st->free_pos.y = tr.top; + st->snapped = false; + } + UpdatePosition(hwnd); + // Start shrink-to-tab after snapping — use timer with explicit ID + if (st->snapped) { + KillTimer(hwnd, kHideTimerId); + st->hide_timer_id = SetTimer(hwnd, kHideTimerId, kAutoHideMs, nullptr); + } +} + +void FloatingToolbar::CheckMouseNearEdge(HWND hwnd, POINT cursor) { + auto* st = GetState(hwnd); + if (!st || !st->fullscreen_parent || st->dragging) return; + RECT pr; + GetWindowRect(st->fullscreen_parent, &pr); + int screen_w = pr.right - pr.left; + int screen_cx = (pr.left + pr.right) / 2; + const int kSafeZoneHalfW = screen_w / 3; // 2/3 of screen width + + // "Safe zone": top center 2/3 of screen — show at initial position + if (cursor.y <= pr.top + 10 && abs(cursor.x - screen_cx) <= kSafeZoneHalfW) { + int init_x = (pr.left + pr.right - st->tb_width) / 2; + int init_y = pr.top + 15; + // Already at initial position and visible — don't re-trigger + if (!st->snapped && !st->tab_mode && IsWindowVisible(hwnd) && + st->free_pos.x == init_x && st->free_pos.y == init_y) { + return; + } + st->snapped = false; + st->free_pos.x = init_x; + st->free_pos.y = init_y; + st->tab_mode = false; + UpdatePosition(hwnd); + ShowBar(hwnd); + return; + } + + if (st->pinned) return; + + bool hit = false; + switch (st->snap) { + case SnapEdge::Top: hit = (cursor.y <= pr.top + 10); break; + case SnapEdge::Bottom: hit = (cursor.y >= pr.bottom - 10); break; + case SnapEdge::Left: hit = (cursor.x <= pr.left + 10); break; + case SnapEdge::Right: hit = (cursor.x >= pr.right - 10); break; + } + if (hit) ShowBar(hwnd); +} + +LRESULT CALLBACK FloatingToolbar::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + auto* st = GetState(hwnd); + switch (msg) { + case WM_CREATE: + return 0; + case WM_SIZE: { + RECT rc; GetClientRect(hwnd, &rc); + int w = rc.right - rc.left, h = rc.bottom - rc.top; + if (w > 0 && h > 0) { + HRGN rgn = CreateRoundRectRgn(0, 0, w, h, 14, 14); + SetWindowRgn(hwnd, rgn, TRUE); + } + return 0; + } + case WM_ERASEBKGND: + // Properly fill background to avoid ghost artifacts + { + RECT rc; GetClientRect(hwnd, &rc); + HBRUSH white = static_cast(GetStockObject(WHITE_BRUSH)); + FillRect(reinterpret_cast(wp), &rc, white); + } + return 1; // non-zero = we handled it + case WM_DESTROY: { + if (st) { + KillTimer(hwnd, kHideTimerId); + if (st->ui_font) DeleteObject(st->ui_font); + delete st; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + } + return 0; + } + case WM_TIMER: + if (wp == kHideTimerId && st && !st->dragging) { + POINT pt; + GetCursorPos(&pt); + RECT pr, tr; + GetWindowRect(st->fullscreen_parent, &pr); + GetWindowRect(hwnd, &tr); + const int kEdgeDist = 10; + bool over_toolbar = PtInRect(&tr, pt); + bool near_edge = false; + switch (st->snap) { + case SnapEdge::Top: near_edge = (pt.y <= pr.top + kEdgeDist); break; + case SnapEdge::Bottom: near_edge = (pt.y >= pr.bottom - kEdgeDist); break; + case SnapEdge::Left: near_edge = (pt.x <= pr.left + kEdgeDist); break; + case SnapEdge::Right: near_edge = (pt.x >= pr.right - kEdgeDist); break; + } + if (over_toolbar || near_edge) { + KillTimer(hwnd, kHideTimerId); + st->hide_timer_id = SetTimer(hwnd, kHideTimerId, kAutoHideMs, nullptr); + } else { + KillTimer(hwnd, kHideTimerId); + st->hide_timer_id = 0; + HideBar(hwnd); + } + } + return 0; + case WM_MOUSEMOVE: + if (st && st->tooltip) { + MSG m{ hwnd, msg, wp, lp }; + SendMessageW(st->tooltip, TTM_RELAYEVENT, 0, reinterpret_cast(&m)); + } + // If in tab mode, expand on mouse touch + if (st && st->tab_mode) { + ShowBar(hwnd); + } + if (st && !st->pinned) { KillAutoHideTimer(hwnd); StartAutoHideTimer(hwnd); } + if (st && st->dragging) { + POINT pt{ GET_X_LPARAM(lp), GET_Y_LPARAM(lp) }; + ClientToScreen(hwnd, &pt); + int dx = pt.x - st->drag_start.x, dy = pt.y - st->drag_start.y; + RECT rc; GetWindowRect(hwnd, &rc); + SetWindowPos(hwnd, nullptr, rc.left + dx, rc.top + dy, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + st->drag_start = pt; + } + return 0; + case WM_LBUTTONDOWN: { + POINT pt{ GET_X_LPARAM(lp), GET_Y_LPARAM(lp) }; + HWND child = ChildWindowFromPoint(hwnd, pt); + if (st && (child == hwnd || !child || child == st->drag_handle)) { + st->dragging = true; + ClientToScreen(hwnd, &pt); + st->drag_start = pt; + SetCapture(hwnd); + } + return 0; + } + case WM_LBUTTONUP: + if (st && st->dragging) { st->dragging = false; ReleaseCapture(); SnapToNearestEdge(hwnd); } + return 0; + case WM_COMMAND: { + if (st && !st->pinned) { KillAutoHideTimer(hwnd); StartAutoHideTimer(hwnd); } + UINT id = LOWORD(wp); + switch (id) { + case kBtnDrag: + if (st && !st->dragging) { + st->dragging = true; + GetCursorPos(&st->drag_start); + SetCapture(hwnd); + } + return 0; + case kBtnVm: { + if (st->running_vm_ids.empty()) break; + HMENU menu = CreatePopupMenu(); + for (size_t i = 0; i < st->running_vm_ids.size(); ++i) { + UINT flags = MF_STRING; + if (st->running_vm_ids[i] == st->current_vm_id) flags |= MF_CHECKED; + std::wstring name = i18n::to_wide(st->running_vm_names[i]); + AppendMenuW(menu, flags, static_cast(1000 + i), name.c_str()); + } + RECT rc; GetWindowRect(st->btn_vm, &rc); + SetForegroundWindow(hwnd); + UINT sel = TrackPopupMenuEx(menu, + TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD, + rc.left, rc.bottom, hwnd, nullptr); + DestroyMenu(menu); + if (sel >= 1000 && st->switch_cb) { + size_t idx = sel - 1000; + if (idx < st->running_vm_ids.size()) + st->switch_cb(st->running_vm_ids[idx]); + } + return 0; + } + case kBtnDpi: + if (st && st->dpi_zoom_cb) st->dpi_zoom_cb(); + return 0; + case kBtnPin: + if (st) { + st->pinned = !st->pinned; + { + HINSTANCE hi = GetModuleHandleW(nullptr); + HBITMAP hbm = LoadBitmapW(hi, + MAKEINTRESOURCEW(st->pinned ? IDB_FS_PIN : IDB_FS_UNPIN)); + SendMessageW(st->btn_pin, BM_SETIMAGE, IMAGE_BITMAP, reinterpret_cast(hbm)); + } + if (!st->pinned) StartAutoHideTimer(hwnd); + else KillAutoHideTimer(hwnd); + if (st->pin_cb) st->pin_cb(st->pinned); + // Update tooltip + show OSD hint when unpinning + if (st->tooltip) { + TOOLINFOW ti{ sizeof(ti) }; + ti.uFlags = TTF_SUBCLASS | TTF_IDISHWND; + ti.hwnd = hwnd; + ti.uId = reinterpret_cast(st->btn_pin); + ti.lpszText = const_cast(st->pinned + ? L"\U0001F4CC 取消固定" + : L"\U0001F4CD 固定"); + SendMessageW(st->tooltip, TTM_UPDATETIPTEXTW, 0, reinterpret_cast(&ti)); + } + if (!st->pinned && st->fullscreen_parent) { + PostMessageW(st->fullscreen_parent, WM_FS_SHOW_HINT, 0, 0); + } + } + return 0; + case kBtnExit: + if (st && st->exit_cb) st->exit_cb(); + return 0; + } + return 0; + } + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + RECT rc; GetClientRect(hwnd, &rc); + // Double-buffer: draw to memory DC first, then blit to screen + HDC mem_dc = CreateCompatibleDC(hdc); + HBITMAP mem_bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); + HGDIOBJ old_bmp = SelectObject(mem_dc, mem_bmp); + // Background + HBRUSH white = static_cast(GetStockObject(WHITE_BRUSH)); + FillRect(mem_dc, &rc, white); + // Border + HPEN border = CreatePen(PS_SOLID, 1, RGB(180, 180, 180)); + HPEN old_pen = static_cast(SelectObject(mem_dc, border)); + HBRUSH old_br = static_cast(SelectObject(mem_dc, GetStockObject(NULL_BRUSH))); + RoundRect(mem_dc, rc.left, rc.top, rc.right, rc.bottom, 14, 14); + SelectObject(mem_dc, old_pen); + SelectObject(mem_dc, old_br); + DeleteObject(border); + // Blit to screen + BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem_dc, 0, 0, SRCCOPY); + SelectObject(mem_dc, old_bmp); + DeleteObject(mem_bmp); + DeleteDC(mem_dc); + EndPaint(hwnd, &ps); + return 0; + } + default: + return DefWindowProcW(hwnd, msg, wp, lp); + } +} diff --git a/src/manager/ui/win32_floating_toolbar.h b/src/manager/ui/win32_floating_toolbar.h new file mode 100644 index 0000000..dd70881 --- /dev/null +++ b/src/manager/ui/win32_floating_toolbar.h @@ -0,0 +1,84 @@ +#pragma once + +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include "manager/app_settings.h" + +// Topmost floating toolbar for fullscreen mode. +// Auto-hides after 3s when unpinned. Pinnable. Draggable; snaps to nearest screen edge. +class FloatingToolbar { +public: + using ExitCallback = std::function; + using PinCallback = std::function; + using SwitchCallback = std::function; + using DpiZoomCallback = std::function; + + static HWND Create(HINSTANCE hinst, HWND fullscreen_hwnd); + + static void RestoreState(HWND hwnd, const settings::FullscreenToolbarState& state); + static settings::FullscreenToolbarState SaveState(HWND hwnd); + + static void SetExitCallback(HWND hwnd, ExitCallback cb); + static void SetPinCallback(HWND hwnd, PinCallback cb); + static void SetSwitchCallback(HWND hwnd, SwitchCallback cb); + static void SetDpiZoomCallback(HWND hwnd, DpiZoomCallback cb); + + // Update displayed VM info and available VMs for dropdown. + static void SetVmInfo(HWND hwnd, const std::string& current_id, const std::string& name, + uint32_t width, uint32_t height, + const std::vector& running_ids, + const std::vector& running_names); + static void SetDpiZoomState(HWND hwnd, bool enabled); + + static void OnFullscreenDeactivated(HWND hwnd); + static void OnFullscreenActivated(HWND hwnd); + static void CheckMouseNearEdge(HWND hwnd, POINT cursor_screen); + +private: + static LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); + + enum class SnapEdge { Top, Bottom, Left, Right }; + + struct ToolbarState { + bool pinned = true; + bool snapped = false; // false = free-floating + SnapEdge snap = SnapEdge::Top; + int offset = -1; + POINT free_pos{}; // position when free-floating + bool tab_mode = false; // shrunk to icon-only tab at edge + ExitCallback exit_cb; + PinCallback pin_cb; + SwitchCallback switch_cb; + DpiZoomCallback dpi_zoom_cb; + HWND fullscreen_parent = nullptr; + HFONT ui_font = nullptr; + HWND drag_handle = nullptr; // grip icon for dragging + HWND btn_vm = nullptr; // dropdown button showing VM name + HWND btn_dpi = nullptr; + HWND btn_pin = nullptr; + HWND btn_exit = nullptr; + HWND tooltip = nullptr; + std::vector running_vm_ids; + std::vector running_vm_names; + std::string current_vm_id; + POINT drag_start{}; + bool dragging = false; + UINT_PTR hide_timer_id = 0; + int tb_width = 0; + int tb_height = 0; + }; + + static ToolbarState* GetState(HWND hwnd); + static void StartAutoHideTimer(HWND hwnd); + static void KillAutoHideTimer(HWND hwnd); + static void ShowBar(HWND hwnd); + static void HideBar(HWND hwnd); + static void LayoutButtons(HWND hwnd); + static void UpdatePosition(HWND hwnd); + static void SnapToNearestEdge(HWND hwnd); +}; diff --git a/src/manager/ui/win32_fullscreen_osd.cpp b/src/manager/ui/win32_fullscreen_osd.cpp new file mode 100644 index 0000000..266fb99 --- /dev/null +++ b/src/manager/ui/win32_fullscreen_osd.cpp @@ -0,0 +1,97 @@ +#include "manager/ui/win32_fullscreen_osd.h" + +namespace { +constexpr const wchar_t* kOsdClass = L"TenBoxFullscreenOsd"; +constexpr UINT_PTR kOsdTimerId = 1; +constexpr DWORD kOsdDurationMs = 1500; +} + +HWND FullscreenOsd::Create(HINSTANCE hinst, HWND parent) { + WNDCLASSEXW wc{ sizeof(wc) }; + wc.lpfnWndProc = WndProc; + wc.hInstance = hinst; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.hbrBackground = static_cast(GetStockObject(NULL_BRUSH)); + wc.lpszClassName = kOsdClass; + RegisterClassExW(&wc); + + return CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE | WS_EX_TOPMOST, + kOsdClass, L"", + WS_POPUP, + 0, 0, 600, 100, + parent, nullptr, hinst, nullptr); +} + +void FullscreenOsd::Show(HWND hwnd, const std::wstring& text) { + if (!hwnd) return; + SetWindowTextW(hwnd, text.c_str()); + + HWND parent = GetParent(hwnd); + RECT prc; + GetClientRect(parent, &prc); + int w = 600, h = 100; + int x = (prc.right - prc.left - w) / 2; + int y = (prc.bottom - prc.top - h) / 2; + + SetLayeredWindowAttributes(hwnd, 0, 200, LWA_ALPHA); + SetWindowPos(hwnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE | SWP_SHOWWINDOW); + + SetTimer(hwnd, kOsdTimerId, kOsdDurationMs, nullptr); + InvalidateRect(hwnd, nullptr, TRUE); +} + +LRESULT CALLBACK FullscreenOsd::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_CREATE: + return 0; + + case WM_TIMER: + if (wp == kOsdTimerId) { + KillTimer(hwnd, kOsdTimerId); + ShowWindow(hwnd, SW_HIDE); + } + return 0; + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + RECT rc; + GetClientRect(hwnd, &rc); + + HBRUSH bg = CreateSolidBrush(RGB(32, 32, 32)); + HPEN pen = CreatePen(PS_SOLID, 1, RGB(70, 70, 70)); + HBRUSH old_br = static_cast(SelectObject(hdc, bg)); + HPEN old_pen = static_cast(SelectObject(hdc, pen)); + RoundRect(hdc, rc.left, rc.top, rc.right, rc.bottom, 12, 12); + SelectObject(hdc, old_br); + SelectObject(hdc, old_pen); + DeleteObject(bg); + DeleteObject(pen); + + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, RGB(255, 255, 255)); + LOGFONTW lf{}; + HFONT stock = static_cast(GetStockObject(DEFAULT_GUI_FONT)); + GetObjectW(stock, sizeof(lf), &lf); + lf.lfHeight = -36; // 27pt + lf.lfWeight = FW_SEMIBOLD; + wcscpy_s(lf.lfFaceName, L"Segoe UI"); + HFONT font = CreateFontIndirectW(&lf); + HFONT old_font = static_cast(SelectObject(hdc, font)); + + std::wstring text(256, L'\0'); + GetWindowTextW(hwnd, &text[0], 256); + while (!text.empty() && text.back() == L'\0') text.pop_back(); + DrawTextW(hdc, text.c_str(), -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + SelectObject(hdc, old_font); + DeleteObject(font); + EndPaint(hwnd, &ps); + return 0; + } + + default: + return DefWindowProcW(hwnd, msg, wp, lp); + } +} diff --git a/src/manager/ui/win32_fullscreen_osd.h b/src/manager/ui/win32_fullscreen_osd.h new file mode 100644 index 0000000..411045e --- /dev/null +++ b/src/manager/ui/win32_fullscreen_osd.h @@ -0,0 +1,16 @@ +#pragma once + +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#include + +// Transient text overlay shown at center of fullscreen window on VM switch. +class FullscreenOsd { +public: + static HWND Create(HINSTANCE hinst, HWND parent); + static void Show(HWND hwnd, const std::wstring& text); + +private: + static LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); +}; diff --git a/src/manager/ui/win32_fullscreen_window.cpp b/src/manager/ui/win32_fullscreen_window.cpp new file mode 100644 index 0000000..2c87dbc --- /dev/null +++ b/src/manager/ui/win32_fullscreen_window.cpp @@ -0,0 +1,258 @@ +#include "manager/ui/win32_fullscreen_window.h" +#include "manager/ui/win32_display_panel.h" +#include "manager/ui/win32_floating_toolbar.h" +#include "manager/ui/win32_fullscreen_osd.h" +#include "manager/i18n.h" +#include +#include +#pragma comment(lib, "dwmapi.lib") + +namespace { +constexpr const wchar_t* kWndClass = L"TenBoxFullscreenWindow"; +} + +FullscreenWindow::FullscreenWindow() = default; + +FullscreenWindow::~FullscreenWindow() { + if (hwnd_ && IsWindow(hwnd_)) { + DestroyWindow(hwnd_); + } +} + +bool FullscreenWindow::Create(HINSTANCE hinst, HMONITOR monitor, + std::unique_ptr display_panel, + const std::string& current_vm_id, + ManagerService& manager) { + display_panel_ = std::move(display_panel); + current_vm_id_ = current_vm_id; + manager_ = &manager; + + WNDCLASSEXW wc{ sizeof(wc) }; + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = WndProc; + wc.hInstance = hinst; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.hbrBackground = reinterpret_cast(GetStockObject(BLACK_BRUSH)); + wc.lpszClassName = kWndClass; + RegisterClassExW(&wc); + + MONITORINFO mi{ sizeof(mi) }; + GetMonitorInfoW(monitor, &mi); + const RECT& mr = mi.rcMonitor; + int w = mr.right - mr.left; + int h = mr.bottom - mr.top; + + // Create hidden first so DWM never renders a default border + hwnd_ = CreateWindowExW(0, kWndClass, L"", + WS_POPUP | WS_THICKFRAME, + mr.left, mr.top, w, h, + nullptr, nullptr, hinst, this); + + if (!hwnd_) return false; + + MARGINS m{ 0, 0, 0, 1 }; + DwmExtendFrameIntoClientArea(hwnd_, &m); + COLORREF border_color = RGB(0, 0, 0); + DwmSetWindowAttribute(hwnd_, DWMWA_BORDER_COLOR, &border_color, sizeof(border_color)); + DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_DONOTROUND; + DwmSetWindowAttribute(hwnd_, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner)); + SetWindowPos(hwnd_, nullptr, 0, 0, 0, 0, + SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE | + SWP_FRAMECHANGED | SWP_NOSIZE | SWP_NOMOVE); + + SetWindowLongPtrW(hwnd_, GWLP_USERDATA, reinterpret_cast(this)); + + display_panel_->Reparent(hwnd_); + SetWindowPos(display_panel_->Handle(), nullptr, 0, 0, w, h, SWP_NOZORDER); + display_panel_->SetFullscreenMode(true); + display_panel_->SetHintOffsetY(60); + display_panel_->SetVisible(true); + + // Notify VM of new display size + { + RECT rc; + GetClientRect(hwnd_, &rc); + uint32_t disp_w = static_cast(rc.right) & ~7u; + uint32_t disp_h = static_cast(rc.bottom); + if (disp_w > 0 && disp_h > 0) { + manager.SetDisplaySize(current_vm_id_, disp_w, disp_h); + } + } + + toolbar_hwnd_ = FloatingToolbar::Create(hinst, hwnd_); + FloatingToolbar::SetExitCallback(toolbar_hwnd_, [this]() { Exit(); }); + + osd_hwnd_ = FullscreenOsd::Create(hinst, hwnd_); + + SetTimer(hwnd_, 1, 200, nullptr); + + ShowWindow(hwnd_, SW_SHOW); + SetForegroundWindow(hwnd_); + + return true; +} + +std::unique_ptr FullscreenWindow::ReleaseDisplayPanel() { + if (display_panel_) { + display_panel_->SetFullscreenMode(false); + if (hwnd_) display_panel_->Reparent(nullptr); + } + return std::move(display_panel_); +} + +void FullscreenWindow::Exit() { + if (exiting_) return; + exiting_ = true; + if (exit_cb_) exit_cb_(); +} + +void FullscreenWindow::ToggleDpiZoom(float dpi_factor) { + if (!display_panel_ || !hwnd_) return; + display_panel_->SetDpiZoomFactor(dpi_factor); + RECT rc; + GetClientRect(hwnd_, &rc); + uint32_t pw = rc.right & ~7u; + uint32_t ph = rc.bottom; + // When zoomed in, guest renders at lower resolution, then DisplayPanel scales up + uint32_t disp_w, disp_h; + if (dpi_factor > 1.0f) { + UINT dpi = GetDpiForWindow(hwnd_); + disp_w = static_cast(MulDiv(static_cast(pw), 96, static_cast(dpi))) & ~7u; + disp_h = static_cast(MulDiv(static_cast(ph), 96, static_cast(dpi))); + } else { + disp_w = pw; + disp_h = ph; + } + if (disp_w > 0 && disp_h > 0 && manager_) { + manager_->SetDisplaySize(current_vm_id_, disp_w, disp_h); + } + InvalidateRect(hwnd_, nullptr, TRUE); +} + +void FullscreenWindow::AdoptVmState(const std::vector& framebuffer, + uint32_t fb_width, uint32_t fb_height, + const CursorInfo& cursor, + const std::vector& cursor_pixels) { + if (display_panel_) { + display_panel_->RestoreFramebuffer(fb_width, fb_height, framebuffer); + display_panel_->RestoreCursor(cursor, cursor_pixels); + } +} + +void FullscreenWindow::ShowOsd(const std::wstring& text) { + if (osd_hwnd_) { + FullscreenOsd::Show(osd_hwnd_, text); + } +} + +void FullscreenWindow::SetExitCallback(ExitCallback cb) { exit_cb_ = std::move(cb); } +void FullscreenWindow::SetSwitchVmCallback(SwitchVmCallback cb) { switch_vm_cb_ = std::move(cb); } + +void FullscreenWindow::SwitchToVm(const std::string& vm_id) { + if (switch_vm_cb_) switch_vm_cb_(vm_id, false); +} + +std::vector FullscreenWindow::GetRunningVmIds() const { + std::vector ids; + if (!manager_) return ids; + auto vms = manager_->ListVms(); + for (const auto& vm : vms) { + if (vm.state == VmPowerState::kRunning) { + ids.push_back(vm.spec.vm_id); + } + } + return ids; +} + +void FullscreenWindow::RefreshVmList() { + // Toolbar will pick up changes via FloatingToolbar methods +} + +void FullscreenWindow::OnKeyDown(WPARAM wp, LPARAM lp) { + if (display_panel_ && display_panel_->IsCaptured()) return; +} + +LRESULT CALLBACK FullscreenWindow::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + auto* self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + + switch (msg) { + case WM_CREATE: + return 0; + + case WM_NCCALCSIZE: + if (wp) return 0; + break; + + case WM_NCACTIVATE: + // Suppress DWM white border on activation + lp = -1; + return DefWindowProcW(hwnd, msg, wp, lp); + + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (self) self->OnKeyDown(wp, lp); + return 0; + + case WM_TIMER: + if (self && self->toolbar_hwnd_) { + POINT pt; + GetCursorPos(&pt); + FloatingToolbar::CheckMouseNearEdge(self->toolbar_hwnd_, pt); + } + return 0; + + case WM_FS_SHOW_HINT: + if (self && self->osd_hwnd_) { + FullscreenOsd::Show(self->osd_hwnd_, + i18n::tr_w(i18n::S::kFullscreenFindToolbar).c_str()); + } + return 0; + + case WM_CLOSE: + if (self) self->Exit(); + return 0; + + case WM_DESTROY: + KillTimer(hwnd, 1); + return 0; + + case WM_ACTIVATE: + if (LOWORD(wp) == WA_INACTIVE) { + // Don't hide toolbar if it's the window gaining activation (e.g. user clicked it) + if (self && self->toolbar_hwnd_ && reinterpret_cast(lp) != self->toolbar_hwnd_) { + FloatingToolbar::OnFullscreenDeactivated(self->toolbar_hwnd_); + } + return 0; + } + { + LRESULT lr = DefWindowProcW(hwnd, msg, wp, lp); + if (self && self->display_panel_) { + SetFocus(self->display_panel_->Handle()); + } + if (self && self->toolbar_hwnd_) { + FloatingToolbar::OnFullscreenActivated(self->toolbar_hwnd_); + } + return lr; + } + + case WM_DISPLAYCHANGE: + // Re-anchor to current monitor on display config changes + if (self && self->hwnd_) { + HMONITOR mon = MonitorFromWindow(self->hwnd_, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{ sizeof(mi) }; + if (GetMonitorInfoW(mon, &mi)) { + const RECT& mr = mi.rcMonitor; + SetWindowPos(self->hwnd_, nullptr, + mr.left, mr.top, + mr.right - mr.left, + mr.bottom - mr.top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + } + return 0; + + default: + return DefWindowProcW(hwnd, msg, wp, lp); + } + return DefWindowProcW(hwnd, msg, wp, lp); +} diff --git a/src/manager/ui/win32_fullscreen_window.h b/src/manager/ui/win32_fullscreen_window.h new file mode 100644 index 0000000..d1469ff --- /dev/null +++ b/src/manager/ui/win32_fullscreen_window.h @@ -0,0 +1,73 @@ +#pragma once + +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include "manager/manager_service.h" + +class DisplayPanel; +struct CursorInfo; + +// Borderless fullscreen window that hosts a reparented DisplayPanel. +class FullscreenWindow { +public: + using ExitCallback = std::function; + using SwitchVmCallback = std::function; + + FullscreenWindow(); + ~FullscreenWindow(); + + // Create the fullscreen window on the specified monitor. + bool Create(HINSTANCE hinst, HMONITOR monitor, + std::unique_ptr display_panel, + const std::string& current_vm_id, + ManagerService& manager); + + void Exit(); + HWND Handle() const { return hwnd_; } + + void SetExitCallback(ExitCallback cb); + void SetSwitchVmCallback(SwitchVmCallback cb); + + void SwitchToVm(const std::string& vm_id); + void SetCurrentVmId(const std::string& id) { current_vm_id_ = id; } + + // Release ownership of the DisplayPanel (during exit fullscreen). + std::unique_ptr ReleaseDisplayPanel(); + + HWND GetToolbarHwnd() const { return toolbar_hwnd_; } + + // Adopt a different VM's cached framebuffer and cursor after switching. + void AdoptVmState(const std::vector& framebuffer, + uint32_t fb_width, uint32_t fb_height, + const CursorInfo& cursor, const std::vector& cursor_pixels); + + // Toggle DPI zoom and apply to DisplayPanel. + void ToggleDpiZoom(float dpi_factor); + + // Show transient OSD overlay with VM name. + void ShowOsd(const std::wstring& text); + + std::vector GetRunningVmIds() const; + void RefreshVmList(); + +private: + static LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); + + void OnKeyDown(WPARAM wp, LPARAM lp); + + HWND hwnd_ = nullptr; + HWND toolbar_hwnd_ = nullptr; + HWND osd_hwnd_ = nullptr; + std::unique_ptr display_panel_; + ManagerService* manager_ = nullptr; + std::string current_vm_id_; + bool exiting_ = false; + + ExitCallback exit_cb_; + SwitchVmCallback switch_vm_cb_; +}; diff --git a/src/manager/ui/win32_ui_shell.cpp b/src/manager/ui/win32_ui_shell.cpp index c0b25cb..86af66f 100644 --- a/src/manager/ui/win32_ui_shell.cpp +++ b/src/manager/ui/win32_ui_shell.cpp @@ -4,6 +4,8 @@ #include "manager/ui/settings_dialog.h" #include "manager/ui/llm_proxy_dialog.h" #include "manager/ui/win32_display_panel.h" +#include "manager/ui/win32_fullscreen_window.h" +#include "manager/ui/win32_floating_toolbar.h" #include "manager/ui/info_tab.h" #include "manager/ui/console_tab.h" #include "manager/ui/vm_listview.h" @@ -61,6 +63,7 @@ enum CmdId : UINT { IDM_LLM_PROXY = 1027, IDM_HELP_DOC = 1028, IDM_TRAY_TOGGLE = 1029, + IDM_FULLSCREEN = 1030, }; // ── Control IDs ── @@ -110,7 +113,7 @@ extern void ShowPortForwardsDialog(HWND parent, ManagerService& mgr, // ── Static singleton HWND (needed for InvokeOnUiThread) ── -static HWND g_main_hwnd = nullptr; +HWND g_main_hwnd = nullptr; static std::mutex g_invoke_mutex; static std::deque> g_invoke_queue; @@ -186,6 +189,12 @@ struct Win32UiShell::Impl { InfoTab info_tab; ConsoleTab console_tab; std::unique_ptr display_panel; + std::unique_ptr fullscreen_window; + RECT pre_fullscreen_rect{}; + bool in_fullscreen = false; + std::string display_callbacks_vm_id; + DWORD64 esc_tick = 0; + UINT_PTR esc_timer = 0; std::vector records; int selected_index = -1; @@ -333,6 +342,9 @@ static HMENU BuildMenuBar(bool show_toolbar) { AppendMenuW(bar, MF_POPUP, reinterpret_cast(vm_menu), i18n::tr_w(S::kMenuVm).c_str()); HMENU view_menu = CreatePopupMenu(); + AppendMenuW(view_menu, MF_STRING, IDM_FULLSCREEN, + i18n::tr_w(i18n::S::kMenuFullscreen).c_str()); + AppendMenuW(view_menu, MF_SEPARATOR, 0, nullptr); AppendMenuW(view_menu, MF_STRING | (show_toolbar ? MF_CHECKED : MF_UNCHECKED), IDM_VIEW_TOOLBAR, i18n::tr_w(S::kMenuViewToolbar).c_str()); AppendMenuW(bar, MF_POPUP, reinterpret_cast(view_menu), i18n::tr_w(S::kMenuView).c_str()); @@ -365,6 +377,8 @@ static HIMAGELIST CreateToolbarImageList(HINSTANCE hinst, int icon_size) { IDB_TOOLBAR_PORT_FORWARDS, IDB_TOOLBAR_DPI_ZOOM, IDB_TOOLBAR_LLM_PROXY, + IDB_TOOLBAR_FULLSCREEN, + IDB_TOOLBAR_FULLSCREEN_EXIT, }; HIMAGELIST hil = ImageList_Create( @@ -420,6 +434,7 @@ static HWND CreateToolbar(HWND parent, HINSTANCE hinst, UINT dpi) { {IDM_PORT_FORWARDS, i18n::tr(S::kToolbarPortForwards), 8, 0}, {0, nullptr, -1, 0}, {IDM_DPI_ZOOM, i18n::tr(S::kToolbarDpiZoom), 9, BTNS_CHECK}, + {IDM_FULLSCREEN, i18n::tr(S::kToolbarFullscreen), 11, 0}, {IDC_SPRING_SEP, nullptr, -1, 0}, {IDM_LLM_PROXY, i18n::tr(S::kToolbarLlmProxy), 10, 0}, }; @@ -771,6 +786,14 @@ static void UpdateCommandStates(Impl* p) { SendMessage(p->toolbar, TB_CHECKBUTTON, IDM_DPI_ZOOM, MAKELONG(dpi_scaled ? TRUE : FALSE, 0)); + SendMessage(p->toolbar, TB_ENABLEBUTTON, IDM_FULLSCREEN, + MAKELONG((has_sel && running) ? TRUE : FALSE, 0)); + HMENU view_menu = GetSubMenu(p->menu_bar, 2); + if (view_menu) { + EnableMenuItem(view_menu, IDM_FULLSCREEN, + (has_sel && running) ? MF_ENABLED : MF_GRAYED); + } + InvalidateRect(p->toolbar, nullptr, FALSE); p->console_tab.SetEnabled(running); @@ -1033,6 +1056,14 @@ static LRESULT CALLBACK MainWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { LayoutControls(p); return 0; } + case IDM_FULLSCREEN: { + if (!p->in_fullscreen) { + shell->EnterFullscreen(); + } else { + shell->ExitFullscreen(); + } + return 0; + } case IDM_EDIT: { if (p->selected_index < 0 || p->selected_index >= static_cast(p->records.size())) @@ -1268,13 +1299,13 @@ static LRESULT CALLBACK MainWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (event) { case NIN_SELECT: case NIN_KEYSELECT: { - // Tray click only ever shows / focuses the window; it never - // hides it. This matches the convention of common Windows tray - // apps (Discord, Steam, ...) and side-steps the v4 double-click - // flicker, since a 2nd NIN_SELECT on an already-visible window - // is a no-op. Hiding is done via the X button or the tray - // context menu's "Hide to Tray" item. - ShowMainWindow(p); + // If in fullscreen, bring fullscreen window to front; fallback to main + if (p->in_fullscreen && p->fullscreen_window && + IsWindow(p->fullscreen_window->Handle())) { + SetForegroundWindow(p->fullscreen_window->Handle()); + } else { + ShowMainWindow(p); + } return 0; } case WM_RBUTTONUP: @@ -1637,9 +1668,15 @@ Win32UiShell::Win32UiShell(ManagerService& manager) impl_->selected_index < static_cast(impl_->records.size()) && impl_->records[impl_->selected_index].spec.vm_id == vm_id); if (is_current) { - impl_->display_panel->AdoptFramebuffer( - state.fb_width, state.fb_height, - state.framebuffer.data(), state.framebuffer.size()); + if (impl_->in_fullscreen && impl_->fullscreen_window) { + impl_->fullscreen_window->AdoptVmState( + state.framebuffer, state.fb_width, state.fb_height, + state.cursor, state.cursor_pixels); + } else if (impl_->display_panel) { + impl_->display_panel->AdoptFramebuffer( + state.fb_width, state.fb_height, + state.framebuffer.data(), state.framebuffer.size()); + } } }); }); @@ -1656,7 +1693,13 @@ Win32UiShell::Win32UiShell(ManagerService& manager) impl_->selected_index < static_cast(impl_->records.size()) && impl_->records[impl_->selected_index].spec.vm_id == vm_id); if (is_current) { - impl_->display_panel->UpdateCursor(cursor); + if (impl_->in_fullscreen && impl_->fullscreen_window) { + impl_->fullscreen_window->AdoptVmState( + state.framebuffer, state.fb_width, state.fb_height, + cursor, state.cursor_pixels); + } else if (impl_->display_panel) { + impl_->display_panel->UpdateCursor(cursor); + } } }); }); @@ -1830,10 +1873,308 @@ void Win32UiShell::Hide() { ShowWindow(impl_->hwnd, SW_HIDE); } +// ── Fullscreen mode ── + +void Win32UiShell::EnterFullscreen() { + auto* p = impl_.get(); + if (p->in_fullscreen || !p->display_panel) return; + if (p->selected_index < 0 || p->selected_index >= static_cast(p->records.size())) return; + const auto& vm_id = p->records[p->selected_index].spec.vm_id; + + // Kill pending resize timer to prevent race condition + if (p->resize_timer_id) { + KillTimer(p->hwnd, p->resize_timer_id); + p->resize_timer_id = 0; + } + + GetWindowRect(p->hwnd, &p->pre_fullscreen_rect); + HMONITOR monitor = MonitorFromWindow(p->hwnd, MONITOR_DEFAULTTONEAREST); + + p->display_panel->Reparent(nullptr); + ShowWindow(p->display_panel->Handle(), SW_HIDE); + + auto fs = std::make_unique(); + HINSTANCE hinst = GetModuleHandle(nullptr); + auto dp = std::move(p->display_panel); + + if (!fs->Create(hinst, monitor, std::move(dp), vm_id, manager_)) { + p->display_panel = std::move(dp); + p->display_panel->Reparent(p->hwnd); + ShowWindow(p->display_panel->Handle(), SW_SHOW); + return; + } + + p->display_callbacks_vm_id = vm_id; + + // Wire toolbar callbacks + HWND tb = fs->GetToolbarHwnd(); + if (tb) { + FloatingToolbar::SetSwitchCallback(tb, [this, tb](const std::string& vm_id) { + InvokeOnUiThread([this, tb, vm_id]() { + auto* p2 = impl_.get(); + if (!p2->in_fullscreen || !p2->fullscreen_window) return; + auto vms = manager_.ListVms(); + int new_sel = -1; + for (int i = 0; i < static_cast(vms.size()); ++i) { + if (vms[i].spec.vm_id == vm_id) { new_sel = i; break; } + } + if (new_sel < 0) return; + const auto& spec = vms[new_sel].spec; + p2->selected_index = new_sel; + p2->display_callbacks_vm_id = spec.vm_id; + VmUiState& state = p2->GetVmUiState(spec.vm_id); + if (!state.framebuffer.empty()) { + p2->fullscreen_window->AdoptVmState( + state.framebuffer, state.fb_width, state.fb_height, + state.cursor, state.cursor_pixels); + } + p2->fullscreen_window->SetCurrentVmId(spec.vm_id); + float factor = spec.dpi_scaled ? (static_cast(p2->dpi) / 96.0f) : 1.0f; + p2->fullscreen_window->ToggleDpiZoom(factor); + + RECT rc; + GetClientRect(p2->fullscreen_window->Handle(), &rc); + uint32_t dw = static_cast(rc.right) & ~7u; + uint32_t dh = static_cast(rc.bottom); + if (spec.dpi_scaled && p2->dpi != 96) { + dw = static_cast(MulDiv(static_cast(dw), 96, static_cast(p2->dpi))) & ~7u; + dh = static_cast(MulDiv(static_cast(dh), 96, static_cast(p2->dpi))); + } + std::vector rids, rnames; + for (const auto& vm : vms) { + if (vm.state == VmPowerState::kRunning) { + rids.push_back(vm.spec.vm_id); + rnames.push_back(vm.spec.name); + } + } + FloatingToolbar::SetVmInfo(tb, spec.vm_id, spec.name, + dw, dh, rids, rnames); + FloatingToolbar::SetDpiZoomState(tb, spec.dpi_scaled); + p2->fullscreen_window->ShowOsd(i18n::to_wide(spec.name)); + }); + }); + FloatingToolbar::SetDpiZoomCallback(tb, [this, tb]() { + auto* p2 = impl_.get(); + if (p2->selected_index < 0 || p2->selected_index >= static_cast(p2->records.size())) return; + auto& spec = p2->records[p2->selected_index].spec; + bool new_val = !spec.dpi_scaled; + manager_.SetVmDpiScaled(spec.vm_id, new_val); + spec.dpi_scaled = new_val; + float factor = new_val ? (static_cast(p2->dpi) / 96.0f) : 1.0f; + if (p2->fullscreen_window) { + p2->fullscreen_window->ToggleDpiZoom(factor); + } + FloatingToolbar::SetDpiZoomState(tb, new_val); + // Update displayed resolution + { + RECT rc; + GetClientRect(p2->fullscreen_window->Handle(), &rc); + uint32_t pw = rc.right & ~7u; + uint32_t ph = rc.bottom; + uint32_t new_w, new_h; + if (new_val && p2->dpi != 96) { + new_w = static_cast(MulDiv(static_cast(pw), 96, static_cast(p2->dpi))) & ~7u; + new_h = static_cast(MulDiv(static_cast(ph), 96, static_cast(p2->dpi))); + } else { + new_w = pw; + new_h = ph; + } + std::vector rids, rnames; + auto vms = manager_.ListVms(); + for (const auto& vm : vms) { + if (vm.state == VmPowerState::kRunning) { + rids.push_back(vm.spec.vm_id); + rnames.push_back(vm.spec.name); + } + } + FloatingToolbar::SetVmInfo(tb, spec.vm_id, spec.name, new_w, new_h, rids, rnames); + } + }); + } + + // Initial toolbar VM info — compute expected fullscreen resolution + { + auto vms = manager_.ListVms(); + std::vector rids, rnames; + const auto* spec = &p->records[p->selected_index].spec; + for (const auto& vm : vms) { + if (vm.state == VmPowerState::kRunning) { + rids.push_back(vm.spec.vm_id); + rnames.push_back(vm.spec.name); + } + } + RECT fs_rc; + GetClientRect(fs->Handle(), &fs_rc); + uint32_t disp_w = static_cast(fs_rc.right) & ~7u; + uint32_t disp_h = static_cast(fs_rc.bottom); + if (spec->dpi_scaled && p->dpi != 96) { + disp_w = static_cast(MulDiv(static_cast(disp_w), 96, static_cast(p->dpi))) & ~7u; + disp_h = static_cast(MulDiv(static_cast(disp_h), 96, static_cast(p->dpi))); + } + FloatingToolbar::SetVmInfo(tb, spec->vm_id, spec->name, + disp_w, disp_h, rids, rnames); + FloatingToolbar::SetDpiZoomState(tb, spec->dpi_scaled); + } + + fs->SetExitCallback([this]() { + InvokeOnUiThread([this]() { + ExitFullscreen(); + }); + }); + + fs->SetSwitchVmCallback([this](const std::string& cur_vm_id, bool forward) { + InvokeOnUiThread([this, cur_vm_id, forward]() { + auto* p2 = impl_.get(); + if (!p2->in_fullscreen || !p2->fullscreen_window) return; + + auto vms = manager_.ListVms(); + std::vector ridx; + int ci = -1; + for (int i = 0; i < static_cast(vms.size()); ++i) { + if (vms[i].state == VmPowerState::kRunning) { + ridx.push_back(i); + if (vms[i].spec.vm_id == cur_vm_id) + ci = static_cast(ridx.size()) - 1; + } + } + if (ridx.empty()) return; + if (ci < 0) ci = 0; + else if (forward) ci = (ci + 1) % static_cast(ridx.size()); + else ci = (ci - 1 + static_cast(ridx.size())) % static_cast(ridx.size()); + + int new_sel = ridx[ci]; + const auto& spec = vms[new_sel].spec; + p2->selected_index = new_sel; + p2->display_callbacks_vm_id = spec.vm_id; + + VmUiState& state = p2->GetVmUiState(spec.vm_id); + if (!state.framebuffer.empty()) { + p2->fullscreen_window->AdoptVmState( + state.framebuffer, state.fb_width, state.fb_height, + state.cursor, state.cursor_pixels); + } + + // Apply DPI zoom factor to DisplayPanel and notify VM of display size + p2->fullscreen_window->SetCurrentVmId(spec.vm_id); + float factor = spec.dpi_scaled ? (static_cast(p2->dpi) / 96.0f) : 1.0f; + p2->fullscreen_window->ToggleDpiZoom(factor); + + HWND tb = p2->fullscreen_window->GetToolbarHwnd(); + if (tb) { + std::vector rids, rnames; + for (const auto& vm : vms) { + if (vm.state == VmPowerState::kRunning) { + rids.push_back(vm.spec.vm_id); + rnames.push_back(vm.spec.name); + } + } + // Compute display resolution for toolbar display + RECT rc; + GetClientRect(p2->fullscreen_window->Handle(), &rc); + uint32_t dw = static_cast(rc.right) & ~7u; + uint32_t dh = static_cast(rc.bottom); + if (spec.dpi_scaled && p2->dpi != 96) { + dw = static_cast(MulDiv(static_cast(dw), 96, static_cast(p2->dpi))) & ~7u; + dh = static_cast(MulDiv(static_cast(dh), 96, static_cast(p2->dpi))); + } + FloatingToolbar::SetVmInfo(tb, spec.vm_id, spec.name, + dw, dh, rids, rnames); + FloatingToolbar::SetDpiZoomState(tb, spec.dpi_scaled); + } + + p2->fullscreen_window->ShowOsd(i18n::to_wide(spec.name)); + }); + }); + + // Change fullscreen button icon + text to "exit fullscreen" + SendMessage(p->toolbar, TB_CHANGEBITMAP, IDM_FULLSCREEN, 12); + { + std::wstring text = i18n::tr_w(i18n::S::kFullscreenExit); + TBBUTTONINFOW tbi{ sizeof(tbi), TBIF_TEXT }; + tbi.pszText = text.data(); + tbi.cchText = static_cast(text.size()); + SendMessageW(p->toolbar, TB_SETBUTTONINFOW, IDM_FULLSCREEN, reinterpret_cast(&tbi)); + } + p->fullscreen_window = std::move(fs); + p->in_fullscreen = true; + ShowWindow(p->hwnd, SW_HIDE); +} + +void Win32UiShell::ExitFullscreen() { + auto* p = impl_.get(); + if (!p->in_fullscreen || !p->fullscreen_window) return; + + HWND tb = p->fullscreen_window->GetToolbarHwnd(); + if (tb) { + manager_.app_settings().fullscreen_toolbar = + FloatingToolbar::SaveState(tb); + manager_.SaveAppSettings(); + } + + auto dp = p->fullscreen_window->ReleaseDisplayPanel(); + p->display_panel = std::move(dp); + p->fullscreen_window.reset(); + + p->display_panel->SetHintOffsetY(0); + p->display_panel->Reparent(p->hwnd); + ShowWindow(p->display_panel->Handle(), SW_HIDE); + + ShowWindow(p->hwnd, SW_SHOW); + SetWindowPos(p->hwnd, nullptr, + p->pre_fullscreen_rect.left, p->pre_fullscreen_rect.top, + p->pre_fullscreen_rect.right - p->pre_fullscreen_rect.left, + p->pre_fullscreen_rect.bottom - p->pre_fullscreen_rect.top, + SWP_NOZORDER); + + LayoutControls(p); + // Restore fullscreen button icon + text + SendMessage(p->toolbar, TB_CHANGEBITMAP, IDM_FULLSCREEN, 11); + { + std::wstring text = i18n::tr_w(i18n::S::kToolbarFullscreen); + TBBUTTONINFOW tbi{ sizeof(tbi), TBIF_TEXT }; + tbi.pszText = text.data(); + tbi.cchText = static_cast(text.size()); + SendMessageW(p->toolbar, TB_SETBUTTONINFOW, IDM_FULLSCREEN, reinterpret_cast(&tbi)); + } + p->vm_listview.Populate(p->records, p->selected_index); + InvalidateRect(p->hwnd, nullptr, TRUE); + p->in_fullscreen = false; +} + void Win32UiShell::Run() { MSG msg; while (GetMessage(&msg, nullptr, 0, 0)) { bool forwarded = false; + + // ESC long press (2s) exits fullscreen — handled here before DispatchMessage + if (msg.message == WM_KEYDOWN && msg.wParam == VK_ESCAPE) { + auto* p = impl_.get(); + if (p->in_fullscreen && !p->esc_timer) { + p->esc_tick = GetTickCount64(); + p->esc_timer = SetTimer(impl_->hwnd, 3, 250, nullptr); + } + } + if (msg.message == WM_KEYUP && msg.wParam == VK_ESCAPE) { + auto* p = impl_.get(); + if (p->esc_timer) { + KillTimer(impl_->hwnd, p->esc_timer); + p->esc_timer = 0; + } + } + if (msg.message == WM_TIMER && msg.wParam == 3) { + auto* p = impl_.get(); + if (p->in_fullscreen && p->esc_timer) { + if (GetTickCount64() - p->esc_tick >= 2000) { + KillTimer(impl_->hwnd, p->esc_timer); + p->esc_timer = 0; + ExitFullscreen(); + } + } else { + KillTimer(impl_->hwnd, 3); + } + continue; + } + if (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP || msg.message == WM_SYSKEYDOWN || msg.message == WM_SYSKEYUP) { int cur_tab = static_cast(SendMessage(impl_->tab, TCM_GETCURSEL, 0, 0)); diff --git a/src/manager/ui/win32_ui_shell.h b/src/manager/ui/win32_ui_shell.h index a5e127f..46d815a 100644 --- a/src/manager/ui/win32_ui_shell.h +++ b/src/manager/ui/win32_ui_shell.h @@ -16,6 +16,8 @@ class Win32UiShell { void Run(); void Quit(); void RefreshVmList(); + void EnterFullscreen(); + void ExitFullscreen(); static void InvokeOnUiThread(std::function fn); static void SetClipboardFromVm(bool value);