From a4ca581d47157f32b270f485eb0535530a7a6276 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 00:39:39 +0800 Subject: [PATCH 1/8] feat(window-mgmt): add WindowInfo fields + FloatingPolicy Stage A of MS5 (window management). Pure data + math layer, no backend dependency, fully unit-tested in isolation. - WindowInfo: add icon_hint, z_index, is_always_on_top, created_at fields; update WindowState doc to note Minimized/Maximized now exercised. - FloatingPolicy: pure-math initial-geometry policy (mirror WindowPlacementPolicy). Centers a new floating window in the work area, shrinks-to-fit when larger (aspect preserved), nudges by a 24px cascade offset per consecutive window, and clamps the result to stay fully inside the work area. Reuses WindowPlacementPolicy::centerInWorkArea for the shrink/center math. - 13-case FloatingPolicy unit test (cascadeOffset / clampToMinimum / initialGeometry); also fix the stale include path in the placement test CMakeLists (desktop/ui -> ui, post-restructure). --- test/desktop/window_placement/CMakeLists.txt | 37 +++++- .../window_placement/floating_policy_test.cpp | 110 ++++++++++++++++++ ui/components/window_info.h | 19 ++- ui/components/window_placement/CMakeLists.txt | 10 +- .../window_placement/floating_policy.cpp | 62 ++++++++++ .../window_placement/floating_policy.h | 110 ++++++++++++++++++ 6 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 test/desktop/window_placement/floating_policy_test.cpp create mode 100644 ui/components/window_placement/floating_policy.cpp create mode 100644 ui/components/window_placement/floating_policy.h diff --git a/test/desktop/window_placement/CMakeLists.txt b/test/desktop/window_placement/CMakeLists.txt index ca2d9e8dc..e658d7e19 100644 --- a/test/desktop/window_placement/CMakeLists.txt +++ b/test/desktop/window_placement/CMakeLists.txt @@ -1,12 +1,13 @@ -# WindowPlacementPolicy unit tests. -# Pure geometry tests (clampToWorkArea + computeConstrain) — no IWindow, no -# backend, no display — so a plain gtest_main with no QGuiApplication suffices. +# WindowPlacementPolicy / FloatingPolicy unit tests. +# Pure geometry tests (centerInWorkArea + computeConstrain + cascadeOffset + +# clampToMinimum + initialGeometry) — no IWindow, no backend, no display — so a +# plain gtest_main with no QGuiApplication suffices. add_executable(window_placement_test window_placement_test.cpp ) target_include_directories(window_placement_test PRIVATE - ${CMAKE_SOURCE_DIR}/desktop/ui/components + ${CMAKE_SOURCE_DIR}/ui/components ) target_link_libraries(window_placement_test PRIVATE @@ -27,4 +28,32 @@ set_tests_properties(window_placement_test PROPERTIES WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" ) +# FloatingPolicy unit tests — same pure-geometry seam, separate executable. +add_executable(floating_policy_test + floating_policy_test.cpp +) + +target_include_directories(floating_policy_test PRIVATE + ${CMAKE_SOURCE_DIR}/ui/components +) + +target_link_libraries(floating_policy_test PRIVATE + cfdesktop_window_placement + Qt6::Core + GTest::gtest + GTest::gtest_main +) + +set_target_properties(floating_policy_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +add_test(NAME floating_policy_test COMMAND floating_policy_test) +set_tests_properties(floating_policy_test PROPERTIES + LABELS "desktop;unit;components" + TIMEOUT 60 + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + log_info("window_placement_tests" " - window_placement_test") +log_info("floating_policy_tests" " - floating_policy_test") diff --git a/test/desktop/window_placement/floating_policy_test.cpp b/test/desktop/window_placement/floating_policy_test.cpp new file mode 100644 index 000000000..ad1112557 --- /dev/null +++ b/test/desktop/window_placement/floating_policy_test.cpp @@ -0,0 +1,110 @@ +/** + * @file floating_policy_test.cpp + * @brief GoogleTest unit tests for FloatingPolicy. + * + * Covers the pure geometry seam (cascadeOffset + clampToMinimum + + * initialGeometry) with no IWindow, no backend, and no display — the full + * initial-placement decision is tested as a pure function of QRect / QSize + * inputs. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-05 + * @version 0.1 + * @since 0.21 + * @ingroup components + */ + +#include "window_placement/floating_policy.h" + +#include +#include +#include +#include + +namespace { + +using cf::desktop::placement::FloatingPolicy; + +/// Work area used throughout: x in [0,999], y in [48,747], center (500,398). +const QRect kWork{0, 48, 1000, 700}; + +/// @brief Asserts a QRect matches the given components with readable failures. +void expectRect(const QRect& actual, int x, int y, int w, int h) { + EXPECT_EQ(actual.x(), x); + EXPECT_EQ(actual.y(), y); + EXPECT_EQ(actual.width(), w); + EXPECT_EQ(actual.height(), h); +} + +} // namespace + +// ── cascadeOffset (pure linear, no wrapping on its own) ────────────────────── + +TEST(FloatingPolicy, CascadeOffset_Zero_IsOrigin) { + EXPECT_EQ(FloatingPolicy::cascadeOffset(0), QPoint(0, 0)); +} + +TEST(FloatingPolicy, CascadeOffset_One_IsOneStep) { + EXPECT_EQ(FloatingPolicy::cascadeOffset(1), + QPoint(FloatingPolicy::kCascadeStep, FloatingPolicy::kCascadeStep)); +} + +TEST(FloatingPolicy, CascadeOffset_Three_IsThreeSteps) { + EXPECT_EQ(FloatingPolicy::cascadeOffset(3), QPoint(72, 72)); +} + +TEST(FloatingPolicy, CascadeOffset_Negative_IsOrigin) { + EXPECT_EQ(FloatingPolicy::cascadeOffset(-5), QPoint(0, 0)); +} + +// ── clampToMinimum (component-wise floor) ─────────────────────────────────── + +TEST(FloatingPolicy, Clamp_BelowMin_RaisedToMin) { + EXPECT_EQ(FloatingPolicy::clampToMinimum(QSize(100, 100)), QSize(320, 240)); +} + +TEST(FloatingPolicy, Clamp_AboveMin_Unchanged) { + EXPECT_EQ(FloatingPolicy::clampToMinimum(QSize(400, 500)), QSize(400, 500)); +} + +TEST(FloatingPolicy, Clamp_OneBelow_RaisedPartially) { + EXPECT_EQ(FloatingPolicy::clampToMinimum(QSize(500, 100)), QSize(500, 240)); +} + +// ── initialGeometry (centered, shrunk-to-fit, cascaded, clamped to work) ──── + +TEST(FloatingPolicy, Initial_Default_CenteredInWorkArea) { + // 800x600 in 1000x700 work -> centered at (100, 98). + expectRect(FloatingPolicy::initialGeometry(kWork), 100, 98, 800, 600); +} + +TEST(FloatingPolicy, Initial_CustomDesired_Centered) { + // 400x300 in kWork -> centered at (300, 248). + expectRect(FloatingPolicy::initialGeometry(kWork, 0, QSize(400, 300)), 300, 248, 400, 300); +} + +TEST(FloatingPolicy, Initial_DesiredBelowMin_RaisedToMinThenCentered) { + // 100x100 -> min 320x240 -> centered at (340, 278). + expectRect(FloatingPolicy::initialGeometry(kWork, 0, QSize(100, 100)), 340, 278, 320, 240); +} + +TEST(FloatingPolicy, Initial_WorkSmallerThanDefault_ShrinksToWork) { + // 800x600 default in a 200x200 work -> shrink-to-fit wins over the minimum. + expectRect(FloatingPolicy::initialGeometry(QRect(0, 0, 200, 200)), 0, 25, 200, 150); +} + +TEST(FloatingPolicy, Initial_CascadeOne_OffsetAddedClampedToWork) { + // Centered (100,98) + (24,24) = (124,122), still inside the work area. + expectRect(FloatingPolicy::initialGeometry(kWork, 1), 124, 122, 800, 600); +} + +TEST(FloatingPolicy, Initial_HighCascade_ClampedToWorkCorner) { + // Offset (1200,1200) clamped against the work area's right/bottom edge: + // max_x = 999-800+1 = 200, max_y = 747-600+1 = 148. + expectRect(FloatingPolicy::initialGeometry(kWork, 50), 200, 148, 800, 600); +} + +TEST(FloatingPolicy, Initial_EmptyWorkArea_ReturnsClampedSizeAtOrigin) { + // No work area to place within -> clamped-to-min size at the origin. + expectRect(FloatingPolicy::initialGeometry(QRect{}, 0, QSize(100, 100)), 0, 0, 320, 240); +} diff --git a/ui/components/window_info.h b/ui/components/window_info.h index 5bba75636..2084687c9 100644 --- a/ui/components/window_info.h +++ b/ui/components/window_info.h @@ -4,17 +4,19 @@ * * WindowInfo captures the observed state of an external window (title, pid, * geometry, lifecycle state) so the shell can reason about windows without - * owning them. WindowState models the minimal lifecycle the tracker needs. + * owning them. WindowState models the lifecycle the tracker needs to drive + * minimize/maximize/restore transitions and taskbar linkage. * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-06-16 - * @version 0.1 + * @version 0.2 * @since 0.19 * @ingroup components */ #pragma once +#include #include #include @@ -23,15 +25,16 @@ namespace cf::desktop { /** * @brief Lifecycle state of a tracked window. * - * Only Normal and Closed are exercised by the tracker today; the remaining - * values are reserved for future window-management work. + * Normal, Minimized, Maximized, and Closed are exercised by the WindowManager + * state machine today; Closing and Fullscreen are reserved for future work + * (close-animation and full-screen window modes). * * @ingroup components */ enum class WindowState { Normal, ///< Visible, resting state. - Minimized, ///< Hidden to the taskbar (reserved). - Maximized, ///< Filling the work area (reserved). + Minimized, ///< Hidden to the taskbar. + Maximized, ///< Filling the work area. Fullscreen, ///< Full-screen (reserved). Closing, ///< Close requested, not yet gone (reserved). Closed ///< Window is gone. @@ -45,9 +48,13 @@ enum class WindowState { struct WindowInfo { QString window_id; ///< Platform window identifier (cf IWindow::windowID). QString title; ///< Last observed window title. + QString icon_hint; ///< Hint for icon lookup (app_id/path; may be empty). qint64 pid{0}; ///< Owning process id (0 if unknown). QRect geometry; ///< Last observed geometry (device px). WindowState state{WindowState::Normal}; ///< Lifecycle state. + int z_index{0}; ///< Last observed stacking order (0 = bottom). + bool is_always_on_top{false}; ///< Whether the window is pinned on top. + QDateTime created_at; ///< When the window was first observed. }; } // namespace cf::desktop diff --git a/ui/components/window_placement/CMakeLists.txt b/ui/components/window_placement/CMakeLists.txt index 1f0d255b1..b3bed1938 100644 --- a/ui/components/window_placement/CMakeLists.txt +++ b/ui/components/window_placement/CMakeLists.txt @@ -1,9 +1,11 @@ -# Window placement policy: clamps external windows into the desktop work area -# (central rect between top status bar and bottom taskbar). Pure logic over -# IWindow + QRect — no platform branches, no preprocessor conditionals — so it -# stays unit-testable without any backend or display. +# Window placement policies: clamps external windows into the desktop work area +# (WindowPlacementPolicy) and picks the initial geometry for a new floating +# window (FloatingPolicy). Pure logic over IWindow + QRect — no platform +# branches, no preprocessor conditionals — so it stays unit-testable without any +# backend or display. add_library(cfdesktop_window_placement STATIC window_placement_policy.cpp + floating_policy.cpp ) target_include_directories(cfdesktop_window_placement PUBLIC diff --git a/ui/components/window_placement/floating_policy.cpp b/ui/components/window_placement/floating_policy.cpp new file mode 100644 index 000000000..047e59199 --- /dev/null +++ b/ui/components/window_placement/floating_policy.cpp @@ -0,0 +1,62 @@ +/** + * @file floating_policy.cpp + * @brief FloatingPolicy implementation. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-05 + * @version 0.1 + * @since 0.21 + * @ingroup components + */ + +#include "floating_policy.h" + +#include "window_placement_policy.h" + +#include + +namespace cf::desktop::placement { + +QPoint FloatingPolicy::cascadeOffset(int cascade_index) { + if (cascade_index <= 0) { + return {}; + } + return {cascade_index * kCascadeStep, cascade_index * kCascadeStep}; +} + +QSize FloatingPolicy::clampToMinimum(const QSize& s, int min_w, int min_h) { + return QSize(std::max(s.width(), min_w), std::max(s.height(), min_h)); +} + +QRect FloatingPolicy::initialGeometry(const QRect& work_area, int cascade_index, QSize desired) { + // Step 1: never ship a window smaller than the usable minimum. + desired = clampToMinimum(desired); + + // Step 2: shrink-to-fit the work area (aspect preserved) and center. + // WindowPlacementPolicy::centerInWorkArea does exactly this on a + // position-less rect: it scales @p rect down to fit @p work_area and + // centers the result. + const QRect centered = WindowPlacementPolicy::centerInWorkArea( + QRect(0, 0, desired.width(), desired.height()), work_area); + if (work_area.isEmpty()) { + // No work area to place within: hand back the clamped size at origin. + return centered; + } + + // Step 3: nudge by the cascade offset. + const QPoint off = cascadeOffset(cascade_index); + int x = centered.x() + off.x(); + int y = centered.y() + off.y(); + + // Step 4: clamp so the cascaded window stays fully inside the work area. + const int max_x = work_area.right() - centered.width() + 1; + const int max_y = work_area.bottom() - centered.height() + 1; + x = std::min(x, max_x); + y = std::min(y, max_y); + x = std::max(x, work_area.x()); + y = std::max(y, work_area.y()); + + return {x, y, centered.width(), centered.height()}; +} + +} // namespace cf::desktop::placement diff --git a/ui/components/window_placement/floating_policy.h b/ui/components/window_placement/floating_policy.h new file mode 100644 index 000000000..92d865400 --- /dev/null +++ b/ui/components/window_placement/floating_policy.h @@ -0,0 +1,110 @@ +/** + * @file floating_policy.h + * @brief Picks the initial geometry for a freshly appeared floating window. + * + * WindowPlacementPolicy clamps an external window back into the work area when + * it lands outside; FloatingPolicy decides where a new floating window should + * appear in the first place — centered in the work area, shrunk to fit when + * larger than the work area (aspect preserved), and nudged by a small cascade + * offset per consecutive window so a burst of launches does not stack exactly + * on top of each other. The cascade offset is clamped so the window never + * escapes the work area. + * + * Like WindowPlacementPolicy, this policy is intentionally pure: it operates + * only on QRect and QSize. There are zero platform branches and zero + * preprocessor conditionals — moving the window is the backend's job, behind + * IWindow::set_geometry. The math is exposed as static free functions so it is + * unit-testable with no backend. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-05 + * @version 0.1 + * @since 0.21 + * @ingroup components + */ + +#pragma once + +#include +#include +#include + +namespace cf::desktop::placement { + +/** + * @brief Computes the initial geometry for a new floating window. + * + * Used on window appearance to give a freshly arrived window a sensible default + * position: centered in the work area, shrunk to fit when too large, and nudged + * by a per-window cascade offset so successive launches do not perfectly + * overlap. The result is always kept fully inside @p work_area. + * + * @ingroup components + */ +class FloatingPolicy { + public: + /// @brief Default cascade step per window, in device pixels (both axes). + static constexpr int kCascadeStep = 24; + /// @brief Default desired window size when the caller omits it. + static constexpr int kDefaultWidth = 800; + static constexpr int kDefaultHeight = 600; + /// @brief Default minimum window size (so a window stays usable). + static constexpr int kDefaultMinWidth = 320; + static constexpr int kDefaultMinHeight = 240; + + /** + * @brief Returns the cascade offset for the @p cascade_index-th window. + * + * Pure linear offset (index * kCascadeStep on both axes); never wraps on its + * own. initialGeometry() clamps the offset against the work area so the + * final window stays on-screen. Exposed so the offset math is testable + * without a work area. + * + * @param[in] cascade_index Zero-based consecutive-window count. + * + * @return The (dx, dy) offset. + * + * @throws None + * @since 0.21 + */ + static QPoint cascadeOffset(int cascade_index); + + /** + * @brief Raises @p s to at least the given minimum (component-wise). + * + * @param[in] s The requested size. + * @param[in] min_w Minimum width. + * @param[in] min_h Minimum height. + * + * @return @p s with width/height raised to @p min_w / @p min_h when smaller. + * + * @throws None + * @since 0.21 + */ + static QSize clampToMinimum(const QSize& s, int min_w = kDefaultMinWidth, + int min_h = kDefaultMinHeight); + + /** + * @brief Computes the initial geometry for a new floating window. + * + * Steps: raise @p desired to the minimum size; shrink-to-fit @p work_area + * (aspect preserved, never enlarged) via the same math as + * WindowPlacementPolicy::centerInWorkArea; center in @p work_area; then add + * cascadeOffset(@p cascade_index) and clamp so the result stays fully inside + * @p work_area. An empty @p work_area yields a rect of the clamped size at + * the origin. + * + * @param[in] work_area The available work area to place within. + * @param[in] cascade_index Zero-based consecutive-window count (0 = centered). + * @param[in] desired The desired window size (default 800x600). + * + * @return The initial geometry, fully contained in @p work_area when valid. + * + * @throws None + * @since 0.21 + */ + static QRect initialGeometry(const QRect& work_area, int cascade_index = 0, + QSize desired = QSize(kDefaultWidth, kDefaultHeight)); +}; + +} // namespace cf::desktop::placement From 76379f8c8cd06ed62e5a997c7b9ecdce3b6d40a6 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 00:46:48 +0800 Subject: [PATCH 2/8] feat(window-mgmt): add IWindow minimize/maximize/restore + backends Stage B of MS5. Adds the platform state-operation seam the WindowManager state machine will drive. - IWindow: add non-pure virtual minimize/maximize/restore with default no-op bodies (mirror pid()'s pattern), so existing/future backends are not forced to implement all three. - WindowsWindow: override all three via ShowWindow(SW_MINIMIZE/SW_MAXIMIZE/ SW_RESTORE). - WSLX11Window: override minimize/restore by sending the ICCCM 4.1.4 WM_CHANGE_STATE ClientMessage to the root window with SubstructureRedirectMask|SubstructureNotifyMask (the XIconifyWindow / xdotool path, honored by XWayland on WSL). Maximize stays the default no-op on X11 because the EWMH _NET_WM_STATE hint is unreliable under XWayland; intern a new wm_change_state atom alongside the existing cached atoms. --- ui/components/IWindow.h | 40 +++++++++++++++++ ui/platform/linux_wsl/wsl_x11_window.cpp | 40 +++++++++++++++++ ui/platform/linux_wsl/wsl_x11_window.h | 43 +++++++++++++++++++ .../linux_wsl/wsl_x11_window_backend.cpp | 1 + ui/platform/windows/windows_window.cpp | 21 +++++++++ ui/platform/windows/windows_window.h | 15 +++++++ 6 files changed, 160 insertions(+) diff --git a/ui/components/IWindow.h b/ui/components/IWindow.h index 1bb923969..23367cd15 100644 --- a/ui/components/IWindow.h +++ b/ui/components/IWindow.h @@ -73,6 +73,46 @@ class IWindow : public QObject { */ virtual void raise() = 0; + /** + * @brief Minimizes (iconifies) this window. + * + * Default no-op; concrete backends override where the platform exposes a + * minimize operation. On X11/XWayland this is the ICCCM WM_CHANGE_STATE + * ClientMessage (IconicState); on Win32 it is ShowWindow(SW_MINIMIZE). + * + * @throws None + * @note Default implementation is a no-op. + * @since 0.21 + */ + virtual void minimize() {} + + /** + * @brief Maximizes this window to fill the work area. + * + * Default no-op; concrete backends override where the platform exposes a + * reliable maximize operation. On Win32 it is ShowWindow(SW_MAXIMIZE); on + * X11/XWayland the EWMH _NET_WM_STATE hint is unreliable and this stays a + * no-op by default. + * + * @throws None + * @note Default implementation is a no-op. + * @since 0.21 + */ + virtual void maximize() {} + + /** + * @brief Restores this window from a minimized or maximized state. + * + * Default no-op; concrete backends override where the platform exposes a + * restore operation. On X11/XWayland this is the ICCCM WM_CHANGE_STATE + * ClientMessage (NormalState); on Win32 it is ShowWindow(SW_RESTORE). + * + * @throws None + * @note Default implementation is a no-op. + * @since 0.21 + */ + virtual void restore() {} + /** * @brief Returns the owning process id of this window. * diff --git a/ui/platform/linux_wsl/wsl_x11_window.cpp b/ui/platform/linux_wsl/wsl_x11_window.cpp index 03dc88bb8..1a3c903b2 100644 --- a/ui/platform/linux_wsl/wsl_x11_window.cpp +++ b/ui/platform/linux_wsl/wsl_x11_window.cpp @@ -21,6 +21,15 @@ namespace cf::desktop::backend::wsl { +namespace { + +/// ICCCM WM_STATE action values (see ICCCM 4.1.4 / WM_CHANGE_STATE). xcb does +/// not ship these Xlib constants, so they are defined here as plain literals. +constexpr long kIconicState = 3; ///< Iconified / minimized. +constexpr long kNormalState = 1; ///< Mapped / restored. + +} // namespace + WSLX11Window::WSLX11Window(xcb_connection_t* conn, xcb_window_t root, xcb_window_t win, const XcbAtoms& atoms, QObject* parent) : IWindow(parent), conn_(conn), root_(root), window_(win), atoms_(atoms) { @@ -176,6 +185,37 @@ void WSLX11Window::raise() { xcb_flush(conn_); } +void WSLX11Window::minimize() { + sendWmChangeState(kIconicState); +} + +void WSLX11Window::restore() { + sendWmChangeState(kNormalState); +} + +void WSLX11Window::sendWmChangeState(long stateAction) const { + if (!conn_ || window_ == XCB_WINDOW_NONE || atoms_.wm_change_state == XCB_ATOM_NONE) { + return; + } + + // ICCCM 4.1.4: a client asks the WM to change a window's state by sending a + // WM_CHANGE_STATE ClientMessage to the root window with + // SubstructureRedirectMask. The WM intercepts it and performs the iconify + // or restore. This is the path XIconifyWindow(3) and xdotool(1) use. + xcb_client_message_event_t event; + std::memset(&event, 0, sizeof(event)); + event.response_type = XCB_CLIENT_MESSAGE; + event.window = window_; + event.type = atoms_.wm_change_state; + event.format = 32; + event.data.data32[0] = static_cast(stateAction); + + xcb_send_event(conn_, 0, root_, + XCB_EVENT_MASK_SUBSTRUCTURE_REDIRECT | XCB_EVENT_MASK_SUBSTRUCTURE_NOTIFY, + reinterpret_cast(&event)); + xcb_flush(conn_); +} + qint64 WSLX11Window::pid() const { return pid_; } diff --git a/ui/platform/linux_wsl/wsl_x11_window.h b/ui/platform/linux_wsl/wsl_x11_window.h index 09a7fd432..7a5b143ca 100644 --- a/ui/platform/linux_wsl/wsl_x11_window.h +++ b/ui/platform/linux_wsl/wsl_x11_window.h @@ -31,6 +31,7 @@ struct XcbAtoms { xcb_atom_t net_wm_pid = XCB_ATOM_NONE; xcb_atom_t wm_protocols = XCB_ATOM_NONE; xcb_atom_t wm_delete_window = XCB_ATOM_NONE; + xcb_atom_t wm_change_state = XCB_ATOM_NONE; xcb_atom_t net_wm_window_type = XCB_ATOM_NONE; xcb_atom_t net_wm_window_type_dock = XCB_ATOM_NONE; xcb_atom_t net_wm_window_type_desktop = XCB_ATOM_NONE; @@ -97,6 +98,33 @@ class WSLX11Window : public IWindow { */ void raise() override; + /** + * @brief Minimizes (iconifies) the window via WM_CHANGE_STATE. + * + * Sends the ICCCM 4.1.4 WM_CHANGE_STATE ClientMessage to the root window + * with SubstructureRedirectMask so the window manager (XWayland on WSL) + * iconifies the window. This is the same path used by XIconifyWindow and + * xdotool(1). + * + * @throws None + * @note No-op when the WM_CHANGE_STATE atom could not be interned. + * @since 0.21 + */ + void minimize() override; + + /** + * @brief Restores the window from minimized via WM_CHANGE_STATE. + * + * Sends the ICCCM 4.1.4 WM_CHANGE_STATE ClientMessage with NormalState to + * the root window so the window manager (XWayland on WSL) restores the + * window. + * + * @throws None + * @note No-op when the WM_CHANGE_STATE atom could not be interned. + * @since 0.21 + */ + void restore() override; + /** * @brief Returns the owning process id read from _NET_WM_PID. * @return The process id, or 0 when unavailable. @@ -125,6 +153,21 @@ class WSLX11Window : public IWindow { /// @brief Queries _NET_WM_PID once; returns 0 when unavailable. qint64 readPid() const; + + /// @brief Sends the ICCCM WM_CHANGE_STATE ClientMessage to the root window. + /// + /// @p state_action is the ICCCM state value (3 = IconicState, 1 = + /// NormalState). Routed to the root window with SubstructureRedirectMask so + /// the window manager (XWayland on WSL) performs the actual state change. + /// + /// @param[in] stateAction IconicState (3) to minimize, NormalState (1) to + /// restore. + /// + /// @throws None + /// @note No-op when the connection, window, or WM_CHANGE_STATE atom is + /// missing. + /// @since 0.21 + void sendWmChangeState(long stateAction) const; }; } // namespace cf::desktop::backend::wsl diff --git a/ui/platform/linux_wsl/wsl_x11_window_backend.cpp b/ui/platform/linux_wsl/wsl_x11_window_backend.cpp index 41f0c5a06..2c1f39975 100644 --- a/ui/platform/linux_wsl/wsl_x11_window_backend.cpp +++ b/ui/platform/linux_wsl/wsl_x11_window_backend.cpp @@ -179,6 +179,7 @@ bool WSLX11WindowBackend::internAtoms() { atoms_.net_wm_pid = internAtom("_NET_WM_PID"); atoms_.wm_protocols = internAtom("WM_PROTOCOLS"); atoms_.wm_delete_window = internAtom("WM_DELETE_WINDOW"); + atoms_.wm_change_state = internAtom("WM_CHANGE_STATE"); atoms_.net_wm_window_type = internAtom("_NET_WM_WINDOW_TYPE"); atoms_.net_wm_window_type_dock = internAtom("_NET_WM_WINDOW_TYPE_DOCK"); atoms_.net_wm_window_type_desktop = internAtom("_NET_WM_WINDOW_TYPE_DESKTOP"); diff --git a/ui/platform/windows/windows_window.cpp b/ui/platform/windows/windows_window.cpp index bdf5944c8..f86c9bec1 100644 --- a/ui/platform/windows/windows_window.cpp +++ b/ui/platform/windows/windows_window.cpp @@ -76,6 +76,27 @@ void WindowsWindow::raise() { BringWindowToTop(hwnd_); } +void WindowsWindow::minimize() { + if (!hwnd_ || !IsWindow(hwnd_)) { + return; + } + ShowWindow(hwnd_, SW_MINIMIZE); +} + +void WindowsWindow::maximize() { + if (!hwnd_ || !IsWindow(hwnd_)) { + return; + } + ShowWindow(hwnd_, SW_MAXIMIZE); +} + +void WindowsWindow::restore() { + if (!hwnd_ || !IsWindow(hwnd_)) { + return; + } + ShowWindow(hwnd_, SW_RESTORE); +} + } // namespace cf::desktop::backend::windows #endif // CFDESKTOP_OS_WINDOWS diff --git a/ui/platform/windows/windows_window.h b/ui/platform/windows/windows_window.h index 7bd274607..098031c2b 100644 --- a/ui/platform/windows/windows_window.h +++ b/ui/platform/windows/windows_window.h @@ -71,6 +71,21 @@ class WindowsWindow : public IWindow { */ void raise() override; + /** + * @brief Minimizes (iconifies) this window via ShowWindow(SW_MINIMIZE). + */ + void minimize() override; + + /** + * @brief Maximizes this window via ShowWindow(SW_MAXIMIZE). + */ + void maximize() override; + + /** + * @brief Restores this window via ShowWindow(SW_RESTORE). + */ + void restore() override; + /** * @brief Returns the native Win32 window handle. * From 108ff18212d5e40b218619611bb83e11959ee25f Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 00:56:45 +0800 Subject: [PATCH 3/8] feat(window-mgmt): WindowManager state machine + queries + tests Stage C of MS5. The WindowManager now drives minimize/maximize/restore through a state machine and exposes queries the taskbar toggle will use. - WindowManager: add minimizeWindow/maximizeWindow/restoreWindow (validated via isValidTransition: Normal<->Minimized, Normal<->Maximized; rejects Maximized<->Minimized direct jumps, Closed is terminal), getWindowInfo / getAllWindowInfos / findWindowByPid queries, and windowStateChanged / windowInfoUpdated signals. applyState dispatches the IWindow op, updates the stored info, and emits both signals. - onWindowCame: populate the new WindowInfo fields (created_at etc.) and also index the weak reference in windows_ so externally-arrived windows are resolvable by find_window (previously windows_ was only filled by create_window, leaving the lookup dead for tracked external windows). - destroyed lambda: emit the Closed transition + windowInfoUpdated BEFORE erasing so subscribers observe the final state; also erase windows_. - 14-case unit test (FakeWindow + QSignalSpy when Qt6::Test is available) covering happy paths, rejected transitions, queries, and the destroyed Closed emission. --- test/desktop/CMakeLists.txt | 3 + test/desktop/window_manager/CMakeLists.txt | 42 ++++ test/desktop/window_manager/fake_window.h | 57 ++++++ .../window_manager/window_manager_test.cpp | 190 ++++++++++++++++++ ui/components/WindowManager.cpp | 130 +++++++++++- ui/components/WindowManager.h | 137 +++++++++++++ 6 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 test/desktop/window_manager/CMakeLists.txt create mode 100644 test/desktop/window_manager/fake_window.h create mode 100644 test/desktop/window_manager/window_manager_test.cpp diff --git a/test/desktop/CMakeLists.txt b/test/desktop/CMakeLists.txt index d0ef483f5..c6098be35 100644 --- a/test/desktop/CMakeLists.txt +++ b/test/desktop/CMakeLists.txt @@ -10,6 +10,9 @@ add_subdirectory(frosted_backdrop) # Add window placement tests subdirectory add_subdirectory(window_placement) +# Add window manager (state machine) tests subdirectory +add_subdirectory(window_manager) + # Add wallpaper animation tests subdirectory add_subdirectory(wallpaper_animation) diff --git a/test/desktop/window_manager/CMakeLists.txt b/test/desktop/window_manager/CMakeLists.txt new file mode 100644 index 000000000..649bba626 --- /dev/null +++ b/test/desktop/window_manager/CMakeLists.txt @@ -0,0 +1,42 @@ +# WindowManager state-machine unit tests. +# Drives minimize/maximize/restore via an in-memory FakeWindow (no backend, no +# display). Pure logic + signal emission — Qt6::Widgets pulled in for the +# QObject/QSignalSpy machinery. +add_executable(window_manager_test + window_manager_test.cpp +) + +target_include_directories(window_manager_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/ui/components +) + +target_link_libraries(window_manager_test PRIVATE + cf_desktop_components + Qt6::Core + Qt6::Widgets + GTest::gtest + GTest::gtest_main +) + +# Qt6::Test enables QSignalSpy-based signal verification when available. +if(Qt6Test_FOUND) + target_link_libraries(window_manager_test PRIVATE Qt6::Test) + target_compile_definitions(window_manager_test PRIVATE QT_TEST_AVAILABLE) + log_info("window_manager_tests" " - Qt6::Test available, signal tests enabled") +else() + log_info("window_manager_tests" " - Qt6::Test not available, signal tests disabled") +endif() + +set_target_properties(window_manager_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +add_test(NAME window_manager_test COMMAND window_manager_test) +set_tests_properties(window_manager_test PROPERTIES + LABELS "desktop;unit;components" + TIMEOUT 60 + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +log_info("window_manager_tests" " - window_manager_test") diff --git a/test/desktop/window_manager/fake_window.h b/test/desktop/window_manager/fake_window.h new file mode 100644 index 000000000..3c2d0326b --- /dev/null +++ b/test/desktop/window_manager/fake_window.h @@ -0,0 +1,57 @@ +/** + * @file fake_window.h + * @brief Fake IWindow for WindowManager unit tests. + * + * FakeWindow implements the IWindow contract entirely in-memory: every query + * returns canned data and every operation increments a counter. This lets the + * WindowManager state-machine tests assert behavior with no platform backend + * and no display. It inherits IWindow's Q_OBJECT meta-object (so QObject:: + * destroyed fires normally) without declaring its own Q_OBJECT. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-05 + * @version 0.1 + * @since 0.21 + * @ingroup components + */ + +#pragma once + +#include "IWindow.h" + +#include +#include + +namespace cf::desktop::test { + +/// @brief In-memory IWindow double that records every operation. +class FakeWindow : public IWindow { + public: + FakeWindow(QString id, QString title, QRect geo, qint64 pid, QObject* parent = nullptr) + : IWindow(parent), id_(std::move(id)), title_(std::move(title)), geo_(geo), pid_(pid) {} + + win_id_t windowID() const override { return id_; } + QString title() const override { return title_; } + QRect geometry() const override { return geo_; } + void set_geometry(const QRect& r) override { geo_ = r; } + void requestClose() override { ++close_calls; } + void raise() override { ++raise_calls; } + void minimize() override { ++minimize_calls; } + void maximize() override { ++maximize_calls; } + void restore() override { ++restore_calls; } + qint64 pid() const override { return pid_; } + + int close_calls = 0; + int raise_calls = 0; + int minimize_calls = 0; + int maximize_calls = 0; + int restore_calls = 0; + + private: + QString id_; + QString title_; + QRect geo_; + qint64 pid_; +}; + +} // namespace cf::desktop::test diff --git a/test/desktop/window_manager/window_manager_test.cpp b/test/desktop/window_manager/window_manager_test.cpp new file mode 100644 index 000000000..ea445b825 --- /dev/null +++ b/test/desktop/window_manager/window_manager_test.cpp @@ -0,0 +1,190 @@ +/** + * @file window_manager_test.cpp + * @brief GoogleTest unit tests for the WindowManager state machine. + * + * Drives minimize/maximize/restore through a FakeWindow (no backend, no + * display) and asserts: the IWindow operation is dispatched (counter + * incremented), the stored WindowInfo state transitions correctly, the + * transition table rejects invalid moves, queries behave on known and unknown + * ids, and QObject::destroyed emits the Closed transition before the entry is + * erased. When Qt6::Test is available, signal emission is also verified with + * QSignalSpy. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-05 + * @version 0.1 + * @since 0.21 + * @ingroup components + */ + +#include "WindowManager.h" +#include "fake_window.h" +#include "window_info.h" + +#include + +#ifdef QT_TEST_AVAILABLE +# include +#endif + +#include + +// WindowState is an enum class; QSignalSpy needs a metatype to capture it. +Q_DECLARE_METATYPE(cf::desktop::WindowState) + +namespace { + +using cf::desktop::IWindow; +using cf::desktop::win_id_t; +using cf::desktop::WindowInfo; +using cf::desktop::WindowManager; +using cf::desktop::WindowState; +using cf::desktop::test::FakeWindow; + +/// @brief Owns a WindowManager plus one registered FakeWindow for a test. +struct FakeFixture { + WindowManager wm; + std::unique_ptr window; + + FakeFixture() { + window = std::make_unique("win-1", "Title", QRect(10, 20, 800, 600), 4242); + wm.registerWindowForTest(window->make_weak()); + } +}; + +} // namespace + +// ── Registration populates the data model ────────────────────────────────── + +TEST(WindowManager, Register_PopulatesWindowInfo) { + FakeFixture f; + const WindowInfo info = f.wm.getWindowInfo("win-1"); + EXPECT_EQ(info.window_id, "win-1"); + EXPECT_EQ(info.title, "Title"); + EXPECT_EQ(info.pid, 4242); + EXPECT_EQ(info.state, WindowState::Normal); + EXPECT_TRUE(info.created_at.isValid()); +} + +// ── Happy-path state transitions ─────────────────────────────────────────── + +TEST(WindowManager, Minimize_Normal_DispatchesAndUpdatesState) { + FakeFixture f; + EXPECT_TRUE(f.wm.minimizeWindow("win-1")); + EXPECT_EQ(f.window->minimize_calls, 1); + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Minimized); +} + +TEST(WindowManager, Maximize_Normal_DispatchesAndUpdatesState) { + FakeFixture f; + EXPECT_TRUE(f.wm.maximizeWindow("win-1")); + EXPECT_EQ(f.window->maximize_calls, 1); + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Maximized); +} + +TEST(WindowManager, Restore_Minimized_DispatchesAndUpdatesState) { + FakeFixture f; + ASSERT_TRUE(f.wm.minimizeWindow("win-1")); + EXPECT_TRUE(f.wm.restoreWindow("win-1")); + EXPECT_EQ(f.window->restore_calls, 1); + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Normal); +} + +// ── Invalid transitions are rejected, no dispatch, no state change ───────── + +TEST(WindowManager, Minimize_OnMinimized_Rejected_NoDispatch) { + FakeFixture f; + ASSERT_TRUE(f.wm.minimizeWindow("win-1")); + EXPECT_FALSE(f.wm.minimizeWindow("win-1")); // already Minimized + EXPECT_EQ(f.window->minimize_calls, 1); // not dispatched again + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Minimized); +} + +TEST(WindowManager, Maximize_FromMinimized_Rejected_NoDispatch) { + FakeFixture f; + ASSERT_TRUE(f.wm.minimizeWindow("win-1")); + EXPECT_FALSE(f.wm.maximizeWindow("win-1")); // must restore first + EXPECT_EQ(f.window->maximize_calls, 0); + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Minimized); +} + +TEST(WindowManager, StateOp_OnUnknownId_ReturnsFalse) { + FakeFixture f; + EXPECT_FALSE(f.wm.minimizeWindow("nope")); + EXPECT_FALSE(f.wm.maximizeWindow("nope")); + EXPECT_FALSE(f.wm.restoreWindow("nope")); + EXPECT_EQ(f.window->minimize_calls, 0); +} + +// ── Queries ──────────────────────────────────────────────────────────────── + +TEST(WindowManager, GetWindowInfo_UnknownId_ReturnsClosed) { + FakeFixture f; + const auto gone = f.wm.getWindowInfo("missing"); + EXPECT_EQ(gone.state, WindowState::Closed); + EXPECT_TRUE(gone.window_id.isEmpty()); +} + +TEST(WindowManager, FindWindowByPid_ReturnsIdOrNullopt) { + FakeFixture f; + EXPECT_EQ(f.wm.findWindowByPid(4242), std::optional("win-1")); + EXPECT_FALSE(f.wm.findWindowByPid(9999).has_value()); + EXPECT_FALSE(f.wm.findWindowByPid(0).has_value()); // 0 is never matched +} + +TEST(WindowManager, GetAllWindowInfos_ReturnsAllRegistered) { + WindowManager wm; + FakeWindow a{"a", "A", QRect(0, 0, 100, 100), 10}; + FakeWindow b{"b", "B", QRect(0, 0, 100, 100), 20}; + wm.registerWindowForTest(a.make_weak()); + wm.registerWindowForTest(b.make_weak()); + const auto all = wm.getAllWindowInfos(); + EXPECT_EQ(all.size(), 2); +} + +// ── Destroyed observation emits Closed then disappears ───────────────────── + +TEST(WindowManager, Destroyed_EmitsClosedTransitionThenErases) { + FakeFixture f; + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Normal); + f.window.reset(); // destroy the FakeWindow -> QObject::destroyed fires + EXPECT_EQ(f.wm.getWindowInfo("win-1").state, WindowState::Closed); + EXPECT_FALSE(f.wm.findWindowByPid(4242).has_value()); +} + +// ── Signal emission (only when Qt6::Test is available) ───────────────────── + +#ifdef QT_TEST_AVAILABLE +TEST(WindowManager, Minimize_EmitsWindowStateChangedAndInfoUpdated) { + qRegisterMetaType("cf::desktop::WindowState"); + FakeFixture f; + QSignalSpy state_changed(&f.wm, &WindowManager::windowStateChanged); + QSignalSpy info_updated(&f.wm, &WindowManager::windowInfoUpdated); + ASSERT_TRUE(f.wm.minimizeWindow("win-1")); + EXPECT_EQ(state_changed.count(), 1); + EXPECT_EQ(state_changed.takeFirst().at(1).value(), + WindowState::Minimized); + EXPECT_EQ(info_updated.count(), 1); +} + +TEST(WindowManager, RejectedTransition_EmitsNothing) { + qRegisterMetaType("cf::desktop::WindowState"); + FakeFixture f; + QSignalSpy state_changed(&f.wm, &WindowManager::windowStateChanged); + EXPECT_FALSE(f.wm.minimizeWindow("missing")); + EXPECT_EQ(state_changed.count(), 0); +} + +TEST(WindowManager, Destroyed_EmitsClosedSignal) { + qRegisterMetaType("cf::desktop::WindowState"); + FakeFixture f; + QSignalSpy state_changed(&f.wm, &WindowManager::windowStateChanged); + QSignalSpy disappeared(&f.wm, &WindowManager::windowDisappeared); + f.window.reset(); + EXPECT_EQ(state_changed.count(), 1); + EXPECT_EQ(state_changed.takeFirst().at(1).value(), + WindowState::Closed); + EXPECT_EQ(disappeared.count(), 1); + EXPECT_EQ(disappeared.takeFirst().at(0).value(), 4242); +} +#endif \ No newline at end of file diff --git a/ui/components/WindowManager.cpp b/ui/components/WindowManager.cpp index e2bcbbe8a..b4d728234 100644 --- a/ui/components/WindowManager.cpp +++ b/ui/components/WindowManager.cpp @@ -1,6 +1,8 @@ #include "WindowManager.h" #include "IWindowBackend.h" +#include + namespace cf::desktop { WindowManager::WindowManager(QObject* parent) : QObject(parent) {} @@ -67,18 +69,144 @@ void WindowManager::onWindowCame(aex::WeakPtr window) { info.pid = window_raw->pid(); info.geometry = window_raw->geometry(); info.state = WindowState::Normal; + info.created_at = QDateTime::currentDateTime(); + // icon_hint / z_index / is_always_on_top keep their defaults (empty/0/ + // false); the tracker does not observe them today. window_infos_[id] = info; + // Also index the weak reference so find_window / request_close_window / + // raise_a_window / the state machine can resolve externally-arrived + // windows (not just those from create_window). + windows_[id] = std::move(window); // The backend emits window_gone after the IWindow object is already // destroyed (its weak reference expires), so that signal cannot carry the // id. Watch QObject::destroyed instead: it fires while the entry is still // identifiable via the captured id. connect(window_raw, &QObject::destroyed, this, [this, id, pid = info.pid]() { - window_infos_.erase(id); + // Emit the Closed transition BEFORE erasing so subscribers to + // windowStateChanged / windowInfoUpdated observe the final state. After + // erase, getWindowInfo(id) returns a Closed sentinel (state == Closed). + auto it = window_infos_.find(id); + if (it != window_infos_.end()) { + it->second.state = WindowState::Closed; + emit windowStateChanged(id, WindowState::Closed); + emit windowInfoUpdated(id, it->second); + window_infos_.erase(it); + } + windows_.erase(id); emit windowDisappeared(pid); }); emit windowAppeared(info.pid); + emit windowInfoUpdated(id, info); +} + +// ── State machine ────────────────────────────────────────────────────────── + +bool WindowManager::isValidTransition(WindowState from, WindowState to) { + if (from == to) { + return false; + } + // Closed is terminal; Closing only resolves to Closed. + if (from == WindowState::Closed) { + return false; + } + if (from == WindowState::Closing) { + return to == WindowState::Closed; + } + switch (to) { + case WindowState::Minimized: + case WindowState::Maximized: + // Only reachable from the resting Normal state. + return from == WindowState::Normal; + case WindowState::Normal: + // Restore from Minimized or Maximized. + return from == WindowState::Minimized || from == WindowState::Maximized; + case WindowState::Closing: + // Any live state may request a close. + return true; + case WindowState::Fullscreen: + // Out of scope for this slice. + return false; + case WindowState::Closed: + // Closed is only reached via the destroyed observation, not here. + return false; + } + return false; +} + +bool WindowManager::applyState(const win_id_t& id, WindowState to, void (IWindow::*op)()) { + auto it = window_infos_.find(id); + if (it == window_infos_.end()) { + return false; + } + if (!isValidTransition(it->second.state, to)) { + return false; + } + // Re-fetch the live window and re-check: it may have been torn down + // between the caller's lookup and here (Qt direct connection keeps this + // same-thread, so in practice the window cannot vanish mid-call, but the + // guard is cheap). + auto* raw = find_window(id).Get(); + if (raw == nullptr) { + return false; + } + (raw->*op)(); + it->second.state = to; + emit windowStateChanged(id, to); + emit windowInfoUpdated(id, it->second); + return true; +} + +bool WindowManager::minimizeWindow(const win_id_t& id) { + return applyState(id, WindowState::Minimized, &IWindow::minimize); +} + +bool WindowManager::maximizeWindow(const win_id_t& id) { + return applyState(id, WindowState::Maximized, &IWindow::maximize); +} + +bool WindowManager::restoreWindow(const win_id_t& id) { + return applyState(id, WindowState::Normal, &IWindow::restore); +} + +// ── Queries ──────────────────────────────────────────────────────────────── + +WindowInfo WindowManager::getWindowInfo(const win_id_t& id) const { + const auto it = window_infos_.find(id); + if (it != window_infos_.end()) { + return it->second; + } + // Unknown id (e.g. window already gone): report the terminal state so + // callers can treat absence uniformly. + WindowInfo gone; + gone.state = WindowState::Closed; + return gone; +} + +QList WindowManager::getAllWindowInfos() const { + QList out; + out.reserve(static_cast(window_infos_.size())); + for (const auto& kv : window_infos_) { + out.append(kv.second); + } + return out; +} + +std::optional WindowManager::findWindowByPid(qint64 pid) const { + if (pid == 0) { + return std::nullopt; + } + for (const auto& kv : window_infos_) { + if (kv.second.pid == pid) { + return kv.first; + } + } + return std::nullopt; +} + +void WindowManager::registerWindowForTest(aex::WeakPtr window) { + onWindowCame(std::move(window)); } } // namespace cf::desktop diff --git a/ui/components/WindowManager.h b/ui/components/WindowManager.h index df7c1977e..937b67fdf 100644 --- a/ui/components/WindowManager.h +++ b/ui/components/WindowManager.h @@ -19,7 +19,9 @@ #include "window_info.h" #include +#include #include +#include #include namespace cf::desktop { @@ -89,6 +91,115 @@ class WindowManager : public QObject { */ bool raise_a_window(aex::WeakPtr window); + // ── Window state machine (minimize / maximize / restore) ────────────── + + /** + * @brief Minimizes (iconifies) the tracked window with the given id. + * + * Validates the state transition (Normal -> Minimized), dispatches + * IWindow::minimize on the live window, updates the stored WindowInfo, and + * emits windowStateChanged + windowInfoUpdated. + * + * @param[in] id The window identifier. + * + * @return True on success; false when the id is unknown, the window is + * gone, or the transition is invalid. + * + * @throws None + * @note No-op signal-wise when the transition is rejected. + * @since 0.21 + */ + bool minimizeWindow(const win_id_t& id); + + /** + * @brief Maximizes the tracked window with the given id. + * + * Validates the state transition (Normal -> Maximized), dispatches + * IWindow::maximize, updates the stored WindowInfo, and emits + * windowStateChanged + windowInfoUpdated. + * + * @param[in] id The window identifier. + * + * @return True on success; false when the id is unknown, the window is + * gone, or the transition is invalid. + * + * @throws None + * @note No-op signal-wise when the transition is rejected. + * @since 0.21 + */ + bool maximizeWindow(const win_id_t& id); + + /** + * @brief Restores the tracked window (Minimized/Maximized -> Normal). + * + * Validates the state transition, dispatches IWindow::restore, updates the + * stored WindowInfo, and emits windowStateChanged + windowInfoUpdated. + * + * @param[in] id The window identifier. + * + * @return True on success; false when the id is unknown, the window is + * gone, or the transition is invalid. + * + * @throws None + * @note No-op signal-wise when the transition is rejected. + * @since 0.21 + */ + bool restoreWindow(const win_id_t& id); + + // ── Window queries ──────────────────────────────────────────────────── + + /** + * @brief Returns the stored WindowInfo for the given id. + * + * @param[in] id The window identifier. + * + * @return A copy of the stored WindowInfo, or a WindowInfo with state == + * Closed when the id is unknown (e.g. the window is gone). + * + * @throws None + * @since 0.21 + */ + WindowInfo getWindowInfo(const win_id_t& id) const; + + /** + * @brief Returns snapshots of all currently tracked windows. + * + * @return A list of WindowInfo for every live tracked window. + * + * @throws None + * @since 0.21 + */ + QList getAllWindowInfos() const; + + /** + * @brief Finds the tracked window id whose owning pid matches @p pid. + * + * @param[in] pid The process id to look up. + * + * @return The first matching window id, or std::nullopt when no tracked + * window has that pid. + * + * @throws None + * @note Returns the first match when several windows share a pid. + * @since 0.21 + */ + std::optional findWindowByPid(qint64 pid) const; + + /** + * @brief Registers a window directly (test hook). + * + * Forwards to the same path used when the backend reports window_came, so + * tests can drive the state machine with a fake IWindow and no real + * backend or display. + * + * @param[in] window Weak reference to the window to register. + * + * @throws None + * @note Test-only; production code registers windows via the backend. + * @since 0.21 + */ + void registerWindowForTest(aex::WeakPtr window); + signals: /** * @brief Emitted when a tracked window appears. @@ -104,10 +215,36 @@ class WindowManager : public QObject { */ void windowDisappeared(qint64 pid); + /** + * @brief Emitted after a window's state changes. + * + * @param[in] id The window identifier. + * @param[in] newState The new lifecycle state. + * + * @since 0.21 + */ + void windowStateChanged(const win_id_t& id, WindowState newState); + + /** + * @brief Emitted whenever a tracked WindowInfo is set or updated. + * + * @param[in] id The window identifier. + * @param[in] info The updated window snapshot. + * + * @since 0.21 + */ + void windowInfoUpdated(const win_id_t& id, const WindowInfo& info); + private: /// @brief Records a newly appeared window and watches its destruction. void onWindowCame(aex::WeakPtr window); + /// @brief Returns whether a state transition is permitted by the model. + static bool isValidTransition(WindowState from, WindowState to); + + /// @brief Drives a state transition end-to-end (validate, dispatch, emit). + bool applyState(const win_id_t& id, WindowState to, void (IWindow::*op)()); + /// Weak reference to the window backend. Ownership: external. aex::WeakPtr window_backend_{nullptr}; /// Tracked windows keyed by window ID (weak references only). Ownership: backend. From 5f27b21519bf1c0ec38465c71123dd0cb179fe0b Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 01:00:02 +0800 Subject: [PATCH 4/8] feat(window-mgmt): place new windows via FloatingPolicy (cascade) Stage D of MS5. The window_came handler now uses FloatingPolicy for the initial geometry instead of WindowPlacementPolicy.computeConstrain. On each external window appearance the window is centered in the work area, shrunk to fit when larger, and nudged by a 24px cascade offset per consecutive window so a burst of launches does not stack exactly on top of each other. The cascade index is a monotonic per-entity counter (no dependence on slot execution order). The window's current size is passed as the desired size so the app's chosen geometry is respected and only the position (plus shrink/center) changes. WindowPlacementPolicy still runs on work-area changes (reconstrain_windows) to pull existing windows back inside when the desktop is resized. Same config toggle (window_management/constrain_to_workarea, default on). --- ui/CFDesktopEntity.cpp | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 6f493bb19..a7c7e7f85 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -18,6 +18,7 @@ #include "components/launcher/desktop_entry_index.h" #include "components/statusbar/status_bar.h" #include "components/taskbar/centered_taskbar.h" +#include "components/window_placement/floating_policy.h" #include "components/window_placement/window_placement_policy.h" #include "platform/DesktopPropertyStrategyFactory.h" #include "platform/display_backend_helper.h" @@ -285,18 +286,22 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { } } - // ── Window placement: constrain launched windows into the work area ── - // On each external window appearance, clamp it inside the central work area - // (between the status bar and the taskbar) so it neither overlaps a bar nor - // flies off-screen, mirroring a real desktop WM. The policy is stateless, so - // a temporary is fine here. Runtime toggle via config domain + // ── Window placement: give each new window an initial floating geometry ── + // On each external window appearance, place it via FloatingPolicy: centered + // in the central work area (between the status bar and the taskbar), + // shrunk to fit when larger than the work area, and nudged by a small + // cascade offset per consecutive window so a burst of launches does not + // stack exactly on top of each other. The WindowPlacementPolicy still runs + // on work-area changes (see reconstrain_windows above) to pull windows back + // inside when the desktop is resized. Runtime toggle via config domain // "window_management" / key "constrain_to_workarea" (default on), read per // appearance so flipping the config needs no restart. + auto cascade_index = std::make_shared(0); if (display_backend_) { if (auto window_backend = display_backend_->windowBackend()) { QObject::connect( window_backend.Get(), &cf::desktop::IWindowBackend::window_came, this, - [panel_mgr, this](aex::WeakPtr win) { + [panel_mgr, this, cascade_index](aex::WeakPtr win) { if (!win) { cf::log::warningftag("WindowPlacement", "window_came with null window"); return; @@ -309,16 +314,19 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { .query(cfg::KeyView{.group = "window_management", .key = "constrain_to_workarea"}, true); + if (!enabled) { + return; + } const QRect work = panel_mgr->availableGeometry().translated(desktop_entity_->pos()); const QRect cur = w->geometry(); - const auto target = - cf::desktop::placement::WindowPlacementPolicy{}.computeConstrain(cur, work, - enabled); - if (target.has_value()) { - w->set_geometry(*target); - cf::log::infoftag("WindowPlacement", "constrained '{}' {} -> {}", - w->title().toStdString(), cur, *target); + const int idx = (*cascade_index)++; + const auto target = cf::desktop::placement::FloatingPolicy::initialGeometry( + work, idx, cur.size()); + if (target != cur) { + w->set_geometry(target); + cf::log::infoftag("WindowPlacement", "placed '{}' {} -> {} (cascade #{})", + w->title().toStdString(), cur, target, idx); } }); } From 5812ecf352b3018aa837c69bb4344e19b65fde55 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 01:01:45 +0800 Subject: [PATCH 5/8] feat(window-mgmt): taskbar click toggles raise/minimize for running apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage E of MS5. Clicking a running app's taskbar icon no longer spawns a second instance; it toggles the existing tracked window. launch_app (used by both the taskbar click and the launcher popup) now checks app_pid + WindowManager::findWindowByPid before launching: if the clicked app has a live tracked window it toggles state — Normal minimizes, Minimized restores + raises, any other state (Maximized etc.) just raises. A stale app_pid entry whose window already died falls through to a normal relaunch because findWindowByPid returns nullopt once the window is gone. BuiltinPanel apps are unaffected (they still pop up in-process). --- ui/CFDesktopEntity.cpp | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index a7c7e7f85..bcfafa8c8 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -358,8 +358,8 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // BuiltinPanel entries render in-process (registry lookup); DetachedProcess // entries spawn a QProcess. Used by both taskbar click and launcher popup // so the running-state indicator lights for either entry point. - std::function launch_app = [apps, app_pid, - panel_mgr](const QString& app_id) { + std::function launch_app = [apps, app_pid, panel_mgr, + window_mgr](const QString& app_id) { const cf::desktop::desktop_component::AppEntry* found = nullptr; for (const auto& app : apps) { if (app.app_id == app_id) { @@ -383,6 +383,31 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { } return; } + // DetachedProcess: if this app already has a live tracked window, toggle + // it (raise / minimize) instead of spawning a second instance. A stale + // app_pid entry whose window already died falls through to relaunch — + // findWindowByPid returns nullopt once the window is gone. + if (app_pid->contains(app_id)) { + const qint64 pid = (*app_pid)[app_id]; + if (auto win_id = window_mgr->findWindowByPid(pid); win_id.has_value()) { + using cf::desktop::WindowState; + const auto info = window_mgr->getWindowInfo(*win_id); + if (info.state == WindowState::Normal) { + window_mgr->minimizeWindow(*win_id); + } else if (info.state == WindowState::Minimized) { + window_mgr->restoreWindow(*win_id); + if (auto* w = window_mgr->find_window(*win_id).Get()) { + w->raise(); + } + } else { + // Maximized / other: leave the state alone, just raise. + if (auto* w = window_mgr->find_window(*win_id).Get()) { + w->raise(); + } + } + return; + } + } const auto launched = cf::desktop::desktop_component::AppLaunchService::launch(found->exec_command); if (launched.has_value()) { From 1d1ed315e3f197a48fab93124d183ad89f0987de Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 01:04:47 +0800 Subject: [PATCH 6/8] docs(window-mgmt): mark MS5 state machine + linkage landed; defer decoration Stage F of MS5. Reflects the code reality in the status docs. - milestone_05: status line updated (state machine + taskbar raise/minimize + FloatingPolicy landed 2026-07-05); acceptance criteria checkboxes updated (all but the two decoration items now met); new section documents the decoration DEFERRED decision and its three reasons (WSL X11/Win32 native title bars clash, EGLFS will own decoration natively, overlay would be rewritten); bottom date bumped. - current.md: MS5 line rewritten with the 2026-07-05 landing summary and the DEFERRED note; new 2026-07 MS5 entry under recent milestones; calibration date bumped to 2026-07-05. --- document/status/current.md | 5 ++- .../desktop/milestone_05_window_management.md | 43 ++++++++++++++----- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/document/status/current.md b/document/status/current.md index f4aaf82e3..c78e2babd 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -10,7 +10,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 # CFDesktop 当前项目状态 -> **校准日期**:2026-06-26 | **版本**:0.19.0 +> **校准日期**:2026-07-05 | **版本**:0.19.0 > **本文件是项目进度的唯一事实来源(single source of truth)。** 其他位置一律指向此处,勿另行手抄。 ## 项目导航(各信息去哪看) @@ -50,7 +50,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 1. **MS2 状态栏**(顶部时间 + 系统图标)— ✅ 功能落地(`StatusBar` 实现 + 注册 PanelManager + 主题跟随 + MD3 美化;offscreen 启动通过,待真机视觉确认) 2. **MS3 任务栏**(底部居中图标条 + hover 动画)— ✅ 最小切片完成(`CenteredTaskbar` 注册 Bottom 面板 + `TaskbarIcon` 居中图标/hover 放大/自绘 ripple/运行指示器;`appClicked` 已接 `AppLaunchService::launch` 真启动并记 PID,运行指示器经 `WindowManager` 窗口追踪联动——见 `desktop/ui/CFDesktopEntity.cpp:133-180`) 3. **MS4 应用启动器**(网格 + 搜索 + QProcess + 双态)— 🚧 进行中(双态框架 + 网格 + 搜索框落地:`LaunchKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 把 `builtin:` hack 正式化、消灭 if 链,calculator 双态,`HardwareTier::prefer_inprocess_apps` 按 Low/High 裁决;`AppLauncher` 网格 + 搜索 QLineEdit 实时过滤;`DesktopEntryIndex` 扫 XDG `.desktop`(firefox 等);Noter 移植成第二个独立 App。仅余入场/退场动画。详见 [milestone_04](../todo/desktop/milestone_04_app_launcher.md)) -4. **MS5 窗口管理**(窗口装饰 + 任务栏联动)— 🚧 进行中(追踪+联动切片跑通:`WindowManager` 追踪外部窗口 + `IWindow::pid()` + Taskbar 运行指示器联动;靠 PID 匹配,直接启动(xterm)可靠、间接启动(xdg-open)受限;**窗口装饰已定策→overlay 渲染**〔策略 A:CFDesktop 自绘 overlay 层呈现标题栏+控制按钮作纯视觉指示,因 WSL X11 客户端模式下外部窗口由 XWayland 管理无法直接装饰;X11 WM〔B〕/ Wayland Compositor〔C〕延后到 EGLFS/Wayland 后端,详见 [milestone_05](../todo/desktop/milestone_05_window_management.md):157-160〕) +4. **MS5 窗口管理**(窗口状态机 + 任务栏联动)— 🚧 进行中(追踪+联动切片跑通:`WindowManager` 追踪外部窗口 + `IWindow::pid()` + Taskbar 运行指示器联动;靠 PID 匹配,直接启动(xterm)可靠、间接启动(xdg-open)受限。**2026-07-05 状态机 + 联动 + FloatingPolicy 落地**:`WindowState` 状态机 (`isValidTransition` + `applyState`)、`IWindow` 加 `minimize/maximize/restore` 非纯虚默认 no-op(Windows 三态全实现 `ShowWindow`;WSL X11 minimize+restore 走 ICCCM `WM_CHANGE_STATE` ClientMessage、maximize 留 no-op 因 XWayland EWMH 不可靠)、WindowManager 查询 (`getWindowInfo`/`getAllWindowInfos`/`findWindowByPid`) + `windowStateChanged`/`windowInfoUpdated` 信号、Taskbar 点击运行图标走 raise/minimize toggle(Normal→minimize、Minimized→restore+raise、其他→仅 raise,不再二次启动)、`FloatingPolicy` 新窗口初始位置(居中+24px 级联+缩进 work area)、WindowInfo 补 `icon_hint`/`z_index`/`is_always_on_top`/`created_at`;FloatingPolicy 13 例 + WindowManager 14 例(FakeWindow+QSignalSpy)单测全绿。**装饰 overlay (策略 A) DEFERRED 到 EGLFS/Wayland-compositor 阶段**——WSL X11/Win32 外部窗口自带原生标题栏、overlay 重影;EGLFS 后端将原生持有装饰权。详见 [milestone_05](../todo/desktop/milestone_05_window_management.md)) 闭环达成后按需推进:CrashHandler、IPC、EGLFS 嵌入式后端、输入抽象层、P2/P3 控件。 @@ -67,6 +67,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-07(并发迁移 3 App)**:SystemState/AlarmyClock/CCCalendar 三 App 用 Agent **并行迁移**(参照 noter 范式,验证批量可复制):**SystemState**(`apps/system_state/`)复用 `cfbase` 的 CPU/memory probe(`getCPUProfileInfo`/`getCPUBonusInfo`/`getSystemMemoryInfo`)+ QTimer 刷新,展示 base 层能力;**AlarmyClock**(`apps/alarm_clock/`)1s 轮询 + QSpinBox 编辑器 + QListWidget 已设列表 + QMessageBox 响铃(音频 TODO);**CCCalendar**(`apps/calendar/`)`QCalendarWidget` + 日期笔记(内存 QMap,持久化 TODO)。集成时修:alarm_clock 的 `Q_DECLARE_METATYPE` 移到全局命名空间(moc 要求)、calendar 的 Qt6 API(`setVerticalGridLineVisible` 不存在、`DefaultLocaleLongDate` Qt6 删除→`Qt::TextDate`)。三 App 全 build 绿 + Doxygen 过。 - **2026-07(App 抽离到 CFDeskit + 发现机制重构)**:5 个独立 app(calculator/noter/alarm_clock/calendar/system_state)抽到独立仓 [CFDeskit](https://github.com/Awesome-Embedded-Learning-Studio/CFDeskit);主仓 `apps/` 源码全删(PR #22),改**运行时部署模式**——app 由 CFDeskit build + install 到 `/apps//`,`AppDiscoverer` 改扫 `active_root/apps`(`Apps` PathType 小写,原 `/../apps/` 已弃),空目录 → `log_info` 不报错。system_state **vendoring cfbase probe**(STATIC `mini_system_probe` lib,零 `libcfbase.so` 依赖,完全独立演化)。**ABI 部署隔离**:app rpath `$ORIGIN/..` 找 `apps/libquarkwidgets.so`(独立于 desktop `bin/` 那份),QuarkWidgets 加版本导出(PR #2 `quarkwidgets_runtime_abi_version`)+ CFDeskit `abi_check.hpp` 启动自检(mismatch `qFatal`)。源码开发停放 `third_party/apps/`(gitignore,clone 任意 app 仓一视同仁)。calculator parser 单测随源码迁 CFDeskit(15 TEST 全过)。主仓零 app 代码,desktop 本体纯净。注:`LoadKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 双态机制保留(builtin 仅 about)。 - **2026-07(主仓重组:cfbase + desktop/base 合并 + desktop/ 平铺)**:删 `desktop/` 包装层——`base/`(cfbase) + 原 `desktop/base/`(logger/config/path/filesystem/fundamental/ascii_art) 合并成新顶层 `base/`(cfbase 子目录化,desktop/base 6 模块同居);`desktop/ui` → `ui/`、`desktop/main` → `main/`(升顶层);`desktop/export.h` + `desktop/main.cpp` → 顶层;`CFDesktop_shared` 组装搬到顶层 CMakeLists。源码**零改动**(内部全相对 include,平移后路径不变,实测 `#include "desktop/"` = 0);CMake 重组(base/ 合并 + 顶层 `add_subdirectory(ui/main)` + CFDesktop_shared + exe,补 `enable_testing()` 顶层)。三层架构图 `base → ui → desktop` 改 `base → ui → main`。cfbase 名副其实(真正的 base 层,含探针 + 桌面基础设施)。验证:build 全绿 + ctest 全过 + desktop 启动正常。 +- **2026-07(MS5 窗口状态机 + 任务栏 raise/minimize + FloatingPolicy)**:补齐 milestone_05 除装饰外的全部验收项。`WindowState` 状态机(`isValidTransition`+`applyState`,Normal↔Minimized/Maximized、Closed 终态)+ WindowManager 查询/信号(`getWindowInfo`/`getAllWindowInfos`/`findWindowByPid` + `windowStateChanged`/`windowInfoUpdated`);`IWindow` 加 `minimize/maximize/restore` 非纯虚默认 no-op——Windows 三态全实现(`ShowWindow`),WSL X11 实现 minimize+restore(ICCCM 4.1.4 `WM_CHANGE_STATE` ClientMessage → root + SubstructureRedirectMask,即 `XIconifyWindow`/xdotool 路径)、maximize 留默认 no-op(XWayland 下 EWMH `_NET_WM_STATE` 不可靠);`onWindowCame` 同步填 `windows_` map(原来只填 `window_infos_` 致 `find_window` 对外部窗口失效) + destroyed lambda 先发 Closed 再 erase;Taskbar 点击运行图标走 raise/minimize toggle(不再二次启动);`FloatingPolicy` 新窗口初始位置(居中+24px 级联+缩进 work area,镜像 `WindowPlacementPolicy`);WindowInfo 补 `icon_hint`/`z_index`/`is_always_on_top`/`created_at`。单测:FloatingPolicy 13 例 + WindowManager 14 例(FakeWindow+QSignalSpy)。**装饰 overlay (策略 A) DEFERRED 到 EGLFS 阶段**(WSL X11/Win32 外部窗口自带原生标题栏、overlay 重影;EGLFS/Wayland-compositor 后端将原生持有装饰权)。详见 [milestone_05](../todo/desktop/milestone_05_window_management.md)。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/milestone_05_window_management.md b/document/todo/desktop/milestone_05_window_management.md index fc5e70ca0..1cf30e825 100644 --- a/document/todo/desktop/milestone_05_window_management.md +++ b/document/todo/desktop/milestone_05_window_management.md @@ -5,12 +5,14 @@ description: "预计周期: 7-10 天,前置依赖: Milestone 3: 任务栏 (任 # Milestone 5: 窗口管理可见 -> **状态**: 🚧 进行中(追踪 + 任务栏联动切片跑通;窗口装饰走 overlay 渲染〔策略 A〕待实现) +> **状态**: 🚧 进行中(窗口状态机 + 任务栏 raise/minimize 联动 + FloatingPolicy 落地〔2026-07-05〕;装饰 overlay 渲染 DEFERRED 到 EGLFS 阶段) > **预计周期**: 7-10 天 > **前置依赖**: [Milestone 3: 任务栏](milestone_03_taskbar.md) (任务栏联动) -> **目标**: 外部应用窗口能被管理——有窗口装饰,支持最小化/最大化/关闭,任务栏同步显示运行中应用 +> **目标**: 外部应用窗口能被管理——支持最小化/最大化/恢复/关闭,任务栏同步显示运行中应用,点击运行图标切换 raise/minimize > > **决策(2026-06-26)**:窗口装饰采用**策略 A — overlay 渲染**为初期路径(CFDesktop 自绘层呈现标题栏 + 控制按钮,纯视觉指示;WSL X11 客户端模式下外部窗口由 XWayland 管理无法直接装饰)。策略 B(X11 WM)/ 策略 C(Wayland Compositor)延后到 EGLFS/Wayland 后端阶段。 +> +> **决策(2026-07-05)**:**装饰 overlay(策略 A)DEFERRED 到 EGLFS/Wayland-compositor 阶段**。详见文末「装饰 DEFERRED 决策」。本切片先行落地状态机 + 任务栏联动 + 浮动布局策略,不依赖装饰。 --- @@ -257,15 +259,34 @@ description: "预计周期: 7-10 天,前置依赖: Milestone 3: 任务栏 (任 ## 五、验收标准 -- [ ] 启动外部应用后 WindowManager 能追踪到新窗口 -- [ ] WindowManager 维护 WindowInfo 数据 (标题、PID、状态) -- [ ] 窗口状态可在 Normal/Minimized/Maximized 之间转换 -- [ ] Taskbar 同步显示运行中应用图标 -- [ ] 点击 Taskbar 图标可 raise/minimize 对应窗口 -- [ ] 窗口有自定义装饰 (至少标题 + 关闭按钮的视觉呈现) -- [ ] 窗口关闭后 Taskbar 图标自动消失 -- [ ] Light/Dark 主题切换时装饰正确变色 +- [x] 启动外部应用后 WindowManager 能追踪到新窗口 +- [x] WindowManager 维护 WindowInfo 数据 (标题、PID、状态、icon_hint/z_index/is_always_on_top/created_at) +- [x] 窗口状态可在 Normal/Minimized/Maximized 之间转换(IWindow minimize/maximize/restore + WindowManager 状态机;X11 上 maximize 留默认 no-op,详见 Step B6) +- [x] Taskbar 同步显示运行中应用图标 +- [x] 点击 Taskbar 图标可 raise/minimize 对应窗口(Normal→minimize、Minimized→restore+raise、其他→仅 raise) +- [ ] 窗口有自定义装饰 (至少标题 + 关闭按钮的视觉呈现) — **DEFERRED 到 EGLFS 阶段**(见下) +- [x] 窗口关闭后 Taskbar 图标自动消失(destroyed 观察发 Closed 迁移 + windowDisappeared) +- [ ] Light/Dark 主题切换时装饰正确变色 — 随装饰一同 DEFERRED + +--- + +## 装饰 DEFERRED 决策(2026-07-05) + +WindowDecoration(策略 A overlay)推迟到 EGLFS/Wayland-compositor 后端阶段。理由: + +1. **WSL X11 客户端模式无法直接装饰外部窗口**——外部窗口由 XWayland 管理,自带原生标题栏;CFDesktop 只能做纯视觉 overlay 指示,叠加上去会与原生标题栏重影。 +2. **Win32 后端同理**——外部窗口自带原生标题栏,overlay 重影。 +3. **EGLFS/Wayland-compositor 后端将原生持有窗口装饰权**——那时 CFDesktop 是 compositor,装饰才有真实价值;纯视觉 overlay 在没有 compositor 权限下收益有限,且会在后端切换时被重写。 + +本切片先行落地、且**不依赖装饰**的成果: + +- **WindowState 状态机**——`isValidTransition`(Normal↔Minimized、Normal↔Maximized;Maximized↔Minimized 须 restore 中转;Closed 终态)+ `applyState`(校验→派发 IWindow op→回写 info→发信号)。 +- **IWindow minimize/maximize/restore**——非纯虚默认 no-op;Windows 三态全实现(`ShowWindow`);WSL X11 实现 minimize+restore(ICCCM 4.1.4 `WM_CHANGE_STATE` ClientMessage → root + SubstructureRedirectMask,即 `XIconifyWindow`/xdotool 路径),maximize 留默认 no-op(XWayland 下 EWMH `_NET_WM_STATE` 不可靠)。 +- **WindowManager 查询/信号**——`getWindowInfo`/`getAllWindowInfos`/`findWindowByPid` + `windowStateChanged`/`windowInfoUpdated`。 +- **Taskbar raise/minimize toggle**——点击运行图标不再二次启动,而是按状态切换。 +- **FloatingPolicy**——新窗口初始位置(居中 + 24px 级联 + 缩进 work area)。 +- **单测**——FloatingPolicy 13 例 + WindowManager 14 例(FakeWindow + QSignalSpy)全绿。 --- -*最后更新: 2026-06-29(核对代码现状:`window_info.h` 骨架已存在待补字段、`WindowPlacementPolicy` 已落地补录)* +*最后更新: 2026-07-05(MS5 状态机 + 任务栏 raise/minimize 联动 + FloatingPolicy 落地;装饰 overlay DEFERRED 到 EGLFS)* From 54a34173bcfd5184f6ae97d4b68a9cb78208d6c4 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 02:18:46 +0800 Subject: [PATCH 7/8] fix(taskbar): paint a letter fallback when an app icon is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskbarIcon painted nothing when icon_mask_ was null, and the mask only resolves from a loadable file/qrc path — manifest apps ship no icon, .desktop Icon= is a freedesktop theme name (not a path), and builtin panels set none — so every such tile rendered as an identical blank rounded rectangle. Add a display-name initial fallback in paintEvent (mirroring LauncherTile) so every tile is identifiable. Default apps that ship qrc masks still render them; the tooltip (display_name) was already set. --- ui/components/taskbar/taskbar_icon.cpp | 18 ++++++++++++++++-- ui/components/taskbar/taskbar_icon.h | 4 +++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ui/components/taskbar/taskbar_icon.cpp b/ui/components/taskbar/taskbar_icon.cpp index f3435ae83..6f636943c 100644 --- a/ui/components/taskbar/taskbar_icon.cpp +++ b/ui/components/taskbar/taskbar_icon.cpp @@ -20,6 +20,7 @@ #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" +#include "core/token/typography/cfmaterial_typography_token_literals.h" #include #include @@ -41,6 +42,7 @@ constexpr qreal kHoverScale = 1.2; ///< Scale factor when hovered. constexpr qreal kIconRadius = 10.0; ///< Tile corner radius (px). constexpr qreal kDotRadius = 2.5; ///< Running-indicator dot radius (px). constexpr qreal kDotOffset = 6.0; ///< Dot offset below the tile (px). +constexpr int kGlyphPixelSize = 18; ///< Initial-letter font size (px). constexpr int kHoverDurationMs = 150; ///< Hover zoom duration (ms). constexpr int kRippleDurationMs = 350; ///< Ripple expansion duration (ms). constexpr int kRippleAlpha = 90; ///< Peak ripple overlay alpha. @@ -110,13 +112,21 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { p.drawEllipse(ripple_center_, radius, radius); } - // App glyph: the tinted icon mask. A missing mask leaves the tile blank - // (no silent letter fallback) so a broken resource stays obvious. + // App glyph: the tinted icon mask, or — when no icon resolves (manifest apps + // ship none, .desktop Icon= is a theme name we do not look up, builtin + // panels set none) — the display-name initial so every tile is identifiable. if (!icon_mask_.isNull()) { const qreal glyph = edge * 0.6; const QRectF glyph_rect(c.x() - glyph / 2.0, c.y() - glyph / 2.0, glyph, glyph); p.setRenderHint(QPainter::SmoothPixmapTransform, true); p.drawPixmap(glyph_rect, icon_mask_, QRectF(0, 0, icon_mask_.width(), icon_mask_.height())); + } else { + const QString letter = entry_.display_name.isEmpty() + ? QStringLiteral("?") + : QString(entry_.display_name.at(0)).toUpper(); + p.setPen(foreground_color_); + p.setFont(glyph_font_); + p.drawText(tile, Qt::AlignCenter, letter); } // Running indicator dot near the tile bottom. @@ -165,11 +175,15 @@ void TaskbarIcon::applyTheme() { tile_color_ = cs.queryColor(SURFACE_VARIANT); foreground_color_ = cs.queryColor(ON_SURFACE); indicator_color_ = cs.queryColor(ON_SURFACE_VARIANT); + glyph_font_ = theme.font_type().queryTargetFont(TYPOGRAPHY_TITLE_MEDIUM); + glyph_font_.setPixelSize(kGlyphPixelSize); } catch (...) { // Fallback palette when no theme is registered yet. tile_color_ = QColor(0xE7, 0xE0, 0xEC); foreground_color_ = QColor(0x1C, 0x1B, 0x1F); indicator_color_ = QColor(0x49, 0x45, 0x4E); + glyph_font_ = font(); + glyph_font_.setPixelSize(kGlyphPixelSize); } refreshIcon(); update(); diff --git a/ui/components/taskbar/taskbar_icon.h b/ui/components/taskbar/taskbar_icon.h index 8520818cb..7e4a24e00 100644 --- a/ui/components/taskbar/taskbar_icon.h +++ b/ui/components/taskbar/taskbar_icon.h @@ -19,6 +19,7 @@ #include "app_entry.h" #include +#include #include #include #include @@ -219,7 +220,8 @@ class TaskbarIcon final : public QWidget { QColor tile_color_; ///< Tile fill (surface variant). QColor foreground_color_; ///< Icon tint color (on surface). QColor indicator_color_; ///< Running dot color (on surface variant). - QPixmap icon_mask_; ///< Tinted icon pixmap; null -> blank tile (no fallback). + QPixmap icon_mask_; ///< Tinted icon pixmap; null -> letter fallback. + QFont glyph_font_; ///< Font for the initial-letter fallback. QVariantAnimation* hover_anim_{nullptr}; ///< Zoom-in/out animation. QVariantAnimation* ripple_anim_{nullptr}; ///< Ripple expansion animation. From 3e66e7e4037248e1e39e9340c18734b21c6e2ae0 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sun, 5 Jul 2026 02:43:11 +0800 Subject: [PATCH 8/8] refactor(shell): replace frosted backdrop with fixed-transparency surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frosted-glass backdrop (blur the live wallpaper strip + tint + cache) caused two problems on the bars: - Stale blur after a wallpaper rotation (the panels were never repainted, so the cached blur held the previous wallpaper). - A visible color snap when the blur was eventually rebuilt on settle. Drop the wallpaper-dependent blur entirely. The taskbar and status bar now paint a fixed-alpha surface (0.82) that lets the wallpaper composite through it directly — no per-wallpaper computation, no stale cache, no transition snap, and zero blur cost on every repaint (important on i.MX6ULL, which has no GPU and would pay a CPU box-blur otherwise). Removed per bar: FrostedBackdrop usage, frosted_params_, backdrop_source_, reblur_debounce_, backdrop_null_warned_, resizeEvent, setBackdropSource, and WA_OpaquePaintEvent (its removal is what lets the wallpaper composite through). CFDesktopEntity no longer calls setBackdropSource. The FrostedBackdrop utility class itself is retained (still unit-tested) for a future backend that can blurs cheaply (e.g. EGLFS with PXP). --- ui/CFDesktopEntity.cpp | 2 - ui/components/statusbar/status_bar.cpp | 99 ++++------------------ ui/components/statusbar/status_bar.h | 35 -------- ui/components/taskbar/centered_taskbar.cpp | 82 +++--------------- ui/components/taskbar/centered_taskbar.h | 37 +------- 5 files changed, 32 insertions(+), 223 deletions(-) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index bcfafa8c8..fea3ff2ef 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -270,7 +270,6 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // ── Status bar: top-edge panel (clock + system icons) ── auto* status_bar = new cf::desktop::desktop_component::StatusBar(desktop_entity_); - status_bar->setBackdropSource(shell); panel_mgr->registerPanel(status_bar->GetWeak()); status_bar->show(); @@ -352,7 +351,6 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { const QList apps = loadAppsConfig(prefer_inprocess); auto* taskbar = new cf::desktop::desktop_component::CenteredTaskbar(desktop_entity_); taskbar->setApps(apps); - taskbar->setBackdropSource(shell); panel_mgr->registerPanel(taskbar->GetWeak()); // Shared launch path: resolve app_id -> entry, dispatch by launch_kind. // BuiltinPanel entries render in-process (registry lookup); DetachedProcess diff --git a/ui/components/statusbar/status_bar.cpp b/ui/components/statusbar/status_bar.cpp index f5bb85ce7..0d1cdf195 100644 --- a/ui/components/statusbar/status_bar.cpp +++ b/ui/components/statusbar/status_bar.cpp @@ -16,7 +16,6 @@ #include "status_bar.h" -#include "base/color.h" #include "base/device_pixel.h" #include "cflog.h" #include "core/theme_manager.h" @@ -25,15 +24,12 @@ #include #include -#include #include #include #include -#include #include #include -#include #include // Q_INIT_RESOURCE must run at global scope: the rcc-generated registration @@ -48,19 +44,18 @@ static void registerStatusbarIconsResource() { namespace cf::desktop::desktop_component { using cf::desktop::PanelPosition; -using qw::base::CFColor; using qw::base::device::CanvasUnitHelper; using namespace qw::core::token::literals; namespace { // Fallback palette used when no theme is available (mirrors MD3 light). -constexpr int kBarHeight = 48; ///< Status bar thickness in device pixels. -constexpr int kSideMarginDp = 16; ///< Horizontal padding for clock/icons (dp). -constexpr int kIconGapDp = 12; ///< Spacing between adjacent icons (dp). -constexpr int kIconSizeDp = 16; ///< Icon cell edge length (dp). -constexpr int kTimeGapDp = 10; ///< Gap between the time and the icon cluster (dp). -constexpr qreal kShadowBandDp = 5.0; ///< In-band elevation shadow height (dp). -constexpr qreal kFrostTintAlpha = 0.60; ///< Frosted-glass tint opacity (wallpaper bleed). +constexpr int kBarHeight = 48; ///< Status bar thickness in device pixels. +constexpr int kSideMarginDp = 16; ///< Horizontal padding for clock/icons (dp). +constexpr int kIconGapDp = 12; ///< Spacing between adjacent icons (dp). +constexpr int kIconSizeDp = 16; ///< Icon cell edge length (dp). +constexpr int kTimeGapDp = 10; ///< Gap between the time and the icon cluster (dp). +constexpr qreal kShadowBandDp = 5.0; ///< In-band elevation shadow height (dp). +constexpr qreal kSurfaceAlpha = 0.82; ///< Fixed surface transparency over the wallpaper. // Icon kinds and their compiled-resource mask paths. Indices align with // StatusBar::icon_masks_[]. A missing mask leaves a visible gap in the bar @@ -78,7 +73,8 @@ constexpr const char* const kIconMaskPaths[kStatusIconCount] = { StatusBar::StatusBar(QWidget* parent) : QWidget(parent), timer_(new QTimer(this)), cached_time_(currentTimeText()), cached_date_(currentDateText()) { - setAttribute(Qt::WA_OpaquePaintEvent); + // No WA_OpaquePaintEvent: the bar paints a fixed-alpha surface so the + // wallpaper composites through it. Declaring it opaque would suppress that. setAutoFillBackground(false); setFixedHeight(kBarHeight); setupUi(); @@ -93,25 +89,6 @@ void StatusBar::setupUi() { connect(timer_, &QTimer::timeout, this, &StatusBar::onTimeout); timer_->start(1000); - // Coalesce rapid resizes (e.g. a window drag) into a single frosted-backdrop - // reblur instead of rebuilding on every intermediate geometry. - reblur_debounce_ = new QTimer(this); - reblur_debounce_->setSingleShot(true); - reblur_debounce_->setInterval(80); - connect(reblur_debounce_, &QTimer::timeout, this, [this]() { - frosted_.invalidate(); - update(); - }); - - // A screen change (different monitor / device-pixel-ratio) invalidates the - // backdrop cache so the next paint rebuilds at the new DPR. QWidget has no - // screenChanged signal, so listen to the application-wide primary-screen - // change; devicePixelRatioF() is also part of the cache key as a backstop. - connect(qApp, &QGuiApplication::primaryScreenChanged, this, [this]() { - frosted_.invalidate(); - update(); - }); - // React to theme switches (ThemeManager is the canonical source). connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, [this](const qw::core::ICFTheme&) { applyTheme(); }); @@ -127,29 +104,15 @@ void StatusBar::applyTheme() { icon_color_ = cs.queryColor(ON_SURFACE_VARIANT); divider_color_ = cs.queryColor(OUTLINE_VARIANT); clock_font_ = theme.font_type().queryTargetFont(TYPOGRAPHY_TITLE_MEDIUM); - // MD3 elevation tonal lift: lighten the surface tone a few steps for the - // top of the gradient, so the bar reads as raised above the shell. - const CFColor base(background_color_); - surface_top_color_ = - CFColor(base.hue(), base.chroma(), std::clamp(base.tone() + 3.0f, 0.0f, 100.0f)) - .native_color(); } catch (...) { // Fallback palette when no theme is registered yet. background_color_ = QColor(0xF7, 0xF5, 0xF3); - surface_top_color_ = QColor(0xFF, 0xFF, 0xFF); foreground_color_ = QColor(0x1C, 0x1B, 0x1F); icon_color_ = QColor(0x49, 0x45, 0x4E); divider_color_ = QColor(0xCA, 0xC4, 0xD0); clock_font_ = font(); clock_font_.setPixelSize(15); } - // Frosted-glass tint tracks the resolved surface color (covers both the - // themed and fallback branches above). The top highlight gives the top bar - // its "raised glass" reading. - frosted_params_.tint = background_color_; - frosted_params_.tint_alpha = kFrostTintAlpha; - frosted_params_.top_highlight = true; - frosted_.invalidate(); update(); } @@ -279,49 +242,21 @@ StatusBarStyle StatusBar::style() const { return style_; } -// -- Backdrop -------------------------------------------------------------- -void StatusBar::setBackdropSource(cf::desktop::IShellLayer* source) { - backdrop_source_ = source; - frosted_.invalidate(); - update(); -} - -void StatusBar::resizeEvent(QResizeEvent* event) { - QWidget::resizeEvent(event); - // Debounce: collapse a burst of resizes (e.g. a window drag) into one - // frosted-backdrop reblur rather than rebuilding on every intermediate size. - if (reblur_debounce_ != nullptr) { - reblur_debounce_->start(); - } -} - // -- Painting -------------------------------------------------------------- void StatusBar::paintEvent(QPaintEvent* /*event*/) { const CanvasUnitHelper h(devicePixelRatioF()); QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::SmoothPixmapTransform, true); - // 1. Frosted-glass backdrop at FULL opacity. The boot fade must only fade - // the content layer (clock, icons, chrome); if the glass faded too, the - // raw wallpaper would bleed through during the 250ms fade-in. + // 1. Fixed-transparency surface at FULL opacity. The wallpaper composites + // through the baked alpha (no per-wallpaper blur, no stale cache, no + // transition snap). The boot fade must only fade the content layer + // (clock, icons, chrome); if the surface faded too, the raw wallpaper + // would bleed through during the 250ms fade-in. p.setOpacity(1.0); - const QImage backdrop = - backdrop_source_ != nullptr ? backdrop_source_->currentBackgroundImage() : QImage{}; - const QRect strip = geometry(); - if (!backdrop.isNull() && !strip.isEmpty()) { - p.drawPixmap(0, 0, frosted_.render(backdrop, strip, devicePixelRatioF(), frosted_params_)); - backdrop_null_warned_ = false; - } else { - // Flat fallback (e.g. before the wallpaper loads). Fail loud, once. - QLinearGradient surface(0, 0, 0, height()); - surface.setColorAt(0.0, surface_top_color_); - surface.setColorAt(1.0, background_color_); - p.fillRect(rect(), surface); - if (!backdrop_null_warned_) { - backdrop_null_warned_ = true; - cf::log::warningftag("StatusBar", "backdrop image null; using flat fallback"); - } - } + QColor surface = background_color_; + surface.setAlphaF(kSurfaceAlpha); + p.fillRect(rect(), surface); // 2. Content layer fades in on boot. p.setOpacity(fade_opacity_); diff --git a/ui/components/statusbar/status_bar.h b/ui/components/statusbar/status_bar.h index dd8508f4f..51cd12f39 100644 --- a/ui/components/statusbar/status_bar.h +++ b/ui/components/statusbar/status_bar.h @@ -18,8 +18,6 @@ #include "IStatusBar.h" #include "aex/weak_ptr/weak_ptr.h" #include "aex/weak_ptr/weak_ptr_factory.h" -#include "components/IShellLayer.h" -#include "components/frosted_backdrop/frosted_backdrop.h" #include #include @@ -211,17 +209,6 @@ class StatusBar final : public QWidget, public IStatusBar { */ aex::WeakPtr GetWeak() const { return weak_factory_.GetWeakPtr(); } - /** - * @brief Sets the backdrop source for the frosted-glass surface. - * - * @param[in] source Shell layer supplying the wallpaper image (non-owning). - * - * @throws None - * @note @p source must outlive this status bar. - * @since 0.20 - */ - void setBackdropSource(cf::desktop::IShellLayer* source); - protected: /** * @brief Paints the background, clock, and icon glyphs. @@ -236,16 +223,6 @@ class StatusBar final : public QWidget, public IStatusBar { */ void paintEvent(QPaintEvent* event) override; - /** - * @brief Coalesces geometry changes into a debounced backdrop reblur. - * - * @param[in] event The resize event descriptor. - * - * @throws None - * @since 0.20 - */ - void resizeEvent(QResizeEvent* event) override; - private slots: /// @brief Refreshes the clock text each second. void onTimeout(); @@ -281,7 +258,6 @@ class StatusBar final : public QWidget, public IStatusBar { // Resolved theme values (refreshed by applyTheme()). QColor background_color_; - QColor surface_top_color_; ///< HCT tone-lifted surface, for the top of the gradient. QColor foreground_color_; QColor icon_color_; QColor divider_color_; @@ -294,17 +270,6 @@ class StatusBar final : public QWidget, public IStatusBar { /// back to vector drawing in paintEvent. QPixmap icon_masks_[4]; - /// Shell layer backing the frosted surface (non-owning; may be null). - cf::desktop::IShellLayer* backdrop_source_{nullptr}; - /// Frosted-glass renderer with its own cache (one instance per bar). - cf::desktop::FrostedBackdrop frosted_; - /// Resolved frosted parameters; the tint tracks the active theme surface. - cf::desktop::FrostedParams frosted_params_{}; - /// Coalesces rapid resizes into a single backdrop reblur. Ownership: this. - QTimer* reblur_debounce_{nullptr}; - /// Guards the null-backdrop warning so it fires once per transition. - bool backdrop_null_warned_{false}; - /// Weak pointer factory (must be the last member). mutable aex::WeakPtrFactory weak_factory_{this}; }; diff --git a/ui/components/taskbar/centered_taskbar.cpp b/ui/components/taskbar/centered_taskbar.cpp index 960c29286..5adcc4aa4 100644 --- a/ui/components/taskbar/centered_taskbar.cpp +++ b/ui/components/taskbar/centered_taskbar.cpp @@ -19,18 +19,14 @@ #include "start_button.h" #include "taskbar_icon.h" -#include "cflog.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" -#include #include #include #include #include #include -#include -#include // Q_INIT_RESOURCE must run at global scope: the rcc-generated registration // function lives in the global namespace, but the macro's extern declaration is @@ -49,18 +45,18 @@ using cf::desktop::PanelPosition; using namespace qw::core::token::literals; namespace { -constexpr int kTaskbarHeight = 64; ///< Bar thickness (px). -constexpr int kSideMargin = 12; ///< Horizontal padding (px). -constexpr int kTopBottomMargin = 4; ///< Vertical padding (px). -constexpr int kIconSpacing = 8; ///< Gap between tiles (px). -constexpr int kStartButtonGap = 16; ///< Gap after the start button (px). -constexpr qreal kSurfaceAlpha = 0.92; ///< Flat-fallback surface opacity (null backdrop). -constexpr qreal kFrostTintAlpha = 0.60; ///< Frosted-glass tint opacity (wallpaper bleed). +constexpr int kTaskbarHeight = 64; ///< Bar thickness (px). +constexpr int kSideMargin = 12; ///< Horizontal padding (px). +constexpr int kTopBottomMargin = 4; ///< Vertical padding (px). +constexpr int kIconSpacing = 8; ///< Gap between tiles (px). +constexpr int kStartButtonGap = 16; ///< Gap after the start button (px). +constexpr qreal kSurfaceAlpha = 0.82; ///< Fixed surface transparency over the wallpaper. } // namespace CenteredTaskbar::CenteredTaskbar(QWidget* parent) : QWidget(parent) { registerTaskbarIconsResource(); - setAttribute(Qt::WA_OpaquePaintEvent); + // No WA_OpaquePaintEvent: the bar paints a fixed-alpha surface so the + // wallpaper composites through it. Declaring it opaque would suppress that. setAutoFillBackground(false); setFixedHeight(kTaskbarHeight); setupUi(); @@ -88,23 +84,6 @@ void CenteredTaskbar::setupUi() { layout_->addLayout(icon_layout_); layout_->addStretch(); - // Coalesce rapid resizes (e.g. a window drag) into a single frosted-backdrop - // reblur instead of rebuilding on every intermediate geometry. - reblur_debounce_ = new QTimer(this); - reblur_debounce_->setSingleShot(true); - reblur_debounce_->setInterval(80); - connect(reblur_debounce_, &QTimer::timeout, this, [this]() { - frosted_.invalidate(); - update(); - }); - - // A screen change (different monitor / device-pixel-ratio) invalidates the - // backdrop cache; devicePixelRatioF() is also part of the cache key. - connect(qApp, &QGuiApplication::primaryScreenChanged, this, [this]() { - frosted_.invalidate(); - update(); - }); - // React to theme switches (ThemeManager is the canonical source). connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, [this](const qw::core::ICFTheme&) { applyTheme(); }); @@ -122,10 +101,6 @@ void CenteredTaskbar::applyTheme() { background_color_ = QColor(0xF7, 0xF5, 0xF3); divider_color_ = QColor(0xCA, 0xC4, 0xD0); } - frosted_params_.tint = background_color_; - frosted_params_.tint_alpha = kFrostTintAlpha; - frosted_params_.top_highlight = false; - frosted_.invalidate(); update(); } @@ -174,46 +149,17 @@ void CenteredTaskbar::updateRunningState(const QString& app_id, bool running) { } } -// -- Backdrop -------------------------------------------------------------- -void CenteredTaskbar::setBackdropSource(cf::desktop::IShellLayer* source) { - backdrop_source_ = source; - frosted_.invalidate(); - update(); -} - -void CenteredTaskbar::resizeEvent(QResizeEvent* event) { - QWidget::resizeEvent(event); - // Debounce: collapse a burst of resizes (e.g. a window drag) into one - // frosted-backdrop reblur rather than rebuilding on every intermediate size. - if (reblur_debounce_ != nullptr) { - reblur_debounce_->start(); - } -} - // -- Painting -------------------------------------------------------------- void CenteredTaskbar::paintEvent(QPaintEvent* /*event*/) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); - // Frosted-glass backdrop: blurred wallpaper strip + surface tint + acrylic - // grain. tint_alpha (~0.6) is deliberately lower than the old flat 0.92 so - // the blur reads through; a fully opaque tint would hide the effect. - const QImage backdrop = - backdrop_source_ != nullptr ? backdrop_source_->currentBackgroundImage() : QImage{}; - const QRect strip = geometry(); - if (!backdrop.isNull() && !strip.isEmpty()) { - p.drawPixmap(0, 0, frosted_.render(backdrop, strip, devicePixelRatioF(), frosted_params_)); - backdrop_null_warned_ = false; - } else { - // Flat fallback (e.g. before the wallpaper loads). Fail loud, once. - QColor bg = background_color_; - bg.setAlphaF(kSurfaceAlpha); - p.fillRect(rect(), bg); - if (!backdrop_null_warned_) { - backdrop_null_warned_ = true; - cf::log::warningftag("CenteredTaskbar", "backdrop image null; using flat fallback"); - } - } + // Fixed-transparency surface: the wallpaper composites through it without a + // per-wallpaper blur, so there is no cached blur to invalidate on rotation + // (no stale frame, no transition snap) and zero blur cost on every repaint. + QColor surface = background_color_; + surface.setAlphaF(kSurfaceAlpha); + p.fillRect(rect(), surface); // Soft top elevation shadow: the bar reads as floating above the shell. QLinearGradient shadow(0, 0, 0, height() * 0.5); diff --git a/ui/components/taskbar/centered_taskbar.h b/ui/components/taskbar/centered_taskbar.h index 9e4bdc047..dfb3e2aed 100644 --- a/ui/components/taskbar/centered_taskbar.h +++ b/ui/components/taskbar/centered_taskbar.h @@ -20,15 +20,12 @@ #include "aex/weak_ptr/weak_ptr_factory.h" #include "app_entry.h" #include "components/IPanel.h" -#include "components/IShellLayer.h" -#include "components/frosted_backdrop/frosted_backdrop.h" #include #include #include class QHBoxLayout; -class QTimer; namespace cf::desktop::desktop_component { @@ -165,17 +162,6 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { */ aex::WeakPtr GetWeak() const { return weak_factory_.GetWeakPtr(); } - /** - * @brief Sets the backdrop source for the frosted-glass surface. - * - * @param[in] source Shell layer supplying the wallpaper image (non-owning). - * - * @throws None - * @note @p source must outlive this taskbar. - * @since 0.20 - */ - void setBackdropSource(cf::desktop::IShellLayer* source); - signals: /** * @brief Emitted when a tile is clicked. @@ -197,7 +183,7 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { protected: /** - * @brief Paints the translucent surface and top divider. + * @brief Paints the fixed-transparency surface and top divider. * * @param[in] event The paint event descriptor. * @@ -209,16 +195,6 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { */ void paintEvent(QPaintEvent* event) override; - /** - * @brief Coalesces geometry changes into a debounced backdrop reblur. - * - * @param[in] event The resize event descriptor. - * - * @throws None - * @since 0.20 - */ - void resizeEvent(QResizeEvent* event) override; - private: /// @brief Creates the centered row layout. void setupUi(); @@ -233,17 +209,6 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { QColor background_color_; ///< Surface fill for the bar. QColor divider_color_; ///< Top hairline divider color. - /// Shell layer backing the frosted surface (non-owning; may be null). - cf::desktop::IShellLayer* backdrop_source_{nullptr}; - /// Frosted-glass renderer with its own cache (one instance per bar). - cf::desktop::FrostedBackdrop frosted_; - /// Resolved frosted parameters; the tint tracks the active theme surface. - cf::desktop::FrostedParams frosted_params_{}; - /// Coalesces rapid resizes into a single backdrop reblur. Ownership: this. - QTimer* reblur_debounce_{nullptr}; - /// Guards the null-backdrop warning so it fires once per transition. - bool backdrop_null_warned_{false}; - /// Weak pointer factory (must be the last member). mutable aex::WeakPtrFactory weak_factory_{this}; };