Skip to content

Commit 1cb3799

Browse files
committed
feat(capture): add window capture functionality and options
1 parent 9780e83 commit 1cb3799

18 files changed

Lines changed: 416 additions & 46 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838

3939
- name: Install Linux system dependencies
4040
if: runner.os == 'Linux'
41-
run: sudo apt-get update && sudo apt-get install -y ninja-build libgl1-mesa-dev
41+
run: sudo apt-get update && sudo apt-get install -y ninja-build libgl1-mesa-dev libx11-dev
4242

4343
- name: Set up MSVC
4444
if: runner.os == 'Windows'

CMakeLists.txt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ endif()
2121

2222
qt_standard_project_setup(REQUIRES 6.8)
2323

24+
add_library(clipit_capture_platform INTERFACE)
25+
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
26+
find_package(X11 QUIET)
27+
if(TARGET X11::X11)
28+
target_link_libraries(clipit_capture_platform INTERFACE X11::X11)
29+
target_compile_definitions(clipit_capture_platform INTERFACE CLIPIT_HAS_X11)
30+
endif()
31+
elseif(WIN32)
32+
target_link_libraries(clipit_capture_platform INTERFACE user32)
33+
elseif(APPLE)
34+
find_library(APPLICATION_SERVICES_FRAMEWORK ApplicationServices REQUIRED)
35+
target_link_libraries(clipit_capture_platform
36+
INTERFACE ${APPLICATION_SERVICES_FRAMEWORK}
37+
)
38+
endif()
39+
2440
qt_add_library(clipit_core STATIC
2541
src/core/annotation.cpp
2642
src/core/annotation.h
@@ -79,7 +95,11 @@ target_include_directories(appClipit PRIVATE
7995
${CMAKE_CURRENT_SOURCE_DIR}
8096
${CMAKE_CURRENT_SOURCE_DIR}/src/app
8197
)
82-
target_link_libraries(appClipit PRIVATE clipit_core Qt6::Quick)
98+
target_link_libraries(appClipit PRIVATE
99+
clipit_core
100+
clipit_capture_platform
101+
Qt6::Quick
102+
)
83103
clipit_enable_warnings(appClipit)
84104
target_compile_definitions(appClipit PRIVATE CLIPIT_VERSION="${PROJECT_VERSION}")
85105
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
@@ -106,7 +126,7 @@ if(BUILD_TESTING)
106126
$<TARGET_FILE:appClipit> --help
107127
)
108128
set_tests_properties(cli_help_without_display PROPERTIES
109-
PASS_REGULAR_EXPRESSION "--region"
129+
PASS_REGULAR_EXPRESSION "--window"
110130
)
111131
add_test(
112132
NAME cli_version_without_display
@@ -158,7 +178,7 @@ if(BUILD_TESTING)
158178
${CMAKE_CURRENT_SOURCE_DIR}/src/app
159179
)
160180
target_link_libraries(tst_screenshotservice
161-
PRIVATE clipit_core Qt6::Quick Qt6::Test
181+
PRIVATE clipit_core clipit_capture_platform Qt6::Quick Qt6::Test
162182
)
163183
add_test(NAME screenshotservice COMMAND tst_screenshotservice)
164184
set_tests_properties(screenshotservice PROPERTIES

Main.qml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,13 +344,14 @@ ApplicationWindow {
344344

345345
Repeater {
346346
model: [
347+
{ icon: "window", title: qsTr("窗口截图"), note: window.screenshotService.wayland ? qsTr("由系统选择窗口") : qsTr("当前活动窗口"), action: "window" },
347348
{ icon: "screen", title: qsTr("全屏截图"), note: qsTr("立即捕获"), action: "screen" },
348349
{ icon: "clock", title: qsTr("延时截图"), note: qsTr("3 / 5 / 10 秒"), action: "delay" }
349350
]
350351
delegate: Rectangle {
351352
id: actionCard
352353
required property var modelData
353-
width: (parent.width - 12) / 2
354+
width: (parent.width - 24) / 3
354355
height: parent.height
355356
radius: 13
356357
color: secondaryMouse.containsMouse ? (window.darkMode ? "#292D36" : "#F9FAFF") : window.panel
@@ -382,7 +383,14 @@ ApplicationWindow {
382383
hoverEnabled: true
383384
cursorShape: Qt.PointingHandCursor
384385
enabled: !window.screenshotService.busy
385-
onClicked: actionCard.modelData.action === "screen" ? window.screenshotService.captureFullscreen(0) : delayPopup.open()
386+
onClicked: {
387+
if (actionCard.modelData.action === "window")
388+
window.screenshotService.captureWindow(0)
389+
else if (actionCard.modelData.action === "screen")
390+
window.screenshotService.captureFullscreen(0)
391+
else
392+
delayPopup.open()
393+
}
386394
}
387395
}
388396
}

README.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
一个以 **Linux Wayland** 为主要使用场景,同时支持 Linux X11、Windows 和 macOS 的轻量截图与标注工具。
44

5-
Clipit 通过标准的 [XDG Desktop Portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) 获取屏幕画面,再在应用内完成区域选择、标注、保存和复制。它不依赖 X11 抓屏,也不绑定 GNOME Shell 私有接口。
5+
Clipit 在 Wayland 下通过标准的 [XDG Desktop Portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) 获取画面,再在应用内完成区域选择、标注、保存和复制。Wayland 路径不依赖 X11 抓屏,也不绑定 GNOME Shell 私有接口。
66

77
![Clipit 程序截图](https://img.cdn1.vip/i/6a52698f6b18b_1783785871.webp)
88

@@ -21,7 +21,7 @@ Clipit 要解决的是这段缺失的工作流:**在遵守 Wayland 安全模
2121

2222
## 功能
2323

24-
- 区域截图、全屏截图和延时截图
24+
- 区域截图、窗口截图、全屏截图和延时截图
2525
- 矩形、箭头、自由画笔和单行文字
2626
- 模糊与马赛克隐私处理
2727
- 预设色板与屏幕像素取色
@@ -39,19 +39,20 @@ Clipit 要解决的是这段缺失的工作流:**在遵守 Wayland 安全模
3939
| 平台 | 截图方式 | 当前状态 |
4040
| --- | --- | --- |
4141
| GNOME / KDE Wayland | XDG Desktop Portal | 主要支持目标 |
42-
| Linux X11 | Qt `QScreen::grabWindow()` | 可用 |
43-
| Windows | Qt `QScreen::grabWindow()` | 基础支持 |
44-
| macOS | Qt `QScreen::grabWindow()` | 基础支持,需要录屏权限 |
42+
| Linux X11 | X11 活动窗口查询 + Qt `QScreen::grabWindow()` | 可用 |
43+
| Windows | Win32 活动窗口查询 + Qt `QScreen::grabWindow()` | 基础支持 |
44+
| macOS | Qt + Core Graphics | 基础支持,需要录屏权限 |
4545

46-
Wayland 下,portal 负责向 Clipit 交付一帧完整屏幕画面,区域选择和标注由 Clipit 自己完成。首次使用或受沙箱策略影响时,系统仍可能显示授权界面;这是 Wayland 安全模型的一部分,应用无法绕过。
46+
Wayland 下,portal 负责向 Clipit 交付截图。区域截图仍先获取完整屏幕,再由 Clipit 完成框选和标注。窗口截图优先请求 portal v3 的 `Active Window` 目标;如果系统只提供窗口选择或旧版交互接口,会显示系统选择界面。首次使用或受沙箱策略影响时,系统也可能显示授权界面;这是 Wayland 安全模型的一部分,应用无法绕过。
4747

4848
### 跨平台设计
4949

5050
Clipit 使用 C++、Qt 6 和 QML 开发。主界面、区域选择、标注、PNG 保存与剪贴板等核心逻辑由各平台共用,平台差异集中在“如何安全地取得屏幕画面”这一层:
5151

52-
- **Linux Wayland**:通过标准 XDG Desktop Portal 请求屏幕画面,兼容采用 portal 的 GNOME、KDE 等桌面环境;
53-
- **Linux X11、Windows 和 macOS**:通过 Qt 的跨平台屏幕接口获取主屏幕画面;
54-
- **macOS**:沿用系统录屏权限机制,用户需要在首次使用时授权。
52+
- **Linux Wayland**:通过标准 XDG Desktop Portal 请求屏幕或窗口画面,兼容采用 portal 的 GNOME、KDE 等桌面环境;
53+
- **Linux X11**:通过 X11 查询活动窗口,再由 Qt 获取窗口画面;
54+
- **Windows**:通过 Win32 查询活动窗口,再由 Qt 获取窗口画面;
55+
- **macOS**:通过 Core Graphics 查询并获取最前方窗口画面,沿用系统录屏权限机制,用户需要在首次使用时授权。
5556

5657
这种方式不要求为每个平台重新实现整套编辑器,也避免把 Wayland 支持绑定到某个桌面环境的私有接口。相同的截图一旦交给 Clipit,后续框选、标注、保存和复制行为保持一致。
5758

@@ -62,6 +63,7 @@ Clipit 使用 C++、Qt 6 和 QML 开发。主界面、区域选择、标注、PN
6263
- Qt 6.8 或更高版本
6364
- Qt Quick
6465
- Linux:Qt DBus、`xdg-desktop-portal` 和当前桌面对应的 portal 后端
66+
- Linux X11 窗口截图:构建时需要 X11 开发库;未找到时仍可构建,但不包含 X11 窗口截图
6567
- Wayland 命令行模式:`wl-clipboard`,用于在 Clipit 退出后继续保留剪贴板内容
6668

6769
GNOME 通常使用 `xdg-desktop-portal-gnome`,KDE Plasma 通常使用 `xdg-desktop-portal-kde`
@@ -100,19 +102,25 @@ ctest --test-dir build/release --output-on-failure
100102
# 区域截图
101103
./build/release/appClipit --region
102104

105+
# 窗口截图
106+
./build/release/appClipit --window
107+
103108
# 全屏截图
104109
./build/release/appClipit --fullscreen
105110

106111
# 延时 3 秒后全屏截图
107112
./build/release/appClipit --fullscreen --delay 3
108113
```
109114

110-
短参数分别为 `-r``-f``-d``--region``--fullscreen` 互斥,延时范围为 0–3600 秒。
115+
短参数分别为 `-r``-w``-f``-d``--region``--window``--fullscreen` 互斥,三种截图模式都可以与 `--delay` 组合,延时范围为 0–3600 秒。
116+
117+
`--window` 在 X11、Windows 和 macOS 下截取当前活动窗口。Wayland 下优先截取活动窗口;portal 不支持该目标时会打开系统界面,由用户选择窗口。
111118

112119
Clipit 暂不自行注册全局快捷键。可以在 GNOME 的“设置 → 键盘 → 查看及自定义快捷键 → 自定义快捷键”中绑定以下命令,路径需要替换为可执行文件的实际绝对路径:
113120

114121
```text
115122
/home/user/apps/Clipit/appClipit --region
123+
/home/user/apps/Clipit/appClipit --window
116124
/home/user/apps/Clipit/appClipit --fullscreen
117125
```
118126

@@ -130,21 +138,21 @@ Clipit 暂不自行注册全局快捷键。可以在 GNOME 的“设置 → 键
130138

131139
Clipit 针对不同环境选择不同的抓屏后端,但复用同一套区域选择和标注流程:
132140

133-
1. `PortalScreenCaptureBackend` 在 Wayland 下通过 `org.freedesktop.portal.Screenshot` 请求屏幕画面`QtScreenCaptureBackend` 在其他平台通过 Qt 获取画面
141+
1. `PortalScreenCaptureBackend` 在 Wayland 下通过 `org.freedesktop.portal.Screenshot` 请求屏幕或窗口画面`QtScreenCaptureBackend` 在其他平台查询活动窗口并通过平台接口获取画面
134142
2. `CaptureOverlay.qml` 在静态画面上完成区域选择与标注。
135143
3. `ScreenshotService` 管理截图状态,`AnnotationRenderer` 合成最终图片,`ScreenshotStorage` 以原子方式保存 PNG。
136144

137-
程序有三种启动模式:完整 GUI、仅区域截图和仅全屏截图。全屏命令模式不会加载 QML 界面,适合快捷键或脚本调用。
145+
程序有四种启动模式:完整 GUI、仅区域截图、仅窗口截图和仅全屏截图。窗口与全屏命令模式不会加载 QML 界面,适合快捷键或脚本调用。
138146

139147
项目将可独立测试的图片逻辑放在 `src/core/`,将 portal、Qt 抓屏和剪贴板实现放在 `src/platform/`。详细模块边界和扩展方式见 [架构说明](docs/architecture.md)。提交代码前请阅读 [贡献指南](CONTRIBUTING.md)
140148

141149
## 当前限制
142150

143-
- 非 Wayland 平台目前只捕获主屏幕,尚不支持多显示器联合框选。
151+
- 非 Wayland 平台的全屏与区域模式目前只捕获主屏幕,尚不支持多显示器联合框选。
144152
- 尚未接入 Wayland `GlobalShortcuts` portal,需要通过桌面设置绑定快捷键。
145153
- 文字标注目前只支持单行输入。
146154
- 模糊工具的编辑预览是视觉近似,最终 PNG 会执行实际模糊处理。
147-
- 尚无窗口自动识别、OCR、托盘和持久化截图历史。
155+
- 尚无 OCR、托盘和持久化截图历史。
148156
- macOS 首次截图需要在系统设置中授予录屏权限。
149157

150158
## 参与开发

components/Glyph.qml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ Item {
4242
} else if (root.name === "screen") {
4343
c.strokeRect(3, 4, 18, 13)
4444
c.beginPath(); c.moveTo(9, 21); c.lineTo(15, 21); c.moveTo(12, 17); c.lineTo(12, 21); c.stroke()
45+
} else if (root.name === "window") {
46+
c.strokeRect(3, 4, 18, 16)
47+
c.beginPath(); c.moveTo(3, 8); c.lineTo(21, 8); c.stroke()
48+
c.beginPath(); c.arc(6, 6, 0.7, 0, Math.PI * 2); c.fill()
49+
c.beginPath(); c.arc(9, 6, 0.7, 0, Math.PI * 2); c.fill()
4550
} else if (root.name === "clock") {
4651
c.beginPath(); c.arc(12, 12, 8.5, 0, Math.PI * 2); c.stroke()
4752
c.beginPath(); c.moveTo(12, 7); c.lineTo(12, 12); c.lineTo(15.5, 14); c.stroke()

docs/architecture.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## 目标
44

5-
Clipit 的核心流程是获取屏幕画面、选择区域、应用标注、保存图片和写入剪贴板。平台差异主要存在于屏幕画面的获取方式,因此架构只对抓屏后端和可独立测试的图片处理建立边界。
5+
Clipit 的核心流程是获取屏幕或窗口画面、选择区域、应用标注、保存图片和写入剪贴板。平台差异主要存在于画面的获取方式,因此架构只对抓屏后端和可独立测试的图片处理建立边界。
66

77
## 模块关系
88

@@ -23,7 +23,7 @@ ScreenshotService(流程与状态)
2323

2424
`ScreenshotService` 是 QML 与 C++ 的边界,负责:
2525

26-
- 接收区域、全屏、延时和取消命令;
26+
- 接收区域、窗口、全屏、延时和取消命令;
2727
- 驱动抓屏后端;
2828
- 保存待编辑图片并通知 QML 打开选区窗口;
2929
- 将标注数据交给解析器和渲染器;
@@ -37,19 +37,19 @@ ScreenshotService(流程与状态)
3737
```text
3838
Idle → Delaying → Capturing → Selecting → Saving → Idle
3939
│ │
40-
└──── Fullscreen ─────────
40+
└──── Window / Fullscreen ┘
4141
```
4242

4343
取消或失败会清理临时选区图片并回到 `Idle`
4444

4545
### 抓屏后端
4646

47-
`ScreenCaptureBackend` 只负责产生一张完整的原始屏幕图片
47+
`ScreenCaptureBackend` 接收 `Screen``ActiveWindow` 目标,只负责产生对应的原始图片
4848

49-
- `PortalScreenCaptureBackend` 实现 XDG Desktop Portal 的异步请求、取消、超时和服务可用性监听;
50-
- `QtScreenCaptureBackend` 使用 `QScreen::grabWindow()`用于 X11、Windows 和 macOS
49+
- `PortalScreenCaptureBackend` 实现 XDG Desktop Portal 的异步请求、目标能力判断、取消、超时和服务可用性监听;
50+
- `QtScreenCaptureBackend` 在 X11 和 Windows 下查询活动窗口后调用 `QScreen::grabWindow()`在 macOS 下使用 Core Graphics
5151

52-
区域选择不是抓屏后端的职责。所有后端返回原始画面后,都复用同一个选区和标注流程
52+
区域选择不是抓屏后端的职责。区域模式要求后端返回完整屏幕,再进入统一的选区和标注流程;窗口与全屏模式直接保存后端图片
5353

5454
### 标注模型与渲染
5555

@@ -75,7 +75,7 @@ QML Canvas 只提供编辑预览,最终 PNG 以 `AnnotationRenderer` 的结果
7575

7676
新增抓屏方式时:
7777

78-
1. 实现 `ScreenCaptureBackend``available()``capture()``cancel()`
78+
1. 实现 `ScreenCaptureBackend``available()``capture(CaptureTarget)``cancel()`
7979
2. 正确发出 `captured``canceled``failed` 终态信号。
8080
3. 在组合入口中按平台选择后端。
8181
4. 使用假后端测试控制器状态,不要求 CI 连接真实桌面服务。

src/app/applicationoptions.cpp

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ void configureParser(QCommandLineParser &parser)
1515
parser.addOption(QCommandLineOption(
1616
{QStringLiteral("r"), QStringLiteral("region")},
1717
QStringLiteral("直接开始区域截图,只显示框选层。")));
18+
parser.addOption(QCommandLineOption(
19+
{QStringLiteral("w"), QStringLiteral("window")},
20+
QStringLiteral("开始窗口截图,不启动 Clipit 主窗口。")));
1821
parser.addOption(QCommandLineOption(
1922
{QStringLiteral("f"), QStringLiteral("fullscreen")},
2023
QStringLiteral("直接进行全屏截图,不启动 Clipit 主窗口。")));
@@ -54,20 +57,26 @@ ApplicationOptionsParseResult parseApplicationOptions(const QStringList &argumen
5457
}
5558

5659
const bool region = parser.isSet(QStringLiteral("region"));
60+
const bool window = parser.isSet(QStringLiteral("window"));
5761
const bool fullscreen = parser.isSet(QStringLiteral("fullscreen"));
58-
if (region && fullscreen) {
59-
result.error = QStringLiteral("--region 与 --fullscreen 不能同时使用");
62+
const int captureModeCount = static_cast<int>(region) + static_cast<int>(window)
63+
+ static_cast<int>(fullscreen);
64+
if (captureModeCount > 1) {
65+
result.error = QStringLiteral("--region、--window 与 --fullscreen 不能同时使用");
6066
return result;
6167
}
6268

6369
if (region)
6470
result.options.mode = LaunchMode::RegionCapture;
71+
else if (window)
72+
result.options.mode = LaunchMode::WindowCapture;
6573
else if (fullscreen)
6674
result.options.mode = LaunchMode::FullscreenCapture;
6775

6876
if (parser.isSet(QStringLiteral("delay"))) {
69-
if (!region && !fullscreen) {
70-
result.error = QStringLiteral("--delay 只能与 --region 或 --fullscreen 一起使用");
77+
if (captureModeCount == 0) {
78+
result.error = QStringLiteral(
79+
"--delay 只能与 --region、--window 或 --fullscreen 一起使用");
7180
return result;
7281
}
7382

src/app/applicationoptions.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
enum class LaunchMode {
99
Gui,
1010
RegionCapture,
11+
WindowCapture,
1112
FullscreenCapture,
1213
Help,
1314
Version,

src/app/main.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ int runCaptureCommand(QGuiApplication &app, const ApplicationOptions &options)
9090
QTimer::singleShot(0, &screenshotService, [&screenshotService, options]() {
9191
if (options.mode == LaunchMode::RegionCapture)
9292
screenshotService.captureRegion(options.delaySeconds);
93+
else if (options.mode == LaunchMode::WindowCapture)
94+
screenshotService.captureWindow(options.delaySeconds);
9395
else
9496
screenshotService.captureFullscreen(options.delaySeconds);
9597
});
@@ -147,13 +149,15 @@ int main(int argc, char *argv[])
147149
<< QCoreApplication::applicationVersion() << Qt::endl;
148150
return 0;
149151
case LaunchMode::RegionCapture:
152+
case LaunchMode::WindowCapture:
150153
case LaunchMode::FullscreenCapture:
151154
case LaunchMode::Gui:
152155
break;
153156
}
154157

155158
switch (parsed.options.mode) {
156159
case LaunchMode::RegionCapture:
160+
case LaunchMode::WindowCapture:
157161
case LaunchMode::FullscreenCapture:
158162
return runCaptureCommand(app, parsed.options);
159163
case LaunchMode::Gui:

src/app/screenshotservice.cpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ void ScreenshotService::captureRegion(int delaySeconds)
111111
startCapture(Mode::Region, delaySeconds);
112112
}
113113

114+
void ScreenshotService::captureWindow(int delaySeconds)
115+
{
116+
startCapture(Mode::Window, delaySeconds);
117+
}
118+
114119
void ScreenshotService::captureFullscreen(int delaySeconds)
115120
{
116121
startCapture(Mode::Fullscreen, delaySeconds);
@@ -143,7 +148,9 @@ void ScreenshotService::performCapture()
143148
if (m_state != CaptureState::Delaying && m_state != CaptureState::Capturing)
144149
return;
145150
setState(CaptureState::Capturing);
146-
m_backend->capture();
151+
const auto target = m_mode == Mode::Window ? Clipit::CaptureTarget::ActiveWindow
152+
: Clipit::CaptureTarget::Screen;
153+
m_backend->capture(target);
147154
}
148155

149156
void ScreenshotService::handleCapturedImage(const QImage &image)

0 commit comments

Comments
 (0)