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)* 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/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/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 6f493bb19..fea3ff2ef 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" @@ -269,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(); @@ -285,18 +285,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 +313,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); } }); } @@ -344,14 +351,13 @@ 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 // 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) { @@ -375,6 +381,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()) { 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/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. 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}; }; 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. 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 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. *