From 213dcc2675806bf1e61291dd6ed0bb897c8d2fff Mon Sep 17 00:00:00 2001 From: "Herman S." <429230+has207@users.noreply.github.com> Date: Sat, 4 Oct 2025 12:22:24 +0900 Subject: [PATCH] [UI] Convert to Qt --- .github/workflows/CI.yml | 23 +- docs/building.md | 2 + premake5.lua | 38 ++ src/xenia/app/premake5.lua | 3 +- src/xenia/base/filesystem_win.cc | 8 +- src/xenia/gpu/premake5.lua | 7 + src/xenia/ui/d3d12/d3d12_presenter.cc | 6 + src/xenia/ui/file_picker_qt.cc | 105 ++++ src/xenia/ui/imgui_drawer.cc | 3 + src/xenia/ui/menu_item.cc | 2 +- src/xenia/ui/menu_item.h | 3 +- src/xenia/ui/moc_window_qt.cc | 0 src/xenia/ui/premake5.lua | 10 +- src/xenia/ui/presenter.cc | 22 +- src/xenia/ui/presenter.h | 5 + src/xenia/ui/window_gtk.cc | 4 +- src/xenia/ui/window_qt.cc | 799 ++++++++++++++++++++++++ src/xenia/ui/window_qt.h | 113 ++++ src/xenia/ui/windowed_app_context_qt.cc | 74 +++ src/xenia/ui/windowed_app_context_qt.h | 58 ++ src/xenia/ui/windowed_app_main_qt.cc | 74 +++ xenia-build.py | 195 +++++- 22 files changed, 1539 insertions(+), 15 deletions(-) create mode 100644 src/xenia/ui/file_picker_qt.cc create mode 100644 src/xenia/ui/moc_window_qt.cc create mode 100644 src/xenia/ui/window_qt.cc create mode 100644 src/xenia/ui/window_qt.h create mode 100644 src/xenia/ui/windowed_app_context_qt.cc create mode 100644 src/xenia/ui/windowed_app_context_qt.h create mode 100644 src/xenia/ui/windowed_app_main_qt.cc diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 922b32e01..46bf754ad 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -51,7 +51,7 @@ jobs: wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc sudo apt-add-repository "deb http://apt.llvm.org/${UBUNTU_BASE}/ llvm-toolchain-${UBUNTU_BASE}-20 main" sudo apt-get -y update - sudo apt-get -y install mesa-vulkan-drivers valgrind libc++-dev libc++abi-dev libgtk-3-dev libsdl2-dev libvulkan-dev libx11-xcb-dev clang-20 ninja-build spirv-tools + sudo apt-get -y install mesa-vulkan-drivers valgrind libc++-dev libc++abi-dev libgtk-3-dev libsdl2-dev libvulkan-dev libx11-xcb-dev clang-20 ninja-build spirv-tools qt6-base-dev # Verify spirv-opt is available which spirv-opt && spirv-opt --version || echo "Warning: spirv-opt not found in PATH" git submodule update --init --depth=1 -j$(getconf _NPROCESSORS_ONLN) $(grep -oP '(?<=path = )(?!third_party\/(DirectXShaderCompiler|FidelityFX-(CAS|FSR)|premake-(androidndk|export-compile-commands))).+' .gitmodules) @@ -103,6 +103,15 @@ jobs: restore-keys: | ${{ runner.os }}-vulkan-sdk- + - name: Cache Qt6 + id: cache-qt + uses: actions/cache@v3 + with: + path: C:\Qt + key: ${{ runner.os }}-qt6-6.8.1 + restore-keys: | + ${{ runner.os }}-qt6- + - name: Cache submodules id: cache-submodules uses: actions/cache@v3 @@ -133,6 +142,13 @@ jobs: echo "Warning: spirv-opt.exe not found at expected location" } + # Install Qt6 + $qtCacheHit = '${{ steps.cache-qt.outputs.cache-hit }}' + if ($qtCacheHit -ne 'true') { + pip install aqtinstall + aqt install-qt windows desktop 6.8.1 win64_msvc2022_64 -O C:\Qt + } + $cacheHit = '${{ steps.cache-submodules.outputs.cache-hit }}' if ($cacheHit -ne 'true') { git submodule update --init --depth=1 -j $env:NUMBER_OF_PROCESSORS (Select-String -CaseSensitive '(?<=path = )(?!third_party\/(FidelityFX-(CAS|FSR)|premake-(androidndk|cmake|export-compile-commands))).+' .gitmodules).Matches.Value @@ -148,8 +164,9 @@ jobs: echo "::error::$file is too small." } } - robocopy . build\bin\Windows\Release LICENSE /r:0 /w:0 - robocopy build\bin\Windows\Release artifacts\xenia_edge xenia_edge.exe xenia_edge.pdb LICENSE /r:0 /w:0 + robocopy . build\bin\Windows\Release LICENSE /r:0 /w:0 + # Copy all files and directories (Qt DLLs, plugins, etc.) excluding build artifacts + robocopy build\bin\Windows\Release artifacts\xenia_edge /MIR /r:0 /w:0 /XF *.lib *.idb *.exp /XD generic iconengines imageformats networkinformation styles tls If ($LastExitCode -le 7) { echo "LastExitCode = $LastExitCode";$LastExitCode = 0 } - name: Upload xenia edge artifacts if: steps.prepare_artifacts.outcome == 'success' diff --git a/docs/building.md b/docs/building.md index fae9946ed..89a62ea99 100644 --- a/docs/building.md +++ b/docs/building.md @@ -14,6 +14,8 @@ drivers. * Windows 11 SDK version 10.0.22000.0 (for Visual Studio 2022, this or any newer version) * [Python 3.9+ 64-bit](https://www.python.org/downloads/) * Ensure Python is in PATH. +* [Vulkan SDK](https://sdk.lunarg.com/sdk/download/latest/windows/vulkan-sdk.exe) + * The build script will automatically detect it if installed at `C:\VulkanSDK` ``` git clone https://github.com/has207/xenia-edge.git diff --git a/premake5.lua b/premake5.lua index df02a7f83..b62e56a14 100644 --- a/premake5.lua +++ b/premake5.lua @@ -133,6 +133,7 @@ filter("platforms:Linux") system("linux") toolset("clang") pkg_config.all("gtk+-x11-3.0") + pkg_config.all("Qt6Core Qt6Gui Qt6Widgets") links({ "stdc++fs", "dl", @@ -225,6 +226,8 @@ filter("platforms:Windows") toolset("msc") buildoptions({ "/utf-8", -- 'build correctly on systems with non-Latin codepages'. + "/Zc:__cplusplus", -- Enable correct __cplusplus macro value (required for Qt6). + "/Zc:preprocessor", -- Enable conformant preprocessor (supports #if in macro args). -- Disable warnings "/wd4201", -- Nameless struct/unions are ok. }) @@ -256,6 +259,41 @@ filter("platforms:Windows") "bcrypt", }) + local qt_dir = os.getenv("QT_DIR") + if qt_dir then + includedirs({ + path.join(qt_dir, "include"), + path.join(qt_dir, "include/QtCore"), + path.join(qt_dir, "include/QtGui"), + path.join(qt_dir, "include/QtWidgets"), + }) + libdirs({ + path.join(qt_dir, "lib"), + }) + end + +filter({"platforms:Windows", "configurations:Release"}) + local qt_dir = os.getenv("QT_DIR") + if qt_dir then + links({ + "Qt6Core", + "Qt6Gui", + "Qt6Widgets", + }) + end + +filter({"platforms:Windows", "configurations:Debug or Checked"}) + local qt_dir = os.getenv("QT_DIR") + if qt_dir then + links({ + "Qt6Cored", + "Qt6Guid", + "Qt6Widgetsd", + }) + end + +filter("platforms:Windows") + -- Embed the manifest for things like dependencies and DPI awareness. filter({"platforms:Windows", "kind:ConsoleApp or WindowedApp"}) files({ diff --git a/src/xenia/app/premake5.lua b/src/xenia/app/premake5.lua index 22f1e314b..8c144f141 100644 --- a/src/xenia/app/premake5.lua +++ b/src/xenia/app/premake5.lua @@ -43,7 +43,7 @@ project("xenia-app") local_platform_files() files({ "../base/main_init_"..platform_suffix..".cc", - "../ui/windowed_app_main_"..platform_suffix..".cc", + "../ui/windowed_app_main_qt.cc", }) resincludedirs({ @@ -85,6 +85,7 @@ project("xenia-app") files({ "main_resources.rc", }) + linkoptions({"/ENTRY:mainCRTStartup"}) filter({"architecture:x86_64", "files:../base/main_init_"..platform_suffix..".cc"}) vectorextensions("SSE2") -- Disable AVX for main_init_win.cc so our AVX check doesn't use AVX instructions. diff --git a/src/xenia/base/filesystem_win.cc b/src/xenia/base/filesystem_win.cc index 7a50d8b12..2a0251340 100644 --- a/src/xenia/base/filesystem_win.cc +++ b/src/xenia/base/filesystem_win.cc @@ -40,9 +40,11 @@ std::filesystem::path to_path(const std::u16string_view source) { namespace filesystem { std::filesystem::path GetExecutablePath() { - wchar_t* path; - auto error = _get_wpgmptr(&path); - return !error ? std::filesystem::path(path) : std::filesystem::path(); + // Don't use _get_wpgmptr in Qt apps - CRT may not be initialized properly + // Use GetModuleFileName directly instead + wchar_t buffer[MAX_PATH]; + DWORD len = GetModuleFileNameW(nullptr, buffer, MAX_PATH); + return len > 0 ? std::filesystem::path(buffer) : std::filesystem::path(); } std::filesystem::path GetExecutableFolder() { diff --git a/src/xenia/gpu/premake5.lua b/src/xenia/gpu/premake5.lua index c5b1770ff..015f5e3a2 100644 --- a/src/xenia/gpu/premake5.lua +++ b/src/xenia/gpu/premake5.lua @@ -51,6 +51,13 @@ project("xenia-gpu-shader-compiler") "../base/console_app_main_"..platform_suffix..".cc", }) + -- Include SPIRV-Tools headers from Vulkan SDK + filter("platforms:Windows") + includedirs({ + "$(VULKAN_SDK)/Include", + }) + filter({}) + filter("platforms:Windows") -- Only create the .user file if it doesn't already exist. local user_file = project_root.."/build/xenia-gpu-shader-compiler.vcxproj.user" diff --git a/src/xenia/ui/d3d12/d3d12_presenter.cc b/src/xenia/ui/d3d12/d3d12_presenter.cc index d1bbb34cb..6b4e8cc7c 100644 --- a/src/xenia/ui/d3d12/d3d12_presenter.cc +++ b/src/xenia/ui/d3d12/d3d12_presenter.cc @@ -1068,6 +1068,12 @@ Presenter::PaintResult D3D12Presenter::PaintAndPresentImpl( 0, DXGI_PRESENT_RESTART | (paint_context_.swap_chain_allows_tearing ? DXGI_PRESENT_ALLOW_TEARING : 0)); + static bool logged_present = false; + if (!logged_present) { + XELOGI("D3D12Presenter: First Present() call, result: 0x{:08X}", + present_result); + logged_present = true; + } // Even if presentation has failed, work might have been enqueued anyway // internally before the failure according to Jesse Natalie from the DirectX // Discord server. diff --git a/src/xenia/ui/file_picker_qt.cc b/src/xenia/ui/file_picker_qt.cc new file mode 100644 index 000000000..5e69274ab --- /dev/null +++ b/src/xenia/ui/file_picker_qt.cc @@ -0,0 +1,105 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/file_picker.h" + +#include + +#include +#include + +#include "xenia/base/assert.h" +#include "xenia/base/filesystem.h" +#include "xenia/base/logging.h" +#include "xenia/base/string.h" +#include "xenia/ui/window_qt.h" + +namespace xe { +namespace ui { + +class QtFilePicker : public FilePicker { + public: + QtFilePicker(); + ~QtFilePicker() override; + + bool Show(Window* parent_window) override; + + private: +}; + +std::unique_ptr FilePicker::Create() { + return std::make_unique(); +} + +QtFilePicker::QtFilePicker() = default; + +QtFilePicker::~QtFilePicker() = default; + +bool QtFilePicker::Show(Window* parent_window) { + QString title; + QFileDialog::FileMode file_mode; + QFileDialog::AcceptMode accept_mode; + + switch (mode()) { + case Mode::kOpen: + if (type() == Type::kFile) { + title = "Open File"; + file_mode = QFileDialog::ExistingFile; + } else { + title = "Open Directory"; + file_mode = QFileDialog::Directory; + } + accept_mode = QFileDialog::AcceptOpen; + break; + case Mode::kSave: + title = "Save File"; + file_mode = QFileDialog::AnyFile; + accept_mode = QFileDialog::AcceptSave; + break; + default: + XELOGE("QtFilePicker::Show: Unhandled mode: {}, Type: {}", + static_cast(mode()), static_cast(type())); + assert_always(); + return false; + } + + auto* qt_window = + parent_window ? dynamic_cast(parent_window) : nullptr; + + // Use static QFileDialog function to avoid Qt container ABI issues on Windows + QString initial_dir; + if (!initial_directory().empty()) { + std::string dir_str = initial_directory().string(); + initial_dir = + QString::fromUtf8(dir_str.c_str(), static_cast(dir_str.size())); + } + + QString file_path = QFileDialog::getOpenFileName( + qt_window ? qt_window->qwindow() : nullptr, title, initial_dir, + QString(), // filter + nullptr, // selected filter + QFileDialog::DontUseNativeDialog // Use Qt dialog to avoid native API + // issues + ); + + if (!file_path.isEmpty()) { + QByteArray utf8_bytes = file_path.toUtf8(); + std::string file_path_str(utf8_bytes.constData(), utf8_bytes.size()); + + std::vector selected_files; + selected_files.push_back(xe::to_path(file_path_str)); + set_selected_files(selected_files); + return true; + } + + return false; +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/imgui_drawer.cc b/src/xenia/ui/imgui_drawer.cc index 65b929cf4..0426b4eb0 100644 --- a/src/xenia/ui/imgui_drawer.cc +++ b/src/xenia/ui/imgui_drawer.cc @@ -830,6 +830,9 @@ void ImGuiDrawer::OnKey(KeyEvent& e, bool is_down) { default: break; } + if (presenter_) { + presenter_->RequestUIPaintFromUIThread(); + } } void ImGuiDrawer::UpdateMousePosition(float x, float y) { diff --git a/src/xenia/ui/menu_item.cc b/src/xenia/ui/menu_item.cc index 225ca6444..161e54f91 100644 --- a/src/xenia/ui/menu_item.cc +++ b/src/xenia/ui/menu_item.cc @@ -60,7 +60,7 @@ void MenuItem::RemoveChild(MenuItem* child_item) { } } -MenuItem* MenuItem::child(size_t index) { return children_[index].get(); } +MenuItem* MenuItem::child(size_t index) const { return children_[index].get(); } void MenuItem::OnSelected() { if (callback_) { diff --git a/src/xenia/ui/menu_item.h b/src/xenia/ui/menu_item.h index 01bcbe5c5..151a66197 100644 --- a/src/xenia/ui/menu_item.h +++ b/src/xenia/ui/menu_item.h @@ -57,7 +57,8 @@ class MenuItem { void AddChild(std::unique_ptr child_item); void AddChild(MenuItemPtr child_item); void RemoveChild(MenuItem* child_item); - MenuItem* child(size_t index); + MenuItem* child(size_t index) const; + size_t child_count() const { return children_.size(); } virtual void SetEnabled(bool enabled) {} diff --git a/src/xenia/ui/moc_window_qt.cc b/src/xenia/ui/moc_window_qt.cc new file mode 100644 index 000000000..e69de29bb diff --git a/src/xenia/ui/premake5.lua b/src/xenia/ui/premake5.lua index 6380b65cc..0b36e3be1 100644 --- a/src/xenia/ui/premake5.lua +++ b/src/xenia/ui/premake5.lua @@ -12,7 +12,15 @@ project("xenia-ui") local_platform_files() removefiles({ "*_demo.cc", - "windowed_app_main_*.cc", + -- Exclude old platform-specific implementations in favor of Qt + "window_gtk.cc", + "window_win.cc", + "file_picker_gtk.cc", + "file_picker_win.cc", + "windowed_app_context_gtk.cc", + "windowed_app_context_win.cc", + "windowed_app_main_posix.cc", + "windowed_app_main_win.cc", }) if os.istarget("android") then filter("platforms:Android-*") diff --git a/src/xenia/ui/presenter.cc b/src/xenia/ui/presenter.cc index d53a08120..b77961319 100644 --- a/src/xenia/ui/presenter.cc +++ b/src/xenia/ui/presenter.cc @@ -16,6 +16,7 @@ #include "xenia/ui/window.h" #if XE_PLATFORM_WIN32 +#include "xenia/ui/surface_win.h" #include "xenia/ui/window_win.h" #endif @@ -1208,7 +1209,12 @@ void Presenter::UpdateSurfaceMonitorFromUIThread( #if XE_PLATFORM_WIN32 HMONITOR surface_new_win32_monitor = nullptr; if (surface_) { - HWND hwnd = static_cast(window_)->hwnd(); + // Get HWND from the surface instead of assuming Win32Window + // This supports both Win32Window and QtWindow + HWND hwnd = nullptr; + if (surface_->GetType() == Surface::kTypeIndex_Win32Hwnd) { + hwnd = static_cast(surface_)->hwnd(); + } // The HWND may be non-existent if the window has been closed and destroyed // (the HWND, not the xe::ui::Window) already. if (hwnd) { @@ -1382,6 +1388,20 @@ void Presenter::WaitForUITickFromUIThread() { } dxgi_ui_tick_signal_condition_.wait(dxgi_ui_tick_lock); } +#elif XE_PLATFORM_LINUX + if (!AreUITicksNeededFromUIThread()) { + return; + } + // On Linux, implement timer-based rate limiting at 60 FPS to match game frame + // rate. + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast( + now - linux_ui_tick_last_paint_time_); + constexpr auto frame_time = std::chrono::microseconds(16667); // ~60 FPS + if (elapsed < frame_time) { + std::this_thread::sleep_for(frame_time - elapsed); + } + linux_ui_tick_last_paint_time_ = std::chrono::steady_clock::now(); #endif // XE_PLATFORM } diff --git a/src/xenia/ui/presenter.h b/src/xenia/ui/presenter.h index 9862546ab..a0b64a829 100644 --- a/src/xenia/ui/presenter.h +++ b/src/xenia/ui/presenter.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -956,6 +957,10 @@ class Presenter { std::array guest_output_properties_; + +#if XE_PLATFORM_LINUX + std::chrono::steady_clock::time_point linux_ui_tick_last_paint_time_; +#endif // Accessible only by refreshing, whether the last refresh contained an image // rather than being blank. bool guest_output_active_last_refresh_ = false; diff --git a/src/xenia/ui/window_gtk.cc b/src/xenia/ui/window_gtk.cc index 233ae94d3..1d33ba9c0 100644 --- a/src/xenia/ui/window_gtk.cc +++ b/src/xenia/ui/window_gtk.cc @@ -484,8 +484,6 @@ VirtualKey GTKWindow::TranslateVirtualKey(guint keyval) { return VirtualKey::kSpace; case GDK_KEY_Caps_Lock: return VirtualKey::kCapital; - case GDK_KEY_Escape: - return VirtualKey::kEscape; case GDK_KEY_F1: return VirtualKey::kF1; case GDK_KEY_F2: @@ -521,6 +519,8 @@ VirtualKey GTKWindow::TranslateVirtualKey(guint keyval) { case GDK_KEY_Pause: case GDK_KEY_Break: return VirtualKey::kPause; + case GDK_KEY_Escape: + return VirtualKey::kEscape; default: XELOGW("Unhandled key code: {}", keyval); return VirtualKey(keyval); diff --git a/src/xenia/ui/window_qt.cc b/src/xenia/ui/window_qt.cc new file mode 100644 index 000000000..57e1fa62c --- /dev/null +++ b/src/xenia/ui/window_qt.cc @@ -0,0 +1,799 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/window_qt.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "xenia/base/assert.h" +#include "xenia/base/logging.h" +#include "xenia/ui/virtual_key.h" +#include "xenia/ui/windowed_app_context_qt.h" + +#if defined(XE_PLATFORM_LINUX) +#include +#include "xenia/ui/surface_gnulinux.h" +#elif defined(XE_PLATFORM_WIN32) +#include "xenia/ui/surface_win.h" +#endif + +namespace xe { +namespace ui { + +// Custom widget for external rendering (Vulkan/D3D12) +class ExternalRenderWidget : public QWidget { + public: + explicit ExternalRenderWidget(QtWindow* window, QWidget* parent = nullptr) + : QWidget(parent), window_(window) { + setAttribute(Qt::WA_PaintOnScreen); + setAttribute(Qt::WA_NoSystemBackground); + setAttribute(Qt::WA_OpaquePaintEvent); + + // Enable mouse tracking for ImGui hover + setMouseTracking(true); + // Don't accept keyboard focus - let the main window handle keyboard events + setFocusPolicy(Qt::NoFocus); + } + + QPaintEngine* paintEngine() const override { + // Return nullptr to indicate external rendering + return nullptr; + } + +#if defined(XE_PLATFORM_WIN32) + bool nativeEvent(const QByteArray& eventType, void* message, + qintptr* result) override { + if (eventType == "windows_generic_MSG") { + MSG* msg = static_cast(message); + if (msg->message == WM_ERASEBKGND) { + // Don't erase background - D3D12 handles all rendering + *result = 1; + return true; + } + } + return QWidget::nativeEvent(eventType, message, result); + } +#endif + + protected: + void paintEvent(QPaintEvent*) override { + // Trigger the presenter to paint (Vulkan/D3D12 renders directly) + if (window_) { + window_->TriggerPaint(); + } + } + + void mouseMoveEvent(QMouseEvent* event) override { + if (window_) { + window_->OnMouseMoveEvent(event); + } + } + + void mousePressEvent(QMouseEvent* event) override { + if (window_) { + window_->OnMousePressEvent(event); + } + event->accept(); + } + + void mouseReleaseEvent(QMouseEvent* event) override { + if (window_) { + window_->OnMouseReleaseEvent(event); + } + event->accept(); + } + + void wheelEvent(QWheelEvent* event) override { + if (window_) { + window_->OnWheelEvent(event); + } + } + + private: + QtWindow* window_; +}; + +class QtWindowInternal : public QMainWindow { + public: + explicit QtWindowInternal(QtWindow* window) : window_(window) {} + + protected: + void closeEvent(QCloseEvent* event) override { window_->OnCloseEvent(event); } + void resizeEvent(QResizeEvent* event) override { + QMainWindow::resizeEvent(event); + window_->OnResizeEvent(event); + } + void keyPressEvent(QKeyEvent* event) override { + window_->OnKeyPressEvent(event); + } + void keyReleaseEvent(QKeyEvent* event) override { + window_->OnKeyReleaseEvent(event); + } + bool eventFilter(QObject* obj, QEvent* event) override { + return window_->OnEventFilter(obj, event); + } + + private: + QtWindow* window_; +}; + +std::unique_ptr MenuItem::Create(Type type, const std::string& text, + const std::string& hotkey, + std::function callback) { + return std::make_unique(type, text, hotkey, callback); +} + +std::unique_ptr Window::Create(WindowedAppContext& app_context, + const std::string_view title, + uint32_t desired_logical_width, + uint32_t desired_logical_height) { + return std::make_unique(app_context, title, desired_logical_width, + desired_logical_height); +} + +QtWindow::QtWindow(WindowedAppContext& app_context, + const std::string_view title, uint32_t desired_logical_width, + uint32_t desired_logical_height) + : Window(app_context, title, desired_logical_width, + desired_logical_height) {} + +QtWindow::~QtWindow() { + EnterDestructor(); + if (cursor_auto_hide_timer_) { + cursor_auto_hide_timer_->stop(); + delete cursor_auto_hide_timer_; + cursor_auto_hide_timer_ = nullptr; + } + if (qwindow_) { + delete qwindow_; + qwindow_ = nullptr; + drawing_widget_ = nullptr; + } +} + +bool QtWindow::OpenImpl() { + qwindow_ = new QtWindowInternal(this); + qwindow_->setWindowTitle( + QString::fromUtf8(GetTitle().data(), GetTitle().size())); + + QWidget* central_widget = new QWidget(qwindow_); + qwindow_->setCentralWidget(central_widget); + + QVBoxLayout* layout = new QVBoxLayout(central_widget); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + drawing_widget_ = new ExternalRenderWidget(this, central_widget); + drawing_widget_->setMinimumSize(GetDesiredLogicalWidth(), + GetDesiredLogicalHeight()); + + // Force creation of native window for external rendering + drawing_widget_->setAttribute(Qt::WA_NativeWindow); + drawing_widget_->setAutoFillBackground(false); + + drawing_widget_->installEventFilter(qwindow_); + + layout->addWidget(drawing_widget_); + + const auto* main_menu = dynamic_cast(GetMainMenu()); + if (main_menu) { + // For kNormal type (root menu), add each child menu to the menu bar + for (size_t i = 0; i < main_menu->child_count(); ++i) { + auto* child = dynamic_cast(main_menu->child(i)); + if (child && child->menu()) { + qwindow_->menuBar()->addMenu(child->menu()); + } + } + } + + if (IsFullscreen()) { + qwindow_->showFullScreen(); + } else { + qwindow_->show(); + } + + // Ensure native window is created by accessing winId() + // This must be done after show() so Qt creates the platform window + drawing_widget_->winId(); + + drawing_widget_->setMinimumSize(0, 0); + + { + WindowDestructionReceiver destruction_receiver(this); + + // Notify that the window is on a monitor (required for presenter to paint) + MonitorUpdateEvent monitor_event(this, false); + OnMonitorUpdate(monitor_event); + + OnActualSizeUpdate(uint32_t(drawing_widget_->width()), + uint32_t(drawing_widget_->height()), + destruction_receiver); + if (destruction_receiver.IsWindowDestroyedOrClosed()) { + return true; + } + + if (qwindow_->isActiveWindow()) { + OnFocusUpdate(true, destruction_receiver); + if (destruction_receiver.IsWindowDestroyedOrClosed()) { + return true; + } + } + } + + // Initialize cursor visibility state + cursor_currently_auto_hidden_ = false; + CursorVisibility cursor_visibility = GetCursorVisibility(); + if (cursor_visibility == CursorVisibility::kAutoHidden) { + cursor_auto_hide_last_pos_ = QCursor::pos(); + cursor_currently_auto_hidden_ = true; + UpdateCursorVisibility(); + } else if (cursor_visibility == CursorVisibility::kHidden) { + UpdateCursorVisibility(); + } + + return true; +} + +void QtWindow::RequestCloseImpl() { + if (qwindow_) qwindow_->close(); +} + +void QtWindow::ApplyNewFullscreen() { + if (!qwindow_) return; + WindowDestructionReceiver destruction_receiver(this); + + if (IsFullscreen()) { + qwindow_->menuBar()->hide(); + qwindow_->showFullScreen(); + } else { + qwindow_->showNormal(); + if (GetMainMenu()) { + qwindow_->menuBar()->show(); + } + } + + if (!destruction_receiver.IsWindowDestroyedOrClosed()) { + HandleSizeUpdate(destruction_receiver); + } +} + +void QtWindow::ApplyNewTitle() { + if (qwindow_) { + qwindow_->setWindowTitle( + QString::fromUtf8(GetTitle().data(), GetTitle().size())); + } +} + +void QtWindow::ApplyNewMainMenu(MenuItem* old_main_menu) { + if (!qwindow_) return; + + // Clear old menu + if (old_main_menu) { + qwindow_->menuBar()->clear(); + } + + // Add new menu children + const auto* new_main_menu = dynamic_cast(GetMainMenu()); + if (new_main_menu) { + for (size_t i = 0; i < new_main_menu->child_count(); ++i) { + auto* child = dynamic_cast(new_main_menu->child(i)); + if (child && child->menu()) { + qwindow_->menuBar()->addMenu(child->menu()); + } + } + } + + qwindow_->menuBar()->setVisible(GetMainMenu() != nullptr && !IsFullscreen()); +} + +void QtWindow::ApplyNewCursorVisibility( + CursorVisibility old_cursor_visibility) { + CursorVisibility new_cursor_visibility = GetCursorVisibility(); + cursor_currently_auto_hidden_ = false; + + if (new_cursor_visibility == CursorVisibility::kAutoHidden) { + cursor_auto_hide_last_pos_ = QCursor::pos(); + cursor_currently_auto_hidden_ = true; + } else if (old_cursor_visibility == CursorVisibility::kAutoHidden) { + if (cursor_auto_hide_timer_) { + cursor_auto_hide_timer_->stop(); + delete cursor_auto_hide_timer_; + cursor_auto_hide_timer_ = nullptr; + } + } + + UpdateCursorVisibility(); +} + +void QtWindow::FocusImpl() { + if (qwindow_) { + qwindow_->activateWindow(); + qwindow_->raise(); + } +} + +std::unique_ptr QtWindow::CreateSurfaceImpl( + Surface::TypeFlags allowed_types) { + if (!drawing_widget_) { + XELOGE("CreateSurfaceImpl: drawing_widget_ is null"); + return nullptr; + } + +#if defined(XE_PLATFORM_LINUX) + if (allowed_types & Surface::kTypeFlag_XcbWindow) { + auto* x11App = qApp->nativeInterface(); + if (!x11App) { + XELOGE("CreateSurfaceImpl: QX11Application is null"); + return nullptr; + } + xcb_connection_t* connection = x11App->connection(); + if (!connection) { + XELOGE("CreateSurfaceImpl: XCB connection is null"); + return nullptr; + } + xcb_window_t window = static_cast(drawing_widget_->winId()); + XELOGI("CreateSurfaceImpl: Created XCB surface with window ID: 0x{:x}", + window); + return std::make_unique(connection, window); + } + XELOGE("CreateSurfaceImpl: XcbWindow not in allowed_types ({})", + allowed_types); + return nullptr; +#elif defined(XE_PLATFORM_WIN32) + if (allowed_types & Surface::kTypeFlag_Win32Hwnd) { + auto& qt_context = static_cast(app_context()); + HWND hwnd = reinterpret_cast(drawing_widget_->winId()); + if (!hwnd || !IsWindow(hwnd)) { + XELOGE("CreateSurfaceImpl: Invalid HWND from drawing_widget_->winId()"); + return nullptr; + } + return std::make_unique(qt_context.hinstance(), hwnd); + } + return nullptr; +#else + return nullptr; +#endif +} + +void QtWindow::RequestPaintImpl() { + // Trigger a paint event which will call OnPaint() via + // ExternalRenderWidget::paintEvent + if (drawing_widget_) { + drawing_widget_->update(); + } +} + +void QtWindow::HandleSizeUpdate( + WindowDestructionReceiver& destruction_receiver) { + if (in_size_update_ || !drawing_widget_) { + return; + } + + in_size_update_ = true; + OnActualSizeUpdate(uint32_t(drawing_widget_->width()), + uint32_t(drawing_widget_->height()), destruction_receiver); + in_size_update_ = false; +} + +VirtualKey QtWindow::TranslateVirtualKey(int qt_key) { + switch (qt_key) { + case Qt::Key_Backspace: + return VirtualKey::kBack; + case Qt::Key_Tab: + return VirtualKey::kTab; + case Qt::Key_Return: + case Qt::Key_Enter: + return VirtualKey::kReturn; + case Qt::Key_Shift: + return VirtualKey::kShift; + case Qt::Key_Control: + return VirtualKey::kControl; + case Qt::Key_Alt: + return VirtualKey::kMenu; + case Qt::Key_Pause: + return VirtualKey::kPause; + case Qt::Key_CapsLock: + return VirtualKey::kCapital; + case Qt::Key_Escape: + return VirtualKey::kEscape; + case Qt::Key_Space: + return VirtualKey::kSpace; + case Qt::Key_PageUp: + return VirtualKey::kPrior; + case Qt::Key_PageDown: + return VirtualKey::kNext; + case Qt::Key_End: + return VirtualKey::kEnd; + case Qt::Key_Home: + return VirtualKey::kHome; + case Qt::Key_Left: + return VirtualKey::kLeft; + case Qt::Key_Up: + return VirtualKey::kUp; + case Qt::Key_Right: + return VirtualKey::kRight; + case Qt::Key_Down: + return VirtualKey::kDown; + case Qt::Key_Insert: + return VirtualKey::kInsert; + case Qt::Key_Delete: + return VirtualKey::kDelete; + case Qt::Key_0: + return VirtualKey::k0; + case Qt::Key_1: + return VirtualKey::k1; + case Qt::Key_2: + return VirtualKey::k2; + case Qt::Key_3: + return VirtualKey::k3; + case Qt::Key_4: + return VirtualKey::k4; + case Qt::Key_5: + return VirtualKey::k5; + case Qt::Key_6: + return VirtualKey::k6; + case Qt::Key_7: + return VirtualKey::k7; + case Qt::Key_8: + return VirtualKey::k8; + case Qt::Key_9: + return VirtualKey::k9; + case Qt::Key_A: + return VirtualKey::kA; + case Qt::Key_B: + return VirtualKey::kB; + case Qt::Key_C: + return VirtualKey::kC; + case Qt::Key_D: + return VirtualKey::kD; + case Qt::Key_E: + return VirtualKey::kE; + case Qt::Key_F: + return VirtualKey::kF; + case Qt::Key_G: + return VirtualKey::kG; + case Qt::Key_H: + return VirtualKey::kH; + case Qt::Key_I: + return VirtualKey::kI; + case Qt::Key_J: + return VirtualKey::kJ; + case Qt::Key_K: + return VirtualKey::kK; + case Qt::Key_L: + return VirtualKey::kL; + case Qt::Key_M: + return VirtualKey::kM; + case Qt::Key_N: + return VirtualKey::kN; + case Qt::Key_O: + return VirtualKey::kO; + case Qt::Key_P: + return VirtualKey::kP; + case Qt::Key_Q: + return VirtualKey::kQ; + case Qt::Key_R: + return VirtualKey::kR; + case Qt::Key_S: + return VirtualKey::kS; + case Qt::Key_T: + return VirtualKey::kT; + case Qt::Key_U: + return VirtualKey::kU; + case Qt::Key_V: + return VirtualKey::kV; + case Qt::Key_W: + return VirtualKey::kW; + case Qt::Key_X: + return VirtualKey::kX; + case Qt::Key_Y: + return VirtualKey::kY; + case Qt::Key_Z: + return VirtualKey::kZ; + case Qt::Key_F1: + return VirtualKey::kF1; + case Qt::Key_F2: + return VirtualKey::kF2; + case Qt::Key_F3: + return VirtualKey::kF3; + case Qt::Key_F4: + return VirtualKey::kF4; + case Qt::Key_F5: + return VirtualKey::kF5; + case Qt::Key_F6: + return VirtualKey::kF6; + case Qt::Key_F7: + return VirtualKey::kF7; + case Qt::Key_F8: + return VirtualKey::kF8; + case Qt::Key_F9: + return VirtualKey::kF9; + case Qt::Key_F10: + return VirtualKey::kF10; + case Qt::Key_F11: + return VirtualKey::kF11; + case Qt::Key_F12: + return VirtualKey::kF12; + default: + return VirtualKey::kNone; + } +} + +bool QtWindow::OnEventFilter(QObject* obj, QEvent* event) { + // With native window rendering, we don't need to handle paint events + // The presenter renders directly to the native surface + return false; +} + +void QtWindow::OnCloseEvent(QCloseEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + OnBeforeClose(destruction_receiver); + if (destruction_receiver.IsWindowDestroyedOrClosed()) { + event->accept(); + return; + } + + OnAfterClose(); + event->accept(); +} + +void QtWindow::OnResizeEvent(QResizeEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + HandleSizeUpdate(destruction_receiver); +} + +void QtWindow::OnKeyPressEvent(QKeyEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + + KeyEvent e(this, TranslateVirtualKey(event->key()), 1, false, + event->modifiers() & Qt::ShiftModifier, + event->modifiers() & Qt::ControlModifier, + event->modifiers() & Qt::AltModifier, false); + OnKeyDown(e, destruction_receiver); + + if (!event->text().isEmpty()) { + KeyEvent char_event(this, VirtualKey(event->text()[0].unicode()), 1, false, + event->modifiers() & Qt::ShiftModifier, + event->modifiers() & Qt::ControlModifier, + event->modifiers() & Qt::AltModifier, false); + OnKeyChar(char_event, destruction_receiver); + } +} + +void QtWindow::OnKeyReleaseEvent(QKeyEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + + KeyEvent e(this, TranslateVirtualKey(event->key()), 1, true, + event->modifiers() & Qt::ShiftModifier, + event->modifiers() & Qt::ControlModifier, + event->modifiers() & Qt::AltModifier, false); + OnKeyUp(e, destruction_receiver); +} + +void QtWindow::OnMousePressEvent(QMouseEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + + // Handle cursor auto-hide - always reveal on click + if (GetCursorVisibility() == CursorVisibility::kAutoHidden) { + cursor_currently_auto_hidden_ = false; + SetCursorAutoHideTimer(); + UpdateCursorVisibility(); + } + + MouseEvent::Button button = MouseEvent::Button::kNone; + switch (event->button()) { + case Qt::LeftButton: + button = MouseEvent::Button::kLeft; + break; + case Qt::RightButton: + button = MouseEvent::Button::kRight; + break; + case Qt::MiddleButton: + button = MouseEvent::Button::kMiddle; + break; + case Qt::XButton1: + button = MouseEvent::Button::kX1; + break; + case Qt::XButton2: + button = MouseEvent::Button::kX2; + break; + default: + break; + } + + MouseEvent e(this, button, event->position().x(), event->position().y()); + OnMouseDown(e, destruction_receiver); +} + +void QtWindow::OnMouseReleaseEvent(QMouseEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + + MouseEvent::Button button = MouseEvent::Button::kNone; + switch (event->button()) { + case Qt::LeftButton: + button = MouseEvent::Button::kLeft; + break; + case Qt::RightButton: + button = MouseEvent::Button::kRight; + break; + case Qt::MiddleButton: + button = MouseEvent::Button::kMiddle; + break; + case Qt::XButton1: + button = MouseEvent::Button::kX1; + break; + case Qt::XButton2: + button = MouseEvent::Button::kX2; + break; + default: + break; + } + + MouseEvent e(this, button, event->position().x(), event->position().y()); + OnMouseUp(e, destruction_receiver); +} + +void QtWindow::OnMouseMoveEvent(QMouseEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + + // Handle cursor auto-hide + if (GetCursorVisibility() == CursorVisibility::kAutoHidden) { + QPoint global_pos = QCursor::pos(); + if (global_pos != cursor_auto_hide_last_pos_) { + cursor_currently_auto_hidden_ = false; + SetCursorAutoHideTimer(); + cursor_auto_hide_last_pos_ = global_pos; + UpdateCursorVisibility(); + } + } + + MouseEvent e(this, MouseEvent::Button::kNone, event->position().x(), + event->position().y()); + OnMouseMove(e, destruction_receiver); +} + +void QtWindow::OnWheelEvent(QWheelEvent* event) { + WindowDestructionReceiver destruction_receiver(this); + + int delta = event->angleDelta().y() / 8; + MouseEvent e(this, MouseEvent::Button::kNone, event->position().x(), + event->position().y(), delta); + OnMouseWheel(e, destruction_receiver); +} + +QtMenuItem::QtMenuItem(Type type, const std::string& text, + const std::string& hotkey, + std::function callback) + : MenuItem(type, text, hotkey, std::move(callback)) { + // Ensure Qt application is running before creating Qt objects + if (!QCoreApplication::instance()) { + // Qt not initialized yet - defer creation + return; + } + + // Get the active window as parent, or nullptr if none exists yet + QWidget* parent = qobject_cast(QApplication::activeWindow()); + + if (type == MenuItem::Type::kSeparator) { + action_ = new QAction(parent); + action_->setSeparator(true); + } else if (type == MenuItem::Type::kPopup) { + QString qtext = + QString::fromUtf8(text.c_str(), static_cast(text.size())); + menu_ = new QMenu(qtext, parent); + action_ = menu_->menuAction(); + } else { + QString qtext = + QString::fromUtf8(text.c_str(), static_cast(text.size())); + action_ = new QAction(qtext, parent); + if (!hotkey.empty()) { + QString qhotkey = + QString::fromUtf8(hotkey.c_str(), static_cast(hotkey.size())); + action_->setShortcut(QKeySequence(qhotkey)); + } + QObject::connect(action_, &QAction::triggered, action_, + [this]() { OnTriggered(); }); + } +} + +QtMenuItem::~QtMenuItem() { + if (menu_) { + delete menu_; + } else if (action_) { + delete action_; + } +} + +void QtMenuItem::OnChildAdded(MenuItem* child_item) { + auto* qt_child = dynamic_cast(child_item); + if (!qt_child || !menu_) { + return; + } + + if (qt_child->menu()) { + menu_->addMenu(qt_child->menu()); + } else if (qt_child->action()) { + menu_->addAction(qt_child->action()); + } +} + +void QtMenuItem::OnChildRemoved(MenuItem* child_item) { + auto* qt_child = dynamic_cast(child_item); + if (!qt_child || !menu_) { + return; + } + + if (qt_child->action()) { + menu_->removeAction(qt_child->action()); + } +} + +void QtMenuItem::OnTriggered() { OnSelected(); } + +void QtWindow::SetCursorAutoHideTimer() { + // Delete existing timer if any + if (cursor_auto_hide_timer_) { + cursor_auto_hide_timer_->stop(); + delete cursor_auto_hide_timer_; + cursor_auto_hide_timer_ = nullptr; + } + + // Create new single-shot timer + cursor_auto_hide_timer_ = new QTimer(qwindow_); + cursor_auto_hide_timer_->setSingleShot(true); + QObject::connect(cursor_auto_hide_timer_, &QTimer::timeout, qwindow_, + [this]() { OnCursorAutoHideTimeout(); }); + cursor_auto_hide_timer_->start(kDefaultCursorAutoHideMilliseconds); +} + +void QtWindow::OnCursorAutoHideTimeout() { + if (GetCursorVisibility() == CursorVisibility::kAutoHidden) { + cursor_currently_auto_hidden_ = true; + UpdateCursorVisibility(); + } + + // Clean up timer + if (cursor_auto_hide_timer_) { + delete cursor_auto_hide_timer_; + cursor_auto_hide_timer_ = nullptr; + } +} + +void QtWindow::UpdateCursorVisibility() { + if (!qwindow_ || !drawing_widget_) { + return; + } + + CursorVisibility visibility = GetCursorVisibility(); + + if (visibility == CursorVisibility::kHidden || + (visibility == CursorVisibility::kAutoHidden && + cursor_currently_auto_hidden_)) { + drawing_widget_->setCursor(Qt::BlankCursor); + qwindow_->setCursor(Qt::BlankCursor); + } else { + drawing_widget_->setCursor(Qt::ArrowCursor); + qwindow_->setCursor(Qt::ArrowCursor); + } +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/window_qt.h b/src/xenia/ui/window_qt.h new file mode 100644 index 000000000..24a6495e0 --- /dev/null +++ b/src/xenia/ui/window_qt.h @@ -0,0 +1,113 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_WINDOW_QT_H_ +#define XENIA_UI_WINDOW_QT_H_ + +#include +#include + +#include +#include + +#include "xenia/ui/menu_item.h" +#include "xenia/ui/window.h" + +namespace xe { +namespace ui { + +class QtWindowedAppContext; + +class QtWindowInternal; + +class QtWindow : public Window { + using super = Window; + + public: + QtWindow(WindowedAppContext& app_context, const std::string_view title, + uint32_t desired_logical_width, uint32_t desired_logical_height); + ~QtWindow() override; + + QMainWindow* qwindow() const { return qwindow_; } + + void TriggerPaint() { OnPaint(); } + + // Public accessors for event handling from ExternalRenderWidget + void OnMouseMoveEvent(QMouseEvent* event); + void OnMousePressEvent(QMouseEvent* event); + void OnMouseReleaseEvent(QMouseEvent* event); + void OnWheelEvent(QWheelEvent* event); + + protected: + bool OpenImpl() override; + void RequestCloseImpl() override; + + void ApplyNewFullscreen() override; + void ApplyNewTitle() override; + void ApplyNewMainMenu(MenuItem* old_main_menu) override; + void ApplyNewCursorVisibility( + CursorVisibility old_cursor_visibility) override; + void FocusImpl() override; + + std::unique_ptr CreateSurfaceImpl( + Surface::TypeFlags allowed_types) override; + void RequestPaintImpl() override; + + private: + friend class QtWindowInternal; + friend class ExternalRenderWidget; + + void HandleSizeUpdate(WindowDestructionReceiver& destruction_receiver); + static VirtualKey TranslateVirtualKey(int qt_key); + + void OnCloseEvent(QCloseEvent* event); + void OnResizeEvent(QResizeEvent* event); + void OnKeyPressEvent(QKeyEvent* event); + void OnKeyReleaseEvent(QKeyEvent* event); + bool OnEventFilter(QObject* obj, QEvent* event); + + void SetCursorAutoHideTimer(); + void OnCursorAutoHideTimeout(); + void UpdateCursorVisibility(); + + QMainWindow* qwindow_ = nullptr; + QWidget* drawing_widget_ = nullptr; + + bool in_size_update_ = false; + + // Cursor auto-hide support + QTimer* cursor_auto_hide_timer_ = nullptr; + QPoint cursor_auto_hide_last_pos_; + bool cursor_currently_auto_hidden_ = false; +}; + +class QtMenuItem : public MenuItem { + public: + QtMenuItem(Type type, const std::string& text, const std::string& hotkey, + std::function callback); + ~QtMenuItem() override; + + QAction* action() const { return action_; } + QMenu* menu() const { return menu_; } + + protected: + void OnChildAdded(MenuItem* child_item) override; + void OnChildRemoved(MenuItem* child_item) override; + + private: + void OnTriggered(); + + QAction* action_ = nullptr; + QMenu* menu_ = nullptr; +}; + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_WINDOW_QT_H_ diff --git a/src/xenia/ui/windowed_app_context_qt.cc b/src/xenia/ui/windowed_app_context_qt.cc new file mode 100644 index 000000000..bd1c8321c --- /dev/null +++ b/src/xenia/ui/windowed_app_context_qt.cc @@ -0,0 +1,74 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/ui/windowed_app_context_qt.h" + +#include +#include +#include + +#if XE_PLATFORM_WIN32 +#include "xenia/base/platform_win.h" +#endif + +namespace xe { +namespace ui { + +QtWindowedAppContext::QtWindowedAppContext(QApplication* app) + : app_(app) +#if XE_PLATFORM_WIN32 + , + hinstance_(GetModuleHandle(nullptr)) +#endif +{ + pending_functions_timer_ = new QTimer(); + pending_functions_timer_->setSingleShot(true); + QObject::connect(pending_functions_timer_, &QTimer::timeout, + [this]() { ExecutePendingFunctionsFromTimer(); }); +} + +QtWindowedAppContext::~QtWindowedAppContext() { + if (pending_functions_timer_) { + pending_functions_timer_->stop(); + delete pending_functions_timer_; + pending_functions_timer_ = nullptr; + } +} + +void QtWindowedAppContext::NotifyUILoopOfPendingFunctions() { + // Use QMetaObject::invokeMethod on the timer to safely call from any thread + QMetaObject::invokeMethod( + pending_functions_timer_, [this]() { StartTimerInternal(); }, + Qt::QueuedConnection); +} + +void QtWindowedAppContext::StartTimerInternal() { + if (!pending_functions_timer_->isActive()) { + pending_functions_timer_->start(0); + } +} + +void QtWindowedAppContext::PlatformQuitFromUIThread() { + if (app_) { + app_->quit(); + } +} + +void QtWindowedAppContext::RunMainQtLoop() { + if (app_) { + app_->exec(); + } +} + +void QtWindowedAppContext::ExecutePendingFunctionsFromTimer() { + ExecutePendingFunctionsFromUIThread(); +} + +} // namespace ui +} // namespace xe diff --git a/src/xenia/ui/windowed_app_context_qt.h b/src/xenia/ui/windowed_app_context_qt.h new file mode 100644 index 000000000..6a830af6c --- /dev/null +++ b/src/xenia/ui/windowed_app_context_qt.h @@ -0,0 +1,58 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_UI_WINDOWED_APP_CONTEXT_QT_H_ +#define XENIA_UI_WINDOWED_APP_CONTEXT_QT_H_ + +#include + +#include "xenia/base/platform.h" +#include "xenia/ui/windowed_app_context.h" + +#if XE_PLATFORM_WIN32 +#include "xenia/base/platform_win.h" +#endif + +class QApplication; +class QTimer; + +namespace xe { +namespace ui { + +class QtWindowedAppContext final : public WindowedAppContext { + public: + explicit QtWindowedAppContext(QApplication* app); + ~QtWindowedAppContext(); + +#if XE_PLATFORM_WIN32 + HINSTANCE hinstance() const { return hinstance_; } +#endif + + void NotifyUILoopOfPendingFunctions() override; + + void PlatformQuitFromUIThread() override; + + void RunMainQtLoop(); + + private: + void ExecutePendingFunctionsFromTimer(); + void StartTimerInternal(); + + QApplication* app_; + QTimer* pending_functions_timer_ = nullptr; + +#if XE_PLATFORM_WIN32 + HINSTANCE hinstance_; +#endif +}; + +} // namespace ui +} // namespace xe + +#endif // XENIA_UI_WINDOWED_APP_CONTEXT_QT_H_ diff --git a/src/xenia/ui/windowed_app_main_qt.cc b/src/xenia/ui/windowed_app_main_qt.cc new file mode 100644 index 000000000..982462da0 --- /dev/null +++ b/src/xenia/ui/windowed_app_main_qt.cc @@ -0,0 +1,74 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2025 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include +#include +#include + +#include "xenia/base/cvar.h" +#include "xenia/base/logging.h" +#include "xenia/base/platform.h" +#include "xenia/ui/windowed_app.h" +#include "xenia/ui/windowed_app_context_qt.h" + +#if XE_PLATFORM_WIN32 +#include "xenia/base/main_win.h" +#include "xenia/base/platform_win.h" +#endif + +int main(int argc, char** argv) { +#if XE_PLATFORM_LINUX + // Force X11 backend (Vulkan needs XCB, not Wayland) + qputenv("QT_QPA_PLATFORM", "xcb"); +#endif + + QApplication qt_app(argc, argv); + + int result; + + { + xe::ui::QtWindowedAppContext app_context(&qt_app); + + std::unique_ptr app = + xe::ui::GetWindowedAppCreator()(app_context); + + cvar::ParseLaunchArguments(argc, argv, app->GetPositionalOptionsUsage(), + app->GetPositionalOptions()); + +#if XE_PLATFORM_WIN32 + // Initialize COM for Windows + if (FAILED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) { + return EXIT_FAILURE; + } + + // Use Windows-specific initialization which properly sets up logging + xe::InitializeWin32App(app->GetName()); +#else + xe::InitializeLogging(app->GetName()); +#endif + + if (app->OnInitialize()) { + app_context.RunMainQtLoop(); + result = EXIT_SUCCESS; + } else { + result = EXIT_FAILURE; + } + + app->InvokeOnDestroy(); + } + +#if XE_PLATFORM_WIN32 + xe::ShutdownWin32App(); + CoUninitialize(); +#else + xe::ShutdownLogging(); +#endif + + return result; +} diff --git a/xenia-build.py b/xenia-build.py index 18be2792d..f6866d449 100755 --- a/xenia-build.py +++ b/xenia-build.py @@ -121,10 +121,132 @@ vs_version = import_vs_environment() default_branch = "canary_experimental" +def setup_vulkan_sdk(): + """Setup Vulkan SDK environment variables if not already set. + + Returns: + True if Vulkan SDK is available and valid, False otherwise. + """ + # Check if VULKAN_SDK is already set and valid + existing_vulkan_sdk = os.environ.get("VULKAN_SDK") + if existing_vulkan_sdk: + # Validate that the path exists + if os.path.exists(existing_vulkan_sdk): + # Check if spirv-opt is accessible + if has_bin("spirv-opt"): + print(f"VULKAN_SDK is set to {existing_vulkan_sdk}") + return True + print(f"WARNING: VULKAN_SDK is set to {existing_vulkan_sdk} but spirv-opt not found in PATH") + else: + print(f"WARNING: VULKAN_SDK is set to {existing_vulkan_sdk} but directory does not exist") + return False + + if sys.platform != "win32": + # On Linux, find spirv-opt in PATH and set VULKAN_SDK based on its location + spirv_opt_path = get_bin("spirv-opt") + if spirv_opt_path: + # spirv-opt is typically in $VULKAN_SDK/bin/, so get parent directory + spirv_bin_dir = os.path.dirname(spirv_opt_path) + vulkan_sdk = os.path.dirname(spirv_bin_dir) + os.environ["VULKAN_SDK"] = vulkan_sdk + print(f"Found Vulkan SDK at {vulkan_sdk} (from spirv-opt location)") + return True + return False + + # Windows: Check if Vulkan SDK is installed at the default location + vulkan_base = "C:\\VulkanSDK" + if not os.path.exists(vulkan_base): + return False + + # Get the first (latest) version directory + try: + subdirs = [d for d in os.listdir(vulkan_base) + if os.path.isdir(os.path.join(vulkan_base, d))] + if not subdirs: + return False + + vulkan_sdk = os.path.join(vulkan_base, subdirs[0]) + vulkan_bin = os.path.join(vulkan_sdk, "Bin") + + os.environ["VULKAN_SDK"] = vulkan_sdk + os.environ["PATH"] = f"{vulkan_bin}{os.pathsep}{os.environ['PATH']}" + + print(f"Found Vulkan SDK at {vulkan_sdk}") + return True + except Exception: + return False + + +def setup_qt(): + """Setup Qt environment variables if not already set. + + Returns: + True if Qt is available and valid, False otherwise. + """ + # Check if QT_DIR is already set and valid + existing_qt_dir = os.environ.get("QT_DIR") + if existing_qt_dir: + # Validate that the path exists + if os.path.exists(existing_qt_dir): + print(f"QT_DIR is set to {existing_qt_dir}") + return True + else: + print(f"WARNING: QT_DIR is set to {existing_qt_dir} but directory does not exist") + return False + + if sys.platform != "win32": + # On Linux, check for Qt6 via pkg-config or common install locations + return False + + # Windows: Check if Qt is installed at the default location + qt_base = "C:\\Qt" + if not os.path.exists(qt_base): + return False + + # Get the first (latest) version directory + try: + # List all version directories (e.g., 6.8.1, 6.7.0) + version_dirs = [d for d in os.listdir(qt_base) + if os.path.isdir(os.path.join(qt_base, d)) and d[0].isdigit()] + if not version_dirs: + return False + + # Sort versions to get the latest (simple string sort works for semantic versions) + version_dirs.sort(reverse=True) + qt_version_dir = os.path.join(qt_base, version_dirs[0]) + + # Look for msvc compiler directory (e.g., msvc2022_64, msvc2019_64) + compiler_dirs = [d for d in os.listdir(qt_version_dir) + if os.path.isdir(os.path.join(qt_version_dir, d)) and d.startswith("msvc")] + if not compiler_dirs: + return False + + # Prefer msvc2022_64 if available, otherwise use the first available + if "msvc2022_64" in compiler_dirs: + compiler_dir = "msvc2022_64" + else: + compiler_dirs.sort(reverse=True) + compiler_dir = compiler_dirs[0] + + qt_dir = os.path.join(qt_version_dir, compiler_dir) + os.environ["QT_DIR"] = qt_dir + + print(f"Found Qt at {qt_dir}") + return True + except Exception: + return False + + def main(): # Add self to the root search path. sys.path.insert(0, self_path) + # Setup Vulkan SDK and check if available + vulkan_sdk_available = setup_vulkan_sdk() + + # Setup Qt (optional, only needed for Qt-based UI) + qt_available = setup_qt() + # Augment path to include our fancy things. os.environ["PATH"] += os.pathsep + os.pathsep.join([ self_path, @@ -564,6 +686,63 @@ def get_build_bin_path(args): return os.path.join(self_path, "build", "bin", platform.capitalize(), args["config"].capitalize()) +def run_windeployqt(bin_path, config): + """Runs windeployqt to copy Qt DLLs to the build output directory. + + Args: + bin_path: Path to the directory containing the built executable. + config: Build configuration (debug, checked, or release). + + Returns: + True if windeployqt succeeded or was not needed, False on error. + """ + if sys.platform != "win32": + return True + + qt_dir = os.environ.get("QT_DIR") + if not qt_dir: + # Qt not configured, skip + return True + + windeployqt_path = os.path.join(qt_dir, "bin", "windeployqt.exe") + if not os.path.exists(windeployqt_path): + print(f"WARNING: windeployqt not found at {windeployqt_path}") + return True + + # Find the xenia executable + exe_path = os.path.join(bin_path, "xenia_edge.exe") + if not os.path.exists(exe_path): + # Executable not found, might not be building xenia-app + return True + + print(f"\n- deploying Qt dependencies to {bin_path}...") + + # Determine if we need debug or release Qt DLLs + # Debug and Checked builds need debug Qt DLLs + deploy_args = [ + windeployqt_path, + "--no-translations", # Don't copy translation files + "--no-system-d3d-compiler", # Don't copy D3D compiler + "--no-opengl-sw", # Don't copy software OpenGL renderer + ] + + if config.lower() in ["debug", "checked"]: + deploy_args.append("--debug") + else: + deploy_args.append("--release") + + deploy_args.append(exe_path) + + result = subprocess.call(deploy_args) + + if result == 0: + print(" Qt dependencies deployed successfully") + return True + else: + print(f"WARNING: windeployqt failed with exit code {result}") + return False + + def create_clion_workspace(): """Creates some basic workspace information inside the .idea directory for first start. """ @@ -892,6 +1071,10 @@ class BuildCommand(BaseBuildCommand): result = super(BuildCommand, self).execute(args, pass_args, cwd) if not result: + # Run windeployqt to copy Qt DLLs + bin_path = get_build_bin_path(args) + run_windeployqt(bin_path, args["config"]) + print(f"{bcolors.OKCYAN}Success!{bcolors.ENDC}") else: print(f"{bcolors.FAIL}Failed!{bcolors.ENDC}") @@ -1029,9 +1212,17 @@ class BuildShadersCommand(Command): print("Building Vulkan SPIR-V shaders...") # Get the SPIR-V tool paths. - vulkan_sdk_path = os.environ["VULKAN_SDK"] + vulkan_sdk_path = os.environ.get("VULKAN_SDK") + if not vulkan_sdk_path: + print("ERROR: VULKAN_SDK environment variable is not set") + if sys.platform == "win32": + print("Please install Vulkan SDK from:") + print("https://sdk.lunarg.com/sdk/download/latest/windows/vulkan-sdk.exe") + else: + print("Please install Vulkan SDK and set VULKAN_SDK environment variable") + return 1 if not os.path.exists(vulkan_sdk_path): - print("ERROR: could not find the Vulkan SDK in $VULKAN_SDK") + print(f"ERROR: could not find the Vulkan SDK at {vulkan_sdk_path}") return 1 # bin is lowercase on Linux (even though it's uppercase on Windows). vulkan_bin_path = os.path.join(vulkan_sdk_path, "bin")