Compare commits

...
5 Commits
45 changed files with 2167 additions and 1147 deletions
+59 -13
View File
@@ -1,7 +1,7 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
<h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</p>
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
</div>
@@ -15,11 +15,11 @@
</div>
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。桌面端新增 **桌面墙(DeskHub)**,可在独立窗口中同时查看多台远程桌面,按分组管理设备,并进行批量操作与群控。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [桌面墙](#deskhub) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
<a id="preview"></a>
@@ -27,10 +27,18 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
### 桌面客户端
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。
主窗口以“本桌面”和“远程桌面”两张卡片展示本机凭据与连接入口,下方是可按分组筛选的近期连接。左上角打开桌面墙,右上角菜单进入设置、消息与关于。
<p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" />
<img src="docs/images/desktop-main-zh.png" width="800" alt="新版主窗口:桌面墙入口、本机凭据、远程连接卡片和近期连接分组筛选;数据为演示" />
</p>
### 桌面墙(DeskHub)
在独立窗口中同时查看多台设备,支持分组、可调整大小的网格、精简模式、全屏与批量操作。点击画面可放大查看,再按需接管操控。
<p align="center">
<img src="docs/images/desktop-deskhub-zh.png" width="960" alt="桌面墙网格:六台演示设备、分组筛选、添加、选择、精简模式和网络状态入口" />
</p>
### Web 客户端
@@ -38,7 +46,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" />
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端连接首页(2026-09-25 截图)" />
</p>
图片来源与更新方式见 [截图说明](docs/images/README.md)。
@@ -49,7 +57,8 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
| --- | --- |
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
| 多设备与多显示器 | 近期连接、设备别名与分组筛选、会话标签与远端显示器切换 |
| 桌面墙(DeskHub) | 多桌面实时预览、分组与拖动排序、放大接管、群控、批量连接 / 断开 / 静音 / 文件分发;可配置分辨率、帧率与视频总带宽 |
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
| 网络与部署 | P2P 直连、TURN 中继与强制中继、SRTP 媒体加密、自托管信令与中继服务 |
| 隐私屏 | 被连接时自动开启,或在会话工具栏切换;可用性取决于被控端系统与采集方式 |
@@ -103,9 +112,9 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
### 从另一台电脑连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 **9 位 ID**,点击 **连接**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连。悬停卡片可编辑别名、修改分组、删除记录或连接 / 断开;标题右侧可按分组筛选设备。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
@@ -117,6 +126,27 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
<a id="deskhub"></a>
## 桌面墙:集中查看与操控多台设备
点击主窗口左上角 **桌面墙**,打开独立窗口;连接窗口仍可用于普通远程连接和全局设置。桌面墙会保存设备成员、分组、排序与窗口布局,重启后需要手动连接设备。
1. **添加设备。** 点击 **添加 → 通过设备 ID 添加**,填写 ID、密码及可选分组,点击“添加并连接”;只有勾选“记住密码”才保存凭据。也可选择 **从近期连接添加**,多选已有记录并导入;导入后用“选择 → 连接”发起连接。
2. **查看与整理。** 从“全部设备”切换到指定分组;拖动网格分隔线统一调整画面大小,拖动设备到另一台设备上交换位置。精简模式隐藏名称和管理工具栏,全屏用于扩大查看区域;翻页保留连接。
3. **放大与操控。** 点击设备画面进入只读放大视图,上方缩略图可切换设备,拖动中间分隔条可调整上下区域。点击 **接管操控** 后才发送键鼠;点击“释放操控”或按 **Ctrl / ⌘ + Shift + Esc** 释放。切换设备或窗口失焦也会释放操控。
4. **批量操作。** 点击 **选择**,勾选设备或“全选”,执行连接、断开,或从“更多”中静音 / 取消静音、分发文件。“全选”覆盖当前分组的全部分页;切换分组会清空选择。
<p align="center">
<img src="docs/images/desktop-deskhub-focus-zh.png" width="960" alt="桌面墙放大视图:上方设备缩略图、下方主画面,以及只读预览、开启群控和接管操控入口;画面为演示" />
</p>
**群控:** 在放大视图点击“开启群控”,将当前设备的键鼠操作同步到 **所有已连接设备**,包含其他分组、分页及未显示在墙内的连接;范围不受批量勾选限制。各设备接收相同坐标与按键,屏幕尺寸不同时不会另做比例适配。释放操控会同时结束群控。
桌面墙连接默认静音,需要声音时通过“选择 → 更多 → 取消静音”开启。底部 **网络状态** 可查看所有已连接设备的直连 / 中继、加密状态、收发速率、视频延时、丢包率、分辨率与帧率。需要显示器切换、隐私屏等完整单会话工具时,可在主窗口“近期连接”中点击已连接设备,打开独立会话窗口。
**关闭桌面墙会断开全部远程设备连接,并关闭共用连接的独立会话窗口;最小化不会断开。** 分组、分页和选择范围不会限制关闭时的断开范围。更多操作、预览策略与版本兼容性见 [桌面墙说明](docs/deskhub.md)。
<a id="session"></a>
## 会话中的常用操作
@@ -136,7 +166,7 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
| 全屏 | 进入 / 退出全屏 |
| 断开连接 | 结束当前远程会话 |
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
### 在会话中调整画面
@@ -150,10 +180,10 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
## 客户端设置与更新
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。
点击主窗口右上角 **☰ → 设置**,顶部切换 **常规 / 桌面墙**,修改后点击 **保存**。切换选项卡会保留未保存的修改,点击“取消”放弃修改。“常规”按界面、视频、网络连接等类别组织选项,可向下滚动查看其余设置。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。普通会话的画质、帧率和画面偏好在远程会话工具栏中调整。
<p align="center">
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" />
<img src="docs/images/desktop-settings-zh.png" width="720" alt="新版常规设置:常规与桌面墙选项卡、语言、视频编码、硬件编解码及网络连接设置" />
</p>
| 设置 | 说明 |
@@ -169,6 +199,22 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
### 桌面墙画面传输
在 **设置 → 桌面墙** 中统一配置墙内设备,保存后立即应用到当前桌面墙会话,并用于后续连接;普通连接不受影响。
| 设置 | 说明 |
| --- | --- |
| 分辨率上限 | 自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160 或原始分辨率;自动模式按网格 / 放大视图调整,固定上限保持比例且不放大源画面 |
| 帧率 | 跟随设备设置,或指定 15 / 30 / 60 FPS |
| 视频总带宽 | 默认 20 Mbps,可选 2 / 5 / 10 / 20 / 50 / 100 Mbps;由支持该能力的桌面墙会话共享,放大画面优先分配,不包含音频、文件传输与普通连接,也不是瞬时流量的硬上限 |
<p align="center">
<img src="docs/images/desktop-settings-deskhub-zh.png" width="720" alt="桌面墙传输设置:自动分辨率、跟随设备帧率和 20 Mbps 视频总带宽" />
</p>
支持预览订阅的设备在不可见时可暂停视频传输,重新可见时恢复;旧版被控端按其能力回退,不保证所有分辨率、帧率或带宽选项都生效。
### 检查更新
客户端启动后会在后台检查版本,发现新版本时自动弹出更新提示,可查看更新说明并前往下载页面。关闭提示后,本次运行中的后台检查不会再次弹窗,主窗口菜单入口仍会显示更新提示;重新启动客户端后,如仍有新版本则再次提示。打开 **☰ → 关于** 可查看版本;有新版本时,点击版本信息下方的 **更新日志** 可再次打开更新提示,也可点击 **检查更新** 手动检查。
@@ -256,7 +302,7 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
## 自托管服务器
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 常规 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
+59 -13
View File
@@ -1,7 +1,7 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
<h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</p>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
</div>
@@ -15,11 +15,11 @@
</div>
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. The desktop client now includes **DeskHub**, a separate window for viewing multiple remote desktops, organizing devices into groups, and performing batch actions or synchronized control. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
> This guide follows the current repository source. Desktop screenshots render the current Slint UI with sample IDs, passwords, online statuses, and recent devices; thumbnails show public pages. Check each release's notes for the features and requirements of its binaries. Native iOS build and signing instructions are below.
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [DeskHub](#deskhub) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
<a id="preview"></a>
@@ -27,10 +27,18 @@ CrossDesk connects Windows, macOS, and Linux desktops, with browser and native i
### Desktop client
The main window brings together your device ID, connection password, remote connection entry, and recent devices.
The main window presents your credentials and connection controls in Local Desktop and Remote Desktop cards, with recent devices and a group filter below. Open DeskHub at the top left, or use the top-right menu for settings, messages, and About.
<p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" />
<img src="docs/images/desktop-main-en.png" width="800" alt="Updated main window with DeskHub access, local credentials, remote connection controls, and recent-device group filtering; data is illustrative" />
</p>
### DeskHub
View multiple devices in a separate window with groups, a resizable grid, compact and fullscreen views, and batch actions. Click a preview to enlarge it, then take control when needed.
<p align="center">
<img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub grid with six demonstration devices, group filtering, Add, Select, Compact, and Network controls" />
</p>
### Web client
@@ -38,7 +46,7 @@ The main window brings together your device ID, connection password, remote conn
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" />
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web Client connection page in Chinese, captured on 2026-09-25" />
</p>
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
@@ -49,7 +57,8 @@ See the [screenshot notes](docs/images/README.md) for image sources and refresh
| --- | --- |
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS / Android controllers |
| Live video and audio | H.264 / AV1, hardware codecs, and remote audio; adjust quality, 30 / 60 fps, and video preference during a session |
| Devices and displays | Recent connections, device aliases, session tabs, and remote display switching |
| Devices and displays | Recent connections, device aliases and group filtering, session tabs, and remote display switching |
| DeskHub | Live multi-desktop previews, groups and drag reordering, enlarged views with control, synchronized input, and batch connection / disconnection / audio / file actions; configurable resolution, frame rate, and shared video budget |
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
| Networking and deployment | Direct P2P connections, TURN relay and forced relay, SRTP media encryption, and self-hosted signaling/relay services |
| Privacy screen | Enable automatically when connected to, or toggle from the session toolbar; availability depends on the host OS and capture method |
@@ -103,9 +112,9 @@ Both contain the same complete application, replace each other and preserve user
### Connect from another computer
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
2. **Enter the remote ID.** On the controlling computer, enter the host's **9-digit ID** in **Remote Desktop → Remote ID**, then click **Connect**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, or hover to rename it, edit its groups, remove its record, or connect / disconnect. Use the group filter beside the heading to narrow the list.
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
@@ -117,6 +126,27 @@ The eye button shows or hides the local password, the refresh button generates a
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
<a id="deskhub"></a>
## DeskHub: view and control multiple devices
Click **DeskHub** at the top left of the main window to open a separate window. The connection window remains available for ordinary remote sessions and global settings. DeskHub saves its device membership, groups, order, and window layout; connections must be started manually after relaunching.
1. **Add devices.** Choose **Add → Add by device ID**, enter an ID, password, and optional group, then click **Add and connect**. Credentials are saved only when you select **Remember password**. Alternatively, choose **Add from recent** and import multiple records; use **Select → Connect** to connect after importing.
2. **Arrange the view.** Switch from **All devices** to a group, drag grid dividers to resize all previews, or drag a device onto another to swap positions. **Compact** hides device names and management controls; fullscreen provides more viewing space. Changing pages keeps connections open.
3. **Enlarge and control.** Click a preview to open a read-only enlarged view. Switch devices with the thumbnails above it, and drag the divider to resize the two areas. Click **Control** to send keyboard and mouse input. Click **Release** or press **Ctrl / ⌘ + Shift + Esc** to release; switching devices or losing window focus also releases control.
4. **Apply batch actions.** Click **Select**, choose devices or **Select all**, and connect, disconnect with **Close**, or use **More** to mute / unmute or send a file. **Select all** includes every page in the current group; switching groups clears the selection.
<p align="center">
<img src="docs/images/desktop-deskhub-focus-en.png" width="960" alt="DeskHub enlarged view with thumbnails above the main preview and View only status, Group control, and Control buttons; screens are illustrative" />
</p>
**Group control:** click **Group control** in the enlarged view to mirror input from the current device to **all connected devices**, including other groups, pages, and connections not displayed in the wall. The scope is independent of the batch selection. Devices receive the same coordinates and keys without additional scaling for different screen sizes. Releasing control also ends group control.
DeskHub connections are muted by default. Enable sound with **Select → More → Unmute**. The bottom **Network** button shows direct / relay mode, encryption, receive/send rates, video latency, packet loss, resolution, and frame rate for all connected devices. For the full single-session toolbar, including display switching and privacy screens, click a connected device under **Recent Connections** in the main window to open its separate session window.
**Closing DeskHub disconnects all remote devices and closes session windows sharing those connections; minimizing it keeps connections open.** The disconnect scope includes all groups and pages, regardless of selection. See the [DeskHub guide](docs/deskhub.md) (Chinese) for further controls, preview behavior, and compatibility details.
<a id="session"></a>
## Session controls
@@ -136,7 +166,7 @@ Switch devices using session tabs. Expand the control bar if it is collapsed, an
| Fullscreen | Enter / exit fullscreen |
| Disconnect | End the current remote session |
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → General → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
### Adjust video during a session
@@ -150,10 +180,10 @@ After connecting, open **Video settings** (the sliders icon) in the toolbar. Cho
## Client settings and updates
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure codecs and connections before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path. Adjust quality, frame rate, and video preference from the remote session toolbar.
Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**, and click **Save** to apply changes. Switching tabs preserves pending changes; **Cancel** discards them. General settings are grouped into interface, video, networking, and further sections available by scrolling. Configure codecs and connections before connecting; some settings are disabled during an active session. Adjust ordinary session quality, frame rate, and video preference from its toolbar.
<p align="center">
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current client settings with codec, hardware codecs, TURN, forced relay, and automatic privacy screen options" />
<img src="docs/images/desktop-settings-en.png" width="720" alt="Updated General settings with General and DeskHub tabs, language, video codec, hardware codecs, and networking options" />
</p>
| Setting | Purpose |
@@ -169,6 +199,22 @@ Open **☰ → Settings** in the top-right corner and click **OK** to save. Conf
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved |
### DeskHub video transmission
Use **Settings → DeskHub** to configure wall sessions together. Saving applies changes immediately to current DeskHub sessions and future connections; ordinary connections are unaffected.
| Setting | Purpose |
| --- | --- |
| Resolution limit | Auto, 320 × 180, 640 × 360, 1280 × 720, 1920 × 1080, 2560 × 1440, 3840 × 2160, or Original resolution; Auto adapts to grid / enlarged views, while fixed limits preserve aspect ratio without upscaling the source |
| Frame rate | Follow device settings, or choose 15 / 30 / 60 FPS |
| Video budget | Defaults to 20 Mbps; choose 2 / 5 / 10 / 20 / 50 / 100 Mbps, shared by compatible DeskHub sessions with priority for enlarged views. Audio, file transfers, and ordinary connections are excluded; this is not a hard limit on instantaneous traffic |
<p align="center">
<img src="docs/images/desktop-settings-deskhub-en.png" width="720" alt="DeskHub video settings with Auto resolution, Follow device settings frame rate, and a 20 Mbps video budget" />
</p>
Devices supporting preview subscriptions can pause video while their views are hidden and resume when visible. Older hosts fall back according to their capabilities, so not every resolution, frame-rate, or bandwidth setting will apply.
### Check for updates
The client checks for updates in the background after launch and automatically opens the update dialog when a new version is found, showing release notes and a link to the download page. After you dismiss the dialog, background checks will not reopen it during the current run, while the main window menu keeps its update indicator. Restarting the client shows the dialog again if an update is still available. Open **☰ → About** to view the version. When an update is available, click **Release notes** below the new version to reopen the update dialog, or click **Check for updates** to check manually.
@@ -248,7 +294,7 @@ If **Remote Windows service unavailable** appears, check installation and servic
## Self-hosting
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → General → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting.
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
@@ -1493,6 +1493,10 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
SyncAnnouncements();
});
main->on_refresh_announcements([this] { announcements_.Refresh(); });
main->on_read_all_announcements([this] {
announcements_.ReadAll();
SyncAnnouncements();
});
main->on_open_announcement_link([this](slint::SharedString link) {
const std::string url(link);
if (announcement_text::IsWebUrl(url)) OpenUrl(url);
@@ -2637,6 +2641,7 @@ void GuiApplication::SyncAnnouncements() {
revision == ui_->announcement_selected_revision) {
selected_found = true;
main->set_announcement_selected_id(row.id);
main->set_announcement_selected_revision(row.revision);
main->set_announcement_selected_title(row.title);
main->set_announcement_selected_body(
announcement_text::Format(item["body"].get<std::string>()));
@@ -2648,6 +2653,7 @@ void GuiApplication::SyncAnnouncements() {
if (!selected_found) {
ui_->announcement_selected_id.clear();
main->set_announcement_selected_id("");
main->set_announcement_selected_revision("");
main->set_announcement_selected_body(slint::StyledText{});
}
main->set_announcements(
@@ -4973,6 +4979,7 @@ void GuiApplication::RestoreWindowGeometry(bool before_show,
auto& window = component->window();
const auto current_size = window.size();
const bool wall_mode = gallery_window;
const float compact_width = component->get_minimum_window_width();
const float compact_height = component->get_minimum_window_height();
if (!before_show && ui_->loaded_window_scale[gallery_window] > 0) {
const float ratio = window.scale_factor() / ui_->loaded_window_scale[gallery_window];
@@ -4986,7 +4993,8 @@ void GuiApplication::RestoreWindowGeometry(bool before_show,
}
ui_->loaded_window_scale[gallery_window] = 0;
}
auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height);
auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(),
compact_height, compact_width);
const auto& position = gallery_window
? placement.wall_position
: placement.connection_position;
@@ -5024,7 +5032,8 @@ void GuiApplication::RestoreWindowGeometry(bool before_show,
// placement. Only recover coordinates when its display is unavailable.
if (!found && available.w > 0 && available.h > 0) {
const auto margin = uint32_t(32 * window.scale_factor());
const auto minimum = placement.RestoreSize(false, window.scale_factor(), compact_height);
const auto minimum = placement.RestoreSize(false, window.scale_factor(),
compact_height, compact_width);
safe_size.width = std::max(minimum.width,
std::min(safe_size.width, uint32_t(available.w)));
safe_size.height = std::max(minimum.height, std::min(
@@ -5080,6 +5089,7 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) {
auto& placement = gallery_window ? ui_->gallery_window : ui_->connection_window;
const bool wall_mode = gallery_window;
const bool transitioning = gallery_window && ui_->gallery_transition.Active();
const float compact_width = component->get_minimum_window_width();
const float compact_height = component->get_minimum_window_height();
#if defined(__APPLE__)
const bool fullscreen = gallery_window && IsGalleryWindowFullscreen();
@@ -5091,16 +5101,17 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) {
if (!transitioning && placement.RecoverUndersized(wall_mode, window.position(), current_size,
window.scale_factor(), window.is_visible(),
window.is_minimized(), fullscreen, maximized,
compact_height)) {
compact_height, compact_width)) {
ui_->restore_ticks[gallery_window] = 0;
const auto target = placement.RestoreSize(wall_mode, window.scale_factor(),
compact_height);
compact_height, compact_width);
LOG_WARN("DeskHub undersized window: wall={} client={}x{} scale={}; restoring={}x{}",
wall_mode, current_size.width, current_size.height,
window.scale_factor(), target.width, target.height);
}
if (placement.Restoring() && ++ui_->restore_ticks[gallery_window] == 120) {
const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height);
const auto target = placement.RestoreSize(wall_mode, window.scale_factor(),
compact_height, compact_width);
LOG_WARN("DeskHub window restore delayed: wall={} client={}x{} target={}x{} scale={} fullscreen={} maximized={}",
wall_mode, current_size.width, current_size.height, target.width,
target.height, window.scale_factor(), fullscreen, maximized);
@@ -5114,7 +5125,7 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) {
current_size.height, window.scale_factor(), fullscreen, maximized);
} else if (window.is_visible() && !window.is_minimized() && !transitioning) {
placement.Observe(wall_mode, window.position(), current_size, window.scale_factor(),
fullscreen, maximized, compact_height);
fullscreen, maximized, compact_height, compact_width);
}
}
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Regenerate message_spacing.ttf with fontTools (only needed for regeneration).
Slint 1.17 pins line height to the requested font's ascent, descent and leading,
but StyledText does not expose line-height. This metrics-only font supplies 1.5em
line boxes without replacing any visible glyphs: Slint's normal system fallback
fonts still render the text and links. Use this family for message and release-note
bodies without changing the typography of other controls.
"""
from pathlib import Path
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
def main():
font = FontBuilder(1000, isTTF=True)
glyphs = [".notdef", "zeroWidthSpace"]
font.setupGlyphOrder(glyphs)
font.setupCharacterMap({0x200B: "zeroWidthSpace"})
font.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyphs})
font.setupHorizontalMetrics({name: (0, 0) for name in glyphs})
font.setupHorizontalHeader(ascent=1000, descent=-250, lineGap=250)
font.setupOS2(
version=4,
sTypoAscender=1000,
sTypoDescender=-250,
sTypoLineGap=250,
usWinAscent=1000,
usWinDescent=250,
fsSelection=0xC0, # Regular, use typographic metrics.
)
font.setupNameTable({
"familyName": "CrossDesk Message Spacing",
"styleName": "Regular",
"uniqueFontIdentifier": "CrossDesk-Message-Spacing-1.0",
"fullName": "CrossDesk Message Spacing",
"psName": "CrossDeskMessageSpacing-Regular",
"version": "Version 1.0",
"copyright": "Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.",
})
font.setupPost()
# Fixed timestamps keep the generated asset reproducible.
font.font["head"].created = font.font["head"].modified = 3874867200
font.font.recalcTimestamp = False
font.save(Path(__file__).with_name("message_spacing.ttf"))
if __name__ == "__main__":
main()
@@ -23,10 +23,11 @@ struct TranslationRow {
X(messages, u8"消息", "Messages", u8"Сообщения") \
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
X(message_read, u8"已读", "Read", u8"Прочитано") \
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \
X(message_close, u8"关闭", "Close", u8"Закрыть") \
X(message_delete, u8"删除", "Delete", u8"Удалить") \
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
@@ -24,14 +24,16 @@ struct WindowState {
void Observe(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486) {
bool maximized, float compact_height = 486,
float compact_width = 640) {
if (Restoring())
return;
if (!wall) {
connection_position = position;
return;
}
if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height))
if (!fullscreen && !maximized &&
IsUndersized(size, scale, compact_height, compact_width))
return;
scale = std::max(scale, 1.0f);
wall_fullscreen = fullscreen;
@@ -46,12 +48,13 @@ struct WindowState {
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool visible,
bool minimized, bool fullscreen, bool maximized,
float compact_height = 486) {
float compact_height = 486,
float compact_width = 640) {
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
// Leave those alone, and let an in-flight restoration retain its target.
if (Restoring() || !visible || minimized || fullscreen || maximized ||
size.width == 0 || size.height == 0 ||
!IsUndersized(size, scale, compact_height))
!IsUndersized(size, scale, compact_height, compact_width))
return false;
if (wall) {
wall_position = position;
@@ -66,9 +69,11 @@ struct WindowState {
}
slint::PhysicalSize RestoreSize(bool wall, float scale,
float compact_height = 486) const {
float compact_height = 486,
float compact_width = 640) const {
const float width =
wall && wall_size ? std::max(640.0f, wall_size->width) : 640;
wall && wall_size ? std::max(compact_width, wall_size->width)
: compact_width;
const float height = wall && wall_size
? std::max(compact_height, wall_size->height)
: compact_height;
@@ -157,9 +162,9 @@ private:
std::optional<slint::PhysicalSize> target_, last_size_;
int matches_ = 0;
static bool IsUndersized(slint::PhysicalSize size, float scale,
float compact_height) {
float compact_height, float compact_width) {
scale = std::max(scale, 1.0f);
return double(size.width) + 1 < 640 * scale ||
return double(size.width) + 1 < compact_width * scale ||
double(size.height) + 1 < compact_height * scale;
}
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
@@ -154,6 +154,29 @@ bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
}
return false;
}
bool AnnouncementInbox::ReadAll() {
std::lock_guard<std::mutex> lock(mutex_);
if (catalog_revision_ < 0) return false;
auto updated = reads_;
// Use the complete catalog, including announcements whose bodies have not
// been paged in. Persist once before changing any visible reading state.
for (const auto& [id, revision] : catalog_) {
if (!IsDismissed(id, revision)) updated[id] = revision;
}
if (updated == reads_) return true;
if (!SaveLocalState(updated, dismissed_)) {
state_.read_save_failed = true;
++state_.version;
return false;
}
reads_ = std::move(updated);
state_.read_save_failed = false;
state_.delete_failed = false;
UpdateReadingState();
++state_.version;
return true;
}
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
const auto found = dismissed_.find(id);
return found != dismissed_.end() && found->second == revision;
@@ -38,6 +38,7 @@ class AnnouncementInbox {
void Refresh();
void LoadMore();
bool Read(int64_t id, int64_t revision);
bool ReadAll();
bool Dismiss(int64_t id, int64_t revision);
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
std::chrono::steady_clock::now());
+21 -16
View File
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
// colors in one place so the Slint renderer produces the same visual weight.
export global ImGuiLineStyle {
@@ -8,7 +9,7 @@ export global ImGuiLineStyle {
}
export global FloatingBarStyle {
out property <length> corner-radius: 6px;
out property <length> corner-radius: UiRadius.menu;
}
// ImGui loads a 32px font atlas and applies per-window font scales. These
@@ -76,7 +77,7 @@ export component IconButton inherits Rectangle {
background: touch.pressed ? (danger ? #d92d20 : root.pressed-background)
: touch.has-hover ? (danger ? #f04438 : root.hover-background)
: root.normal-background;
border-radius: 4px;
border-radius: UiRadius.control;
opacity: root.enabled ? 1 : 0.4;
Text {
@@ -109,7 +110,7 @@ export component IconButton inherits Rectangle {
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
border-radius: 4px;
border-radius: UiRadius.control;
z: 20;
tip := Text {
text: root.tooltip;
@@ -136,7 +137,7 @@ export component ImageButton inherits Rectangle {
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
: transparent;
border-radius: 4px;
border-radius: UiRadius.control;
Image {
x: (parent.width - self.width) / 2;
@@ -157,7 +158,7 @@ export component ImageButton inherits Rectangle {
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
border-radius: 4px;
border-radius: UiRadius.control;
z: 20;
tip := Text {
text: root.tooltip;
@@ -175,6 +176,8 @@ export component CompactComboBox inherits Rectangle {
in property <bool> enabled: true;
in property <bool> subtle: false;
in property <bool> show-arrow: true;
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
in property <color> hover-border-color: root.normal-border-color;
property <bool> popup-animation-enabled: false;
property <float> popup-progress: 0.0;
callback selected(int);
@@ -189,8 +192,8 @@ export component CompactComboBox inherits Rectangle {
height: 24px;
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
border-width: 1px;
border-color: root.subtle ? #dfe6ef : #bfc2c7;
border-radius: root.subtle ? 6px : 3px;
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: UiRadius.control;
Text {
x: root.subtle ? 10px : 5px;
@@ -207,6 +210,8 @@ export component CompactComboBox inherits Rectangle {
height: parent.height - 2px;
y: 1px;
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
border-top-right-radius: UiRadius.control - 1px;
border-bottom-right-radius: UiRadius.control - 1px;
Text {
text: FontAwesomeIcons.angle-down;
color: root.subtle ? #79879a : #202124;
@@ -223,7 +228,7 @@ export component CompactComboBox inherits Rectangle {
}
}
}
TouchArea {
combo-touch := TouchArea {
enabled: root.enabled; mouse-cursor: pointer;
clicked => {
root.popup-animation-enabled = false;
@@ -263,8 +268,8 @@ export component CompactComboBox inherits Rectangle {
opacity: root.popup-progress;
background: white;
border-width: 1px;
border-color: root.subtle ? #dfe6ef : #bfc2c7;
border-radius: root.subtle ? 6px : 3px;
border-color: root.normal-border-color;
border-radius: UiRadius.menu;
clip: true;
for choice[index] in root.model: Rectangle {
y: index * 23px;
@@ -288,8 +293,8 @@ export component CompactComboBox inherits Rectangle {
height: parent.height;
background: transparent;
border-width: 1px;
border-color: root.subtle ? #dfe6ef : #bfc2c7;
border-radius: root.subtle ? 6px : 3px;
border-color: root.normal-border-color;
border-radius: UiRadius.menu;
z: 2;
}
}
@@ -361,7 +366,7 @@ export component CompactButton inherits Rectangle {
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
border-width: 1px;
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
border-radius: 3px;
border-radius: UiRadius.control;
Text {
text: root.text;
color: root.enabled ? #24272d : #8e9298;
@@ -385,7 +390,7 @@ export component PrimaryButton inherits Rectangle {
: touch.pressed ? #1849a9
: touch.has-hover ? #2970d6
: #2463c7;
border-radius: 5px;
border-radius: UiRadius.control;
Text {
text: root.text;
@@ -414,7 +419,7 @@ export component SecondaryButton inherits Rectangle {
: touch.has-hover ? #f3f4f6 : white;
border-width: 1px;
border-color: #c9ced7;
border-radius: 5px;
border-radius: UiRadius.control;
clip: true;
Text {
@@ -471,5 +476,5 @@ export component DialogSurface inherits Rectangle {
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 8px;
border-radius: UiRadius.surface;
}
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { IconButton, FontAwesomeIcons } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
@@ -12,6 +13,7 @@ export global DeviceCardStyle {
component CardAction inherits IconButton {
width: 26px; height: 23px;
border-radius: UiRadius.control;
icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
@@ -28,7 +30,7 @@ export component DeviceCardActions inherits Rectangle {
callback connect-device();
callback disconnect-device();
width: DeviceCardStyle.toolbar-width; height: 23px;
background: #4a4e55dc; border-radius: 5px;
background: #4a4e55dc; border-radius: UiRadius.control;
HorizontalLayout {
spacing: 0px;
CardAction {
@@ -1,4 +1,6 @@
import { ScrollView, LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
@@ -23,7 +25,7 @@ export component DeviceGroupDialog inherits Rectangle {
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: 8px;
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #b2b2b2;
TouchArea {}
Text {
@@ -42,7 +44,8 @@ export component DeviceGroupDialog inherits Rectangle {
group-list := Rectangle {
x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
clip: true;
ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width;
@@ -64,8 +67,21 @@ export component DeviceGroupDialog inherits Rectangle {
}
}
}
// Keep row hover backgrounds inside the rounded list on every renderer.
Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * UiRadius.surface;
height: parent.height + 2 * UiRadius.surface;
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white;
}
Rectangle {
width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface;
border-color: #e1e5eb;
}
}
LineEdit {
RoundedLineEdit {
x: 18px; y: group-list.y + group-list.height + 12px;
width: parent.width - 36px; height: 30px;
text <=> root.new-group;
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { CompactButton, FontAwesomeIcons } from "common.slint";
export struct GalleryMenuItem { label: string, enabled: bool }
@@ -61,6 +62,7 @@ export component GalleryMenuButton inherits Rectangle {
key-pressed(event) => { return root.handle-key(event); }
CompactButton {
width: parent.width; height: parent.height;
border-radius: UiRadius.control;
enabled: root.enabled; primary: root.primary;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
@@ -86,10 +88,10 @@ export component GalleryMenuButton inherits Rectangle {
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: 4px;
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
for item[index] in root.items: Rectangle {
x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px;
border-radius: 3px;
border-radius: UiRadius.control;
background: item.enabled && index == root.current-index ? #edf4fc : transparent;
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
accessible-action-default => { root.choose(index); }
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint";
import { ScrollView } from "std-widgets.slint";
@@ -137,7 +138,7 @@ component NetworkRow inherits Rectangle {
}
if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle {
x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px);
height: 26px; background: #293746; border-radius: 4px; z: 10;
height: 26px; background: #293746; border-radius: UiRadius.control; z: 10;
name-tip := Text {
x: 10px; width: parent.width - 20px; height: parent.height;
text: root.device.name; font-size: 11px; color: white;
@@ -172,7 +173,7 @@ export component GalleryNetworkDialog inherits Rectangle {
y: (parent.height - self.height) / 2;
width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: 8px;
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #c6cdd5;
TouchArea {}
FocusScope {
@@ -188,6 +189,7 @@ export component GalleryNetworkDialog inherits Rectangle {
font-size: 17px; font-weight: 600; color: #293746;
}
CompactButton {
border-radius: UiRadius.control;
x: parent.width - 80px; y: 17px; width: 60px; height: 26px;
text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); }
}
@@ -199,7 +201,7 @@ export component GalleryNetworkDialog inherits Rectangle {
}
NetworkRow {
x: 12px; y: 78px; width: parent.width - 38px; height: 30px;
heading: true; background: #f3f5f8; border-radius: 4px;
heading: true; background: #f3f5f8; border-radius: UiRadius.control;
device: {
name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"),
};
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint";
@@ -13,6 +14,7 @@ export component GallerySettings inherits Rectangle {
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.resolution-index;
model: [GalleryStrings.tr("自动", "Auto", "Авто"),
@@ -24,6 +26,7 @@ export component GallerySettings inherits Rectangle {
label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.frame-rate-index;
model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"];
@@ -33,22 +36,15 @@ export component GallerySettings inherits Rectangle {
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
label-reserve: self.width - 112px; divider: false;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.bandwidth-index;
model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"];
}
}
}
Text {
x: 2px; y: 144px; width: parent.width - 4px; height: 70px;
text: GalleryStrings.tr(
"可见设备共享视频预算,优先保障展开画面;不可见设备暂停视频。音频与文件传输不计入,旧版设备不支持限额。瞬时速率可能超过预算。",
"Visible devices share the video budget, with priority for expanded views. Hidden video pauses. Audio, files and older devices are excluded; brief bursts may exceed the budget.",
"Бюджет видео делится между видимыми устройствами с приоритетом крупного вида. Скрытое видео приостановлено. Звук, файлы и старые устройства не учитываются; возможны краткие превышения.");
color: #718096; font-size: 11px; wrap: word-wrap;
}
if root.error != "": Text {
x: 2px; y: 224px; width: parent.width - 4px; height: 32px;
x: 2px; y: 144px; width: parent.width - 4px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
}
+51 -31
View File
@@ -1,12 +1,18 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { RemoteCursor } from "remote_cursor.slint";
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
import { GalleryMenuButton } from "gallery_menu_button.slint";
import { GroupFilter } from "group_filter.slint";
import { ScrollView, LineEdit } from "std-widgets.slint";
import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
component GalleryToolbarButton inherits CompactButton {
component GalleryButton inherits CompactButton {
border-radius: UiRadius.control;
}
component GalleryToolbarButton inherits GalleryButton {
in property <string> alternate-text;
in property <length> horizontal-padding: 12px;
// Reserve both labels so toggling a mode never shifts adjacent controls.
@@ -487,7 +493,7 @@ export component GalleryView inherits Rectangle {
}
if root.batch && !root.simplified: Rectangle {
x: parent.width - 27px; y: parent.height - 27px;
width: 18px; height: 18px; border-radius: 5px; border-width: 1px;
width: 18px; height: 18px; border-radius: 4px; border-width: 1px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
animate background, border-color { duration: 120ms; }
@@ -560,14 +566,14 @@ export component GalleryView inherits Rectangle {
height: footer.y - 12px - self.y;
HorizontalLayout {
x: 0px; y: 0px; width: parent.width - root.view-controls-space; height: 24px; spacing: 8px;
CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
GalleryButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
Rectangle {
horizontal-stretch: 1; min-width: 0px;
Text { x: 0px; width: parent.width; height: parent.height; text: root.focused.name; color: root.focused.online ? #303943 : DeviceCardStyle.offline-text; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; }
}
Rectangle {
width: min(focus-state.preferred-width + 16px, 110px); height: 22px;
border-radius: 5px;
border-radius: UiRadius.control;
background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height;
@@ -589,12 +595,12 @@ export component GalleryView inherits Rectangle {
accessible-action-default => { self.clicked(); }
clicked => { root.action(root.group-controlling ? "group-release" : "group-control", root.focused.id); }
}
CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
}
Rectangle {
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
width: parent.width; height: parent.height - self.y;
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: 6px;
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface;
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : root.focused.online ? #d5dbe3 : DeviceCardStyle.offline-border;
video := Rectangle {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
@@ -666,7 +672,7 @@ export component GalleryView inherits Rectangle {
private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence;
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: 5px;
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface;
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; border-width: 1px; border-color: tile.online ? #d5dbe3 : DeviceCardStyle.offline-border;
accessible-role: button;
accessible-label: self.details;
@@ -696,7 +702,8 @@ export component GalleryView inherits Rectangle {
if self.has-hover { self.show-details(); }
else if root.device-tooltip-id == tile.id { root.device-tooltip-id = ""; }
}
moved => { if self.has-hover { self.show-details(); } }
changed mouse-x => { if self.has-hover { self.show-details(); } }
changed mouse-y => { if self.has-hover { self.show-details(); } }
clicked => { root.device-tooltip-id = ""; root.action("focus", tile.id); }
}
}
@@ -786,7 +793,7 @@ export component GalleryView inherits Rectangle {
network-button := SecondaryButton {
x: connection-count.x + connection-count.width + 12px;
min-width: 64px; width: 64px; height: parent.height;
border-color: #c8cbd0; border-radius: 3px;
border-color: #c8cbd0; border-radius: UiRadius.control;
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
accessible-role: button;
accessible-label: self.text;
@@ -796,9 +803,9 @@ export component GalleryView inherits Rectangle {
}
pager := HorizontalLayout {
x: parent.width - self.width; y: 0px; width: 112px; height: 24px; spacing: 6px;
previous-page := CompactButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
previous-page := GalleryButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; }
next-page := CompactButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
next-page := GalleryButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
}
}
// Paint the fullscreen tooltip above the grid/rail, below modal overlays.
@@ -815,6 +822,7 @@ export component GalleryView inherits Rectangle {
}
IconButton {
width: 24px; height: 24px;
border-radius: UiRadius.control;
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-fullscreen(); }
@@ -836,7 +844,7 @@ export component GalleryView inherits Rectangle {
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
width: 224px; height: 54px; z: 10;
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: 4px;
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface;
Rectangle {
x: 8px; y: 9px; width: 64px; height: 36px;
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
@@ -857,7 +865,7 @@ export component GalleryView inherits Rectangle {
if root.swap-save-failed: Rectangle {
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
width: min(420px, parent.width - 32px); height: 36px; z: 10;
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: 4px;
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: UiRadius.surface;
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
}
@@ -867,7 +875,7 @@ export component GalleryView inherits Rectangle {
x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px);
y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px);
width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px;
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: 5px;
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: UiRadius.control;
device-tooltip-label := Text {
x: 8px; y: 8px; width: parent.width - 16px;
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap;
@@ -878,7 +886,7 @@ export component GalleryView inherits Rectangle {
TouchArea {}
add-dialog := Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
function submit() {
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
password.text = "";
@@ -887,21 +895,21 @@ export component GalleryView inherits Rectangle {
}
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; }
Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; }
device-id := LineEdit {
device-id := RoundedLineEdit {
x: 96px; y: 56px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр");
input-type: InputType.number;
accepted => { password.focus(); }
}
Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; }
password := LineEdit {
password := RoundedLineEdit {
x: 96px; y: 100px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства");
input-type: InputType.password;
accepted => { group.focus(); }
}
Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; }
group := LineEdit {
group := RoundedLineEdit {
x: 96px; y: 144px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)");
accepted => { add-dialog.submit(); }
@@ -911,8 +919,8 @@ export component GalleryView inherits Rectangle {
TouchArea { clicked => { remember.checked = !remember.checked; } }
}
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
CompactButton {
GalleryButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
GalleryButton {
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
enabled: device-id.text != "" && password.text != "";
@@ -925,7 +933,7 @@ export component GalleryView inherits Rectangle {
TouchArea {}
Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; }
@@ -934,7 +942,7 @@ export component GalleryView inherits Rectangle {
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
Rectangle {
x: 18px; y: 102px; width: 404px; height: 160px;
border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true;
border-width: 1px; border-color: #d8dadd; border-radius: UiRadius.surface; clip: true;
recent-list := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
@@ -955,12 +963,24 @@ export component GalleryView inherits Rectangle {
}
}
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * UiRadius.surface;
height: parent.height + 2 * UiRadius.surface;
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white;
}
Rectangle {
width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface;
border-color: #d8dadd;
}
}
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
}
}
if root.confirming: Rectangle {
@@ -968,7 +988,7 @@ export component GalleryView inherits Rectangle {
Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: 360px; height: confirmation-list.height + 118px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
Text {
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
@@ -976,13 +996,13 @@ export component GalleryView inherits Rectangle {
}
Rectangle {
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
background: #eff3f8; border-radius: 11px;
background: #eff3f8; border-radius: UiRadius.surface;
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
}
confirmation-list := Rectangle {
x: 20px; y: 58px; width: parent.width - 40px;
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true;
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: UiRadius.surface; clip: true;
confirmation-scroll := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
@@ -1002,8 +1022,8 @@ export component GalleryView inherits Rectangle {
}
}
}
CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
CompactButton {
GalleryButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
GalleryButton {
x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true;
text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить");
clicked => { root.confirming = false; root.action(root.action-code, ""); }
+9 -4
View File
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint";
@@ -6,6 +7,9 @@ export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
in property <color> normal-border-color: #c9cdd2;
in property <color> hover-border-color: root.normal-border-color;
in property <color> focus-border-color: #8db5df;
in-out property <int> current-index: 0;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
@@ -23,8 +27,9 @@ export component GroupFilter inherits Rectangle {
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2;
border-radius: 4px;
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: UiRadius.control;
animate background, border-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus;
accessible-role: combobox;
@@ -117,7 +122,7 @@ export component GroupFilter inherits Rectangle {
width: parent.width; height: parent.height;
opacity: root.popup-progress;
background: white; border-width: 1px;
border-color: #c9cdd2; border-radius: 5px;
border-color: root.normal-border-color; border-radius: UiRadius.menu;
ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width;
@@ -131,7 +136,7 @@ export component GroupFilter inherits Rectangle {
}
for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: 3px;
width: parent.width; height: 26px; border-radius: UiRadius.control;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item;
File diff suppressed because it is too large Load Diff
+163 -144
View File
@@ -1,5 +1,9 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
import { IconButton, FontAwesomeIcons } from "common.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import "../assets/fonts/message_spacing.ttf";
export struct AnnouncementItem {
id: string,
@@ -13,10 +17,11 @@ export global MessageStrings {
in-out property <string> messages: "Messages";
in-out property <string> unread: "Unread";
in-out property <string> read: "Read";
in-out property <string> read-all: "Mark all as read";
in-out property <string> empty: "No announcements";
in-out property <string> select-message: "Select an announcement to read";
in-out property <string> loading: "Loading…";
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
in-out property <string> read-save-error: "Unable to save reading status locally. Please try again.";
in-out property <string> close: "Close";
in-out property <string> delete-message: "Delete";
in-out property <string> delete-title: "Delete announcement";
@@ -25,7 +30,7 @@ export global MessageStrings {
in-out property <string> cancel: "Cancel";
}
export component MessagePanel inherits DialogSurface {
export component MessagePanel inherits SettingsDialog {
in property <[AnnouncementItem]> items;
in property <int> unread-count;
in property <int> total;
@@ -36,6 +41,7 @@ export component MessagePanel inherits DialogSurface {
in property <bool> loaded;
in property <bool> connected;
in property <string> selected-id;
in property <string> selected-revision;
in property <string> selected-title;
in property <styled-text> selected-body;
in property <string> selected-date;
@@ -44,11 +50,9 @@ export component MessagePanel inherits DialogSurface {
callback open-link(string);
callback delete-message(string, string);
callback load-more;
callback close;
callback read-all;
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
: root.read-save-failed ? MessageStrings.read-save-error : "";
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
private property <color> separator-color: #e6e8eb;
private property <string> pending-delete-id;
private property <string> pending-delete-revision;
function request-delete(id: string, revision: string) {
@@ -67,137 +71,151 @@ export component MessagePanel inherits DialogSurface {
return reject;
}
}
background: white;
border-radius: 7px;
TouchArea { }
title-bar := Rectangle {
x: 0px; y: 0px; width: parent.width; height: 21px;
background: #d3d3d3; border-radius: 7px;
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
message-heading := Text {
x: 6px; width: parent.width - 100px; height: parent.height;
text: MessageStrings.messages;
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124;
vertical-alignment: center; overflow: elide;
}
if root.unread-count > 0: Rectangle {
x: message-heading.x + message-heading.preferred-width + 8px;
y: (parent.height - self.height) / 2; height: 16px;
width: max(16px, unread-count-label.preferred-width + 8px);
border-radius: 8px; background: #eaf2ff;
accessible-role: text;
accessible-label: MessageStrings.unread + " " + root.unread-count;
unread-count-label := Text {
text: "" + root.unread-count; color: #2563eb;
font-size: ImGuiFontStyle.small; font-weight: 600;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
if root.loading: Text {
x: parent.width - 150px; width: 110px; height: parent.height;
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
horizontal-alignment: right; vertical-alignment: center;
}
IconButton {
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
border-radius: 3px;
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
icon-color: #202124;
hover-background: #bfc2c7; pressed-background: #b2b2b2;
accessible-role: button;
accessible-label: MessageStrings.close;
accessible-action-default => { root.close(); }
clicked => { root.close(); }
title: MessageStrings.messages;
close-label: MessageStrings.close;
badge-count: root.unread-count;
badge-label: MessageStrings.unread + " " + root.unread-count;
header-status: root.loading ? MessageStrings.loading : "";
action-text: MessageStrings.read-all;
action-enabled: root.loaded && !root.loading && root.unread-count > 0;
action => { root.read-all(); }
notice := Rectangle {
x: 24px; y: root.content-top; width: parent.width - 48px;
height: root.notice-text == "" ? 0px : notice-label.preferred-height + 20px;
visible: root.notice-text != "";
border-radius: UiRadius.control; background: #fef2f2;
notice-label := Text {
x: 10px; y: 10px; width: parent.width - 20px;
height: self.preferred-height;
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
}
}
if root.notice-text != "": Rectangle {
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px;
border-radius: 4px;
background: #fef2f2;
Text {
x: 8px; width: parent.width - 16px; height: parent.height;
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide;
color: #b3261e;
vertical-alignment: center;
message-content := Rectangle {
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
width: parent.width - 48px; height: parent.height - self.y - 24px;
background: WorkbenchStyle.surface;
border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border;
if root.items.length > 0 || root.selected-id != "": Rectangle {
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
background: WorkbenchStyle.field-surface;
border-top-left-radius: UiRadius.surface - 1px;
border-bottom-left-radius: UiRadius.surface - 1px;
}
}
announcement-list := Rectangle {
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px;
announcements-scroll := ScrollView {
width: parent.width; height: parent.height;
VerticalLayout {
width: announcements-scroll.width; spacing: 0px; padding: 0px;
padding-right: 8px;
alignment: start;
for item[index] in root.items: Rectangle {
height: 54px;
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent;
border-radius: 4px;
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; }
if !item.read: Rectangle {
x: parent.width - 17px; y: 13px; width: 5px; height: 5px;
border-radius: 2.5px; background: #2563eb;
accessible-role: none;
}
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; }
accessible-role: button;
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
accessible-action-default => { root.open-message(item.id, item.revision); }
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } }
IconButton {
x: parent.width - 24px; y: 26px; width: 20px; height: 20px;
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small;
icon-color: #61656c; danger: true;
announcement-list := Rectangle {
x: 8px; y: 8px; width: 176px; height: parent.height - 16px;
announcements-scroll := ScrollView {
width: parent.width; height: parent.height;
viewport-width: self.width - 10px;
viewport-height: announcement-entries.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
announcement-entries := VerticalLayout {
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
alignment: start;
for item in root.items: Rectangle {
height: 64px;
background: root.selected-id == item.id ? #e8efff
: entry-touch.pressed ? WorkbenchStyle.pressed-surface
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: UiRadius.control;
border-width: 1px;
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
Text {
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
text: item.title; overflow: elide; font-size: 13px;
font-weight: item.read ? 400 : 600;
color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text;
vertical-alignment: center;
}
if !item.read: Rectangle {
x: parent.width - 15px; y: 16px; width: 6px; height: 6px;
border-radius: 3px; background: WorkbenchStyle.accent;
accessible-role: none;
}
Text {
x: 10px; y: 35px; width: parent.width - 20px; height: 18px;
text: item.date; color: WorkbenchStyle.muted; font-size: 11px;
overflow: elide; vertical-alignment: center;
}
accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + item.title;
accessible-action-default => { root.request-delete(item.id, item.revision); }
clicked => { root.request-delete(item.id, item.revision); }
}
if index < root.items.length - 1: Rectangle {
x: 8px; y: parent.height - 1px;
width: parent.width - 16px; height: 1px;
background: root.separator-color;
accessible-role: none;
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
accessible-action-default => { root.open-message(item.id, item.revision); }
entry-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.open-message(item.id, item.revision); }
}
}
}
}
}
}
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height;
text: MessageStrings.empty; wrap: word-wrap;
color: #61656c; font-size: ImGuiFontStyle.body;
horizontal-alignment: center; vertical-alignment: center;
}
content-divider := Rectangle {
x: announcement-list.x + announcement-list.width + 12px;
y: root.content-top; width: 1px; height: announcement-list.height;
background: root.separator-color;
visible: root.items.length > 0 || root.selected-id != "";
accessible-role: none;
}
announcement-details := Rectangle {
x: content-divider.x + content-divider.width + 12px;
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height;
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
if root.selected-id != "": details-scroll := ScrollView {
width: parent.width; height: parent.height;
VerticalLayout {
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px;
spacing: 12px; alignment: start;
VerticalLayout {
vertical-stretch: 0; spacing: 4px;
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; }
}
Rectangle { height: 1px; background: root.separator-color; }
StyledText {
vertical-stretch: 0;
text: root.selected-body;
default-color: #202124; default-font-size: ImGuiFontStyle.body;
link-color: #2563eb;
link-clicked(url) => { root.open-link(url); }
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
x: 24px; width: parent.width - 48px; height: parent.height;
text: MessageStrings.empty; wrap: word-wrap;
color: WorkbenchStyle.muted; font-size: 13px;
horizontal-alignment: center; vertical-alignment: center;
}
content-divider := Rectangle {
x: announcement-list.x + announcement-list.width + 8px;
y: 1px; width: 1px; height: parent.height - 2px;
background: WorkbenchStyle.separator;
visible: root.items.length > 0 || root.selected-id != "";
accessible-role: none;
}
announcement-details := Rectangle {
x: content-divider.x + 17px; y: 16px;
width: parent.width - self.x - 16px; height: parent.height - 32px;
clip: true;
if root.selected-id == "" && root.items.length > 0: Text {
width: parent.width; height: parent.height;
text: MessageStrings.select-message; color: WorkbenchStyle.muted; font-size: 13px;
wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center;
}
if root.selected-id != "": details-scroll := ScrollView {
width: parent.width; height: parent.height;
viewport-width: self.width - 12px;
viewport-height: details-content.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
details-content := VerticalLayout {
width: details-scroll.viewport-width; padding: 0px; padding-bottom: 4px;
spacing: 14px; alignment: start;
Rectangle {
height: max(28px, selected-heading.preferred-height);
selected-heading := VerticalLayout {
x: 0px; y: 0px;
width: parent.width - 40px; height: self.preferred-height;
padding: 0px; spacing: 6px;
Text {
vertical-stretch: 0; text: root.selected-title; wrap: word-wrap;
font-size: 16px; font-weight: 600; color: WorkbenchStyle.text;
}
Text {
vertical-stretch: 0; text: root.selected-date;
accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread);
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
}
}
IconButton {
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
icon-color: WorkbenchStyle.muted; danger: true;
accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
}
}
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
StyledText {
vertical-stretch: 0;
text: root.selected-body;
default-color: WorkbenchStyle.text; default-font-size: 13px;
// Metrics-only family provides 1.5em line height; visible
// glyphs continue to use Slint's normal font fallback.
default-font-family: "CrossDesk Message Spacing";
link-color: WorkbenchStyle.accent;
link-clicked(url) => { root.open-link(url); }
}
}
}
}
@@ -211,30 +229,31 @@ export component MessagePanel inherits DialogSurface {
triggered => { root.load-more(); }
}
if root.pending-delete-id != "": Rectangle {
background: #00000042; border-radius: root.border-radius; z: 30;
background: #15223850; border-radius: root.border-radius; z: 30;
TouchArea { clicked => { confirmation-keys.focus(); } }
DialogSurface {
SettingsDialog {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 230px; height: confirmation-content.preferred-height;
border-radius: root.border-radius;
TouchArea { clicked => { confirmation-keys.focus(); } }
width: min(360px, root.width - 48px);
height: self.content-top + confirmation-content.preferred-height + 24px;
title: MessageStrings.delete-title;
close-label: MessageStrings.cancel;
close => { root.pending-delete-id = ""; }
confirmation-content := VerticalLayout {
padding: 16px;
padding-top: 26px;
padding-bottom: 26px;
spacing: 20px;
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
x: 24px; y: parent.content-top; width: parent.width - 48px;
height: self.preferred-height;
padding: 0px; spacing: 24px;
Text { text: MessageStrings.delete-confirmation; font-size: 13px; color: WorkbenchStyle.text; wrap: word-wrap; }
HorizontalLayout {
alignment: center; spacing: 8px;
cancel-delete-button := CompactButton {
width: 72px; text: MessageStrings.cancel;
alignment: end; spacing: 8px;
cancel-delete-button := SettingsButton {
width: 88px; text: MessageStrings.cancel;
accessible-role: button; accessible-label: MessageStrings.cancel;
accessible-action-default => { cancel-delete-button.clicked(); }
clicked => { root.pending-delete-id = ""; }
}
confirm-delete-button := CompactButton {
width: 72px; text: MessageStrings.delete-message; primary: true;
confirm-delete-button := SettingsButton {
width: 88px; text: MessageStrings.delete-message; primary: true;
accessible-role: button; accessible-label: MessageStrings.delete-title;
accessible-action-default => { confirm-delete-button.clicked(); }
clicked => {
@@ -0,0 +1,50 @@
import { LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint";
// Retain the standard editor's selection, clipboard, clear button and input
// method support. Only its outer frame is replaced by the shared shape.
export component RoundedLineEdit inherits Rectangle {
in-out property <string> text;
in property <string> placeholder-text;
in property <InputType> input-type: InputType.text;
in property <bool> enabled: true;
in property <length> font-size: 12px;
in property <color> normal-border-color: #dfe6ef;
in property <brush> surrounding-background: white;
callback accepted();
callback edited();
height: 30px;
border-radius: UiRadius.control;
background: white;
clip: true;
forward-focus: editor;
editor := LineEdit {
x: -1px; y: -1px;
width: parent.width + 2px; height: parent.height + 2px;
text <=> root.text;
placeholder-text: root.placeholder-text;
input-type: root.input-type;
enabled: root.enabled;
font-size: root.font-size;
accepted => { root.accepted(); }
edited => { root.edited(); }
}
// The software renderer clips children to rectangular bounds. Mask the
// standard editor's corners before drawing our rounded border on top.
Rectangle {
x: -root.border-radius; y: -root.border-radius;
width: parent.width + 2 * root.border-radius;
height: parent.height + 2 * root.border-radius;
border-width: root.border-radius;
border-radius: 2 * root.border-radius;
border-color: root.surrounding-background;
}
Rectangle {
width: parent.width; height: parent.height;
border-radius: root.border-radius;
border-width: editor.has-focus ? 2px : 1px;
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
background: transparent;
}
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
@@ -27,7 +28,7 @@ component SessionButton inherits Rectangle {
callback clicked;
width: label.preferred-width + 20px;
height: 28px;
border-radius: 5px;
border-radius: UiRadius.control;
border-width: 1px;
border-color: root.danger ? #d92d20 : #c9ced7;
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
@@ -73,7 +74,7 @@ component SessionButton inherits Rectangle {
}
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
border-radius: 3px; border-width: 1px; border-color: root.danger ? white : #2463c7;
border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7;
}
}
@@ -172,7 +173,7 @@ export component ServerWindow inherits Window {
Rectangle {
width: root.width; height: root.height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
border-radius: 8px;
border-radius: UiRadius.surface;
Text {
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : "");
@@ -189,7 +190,7 @@ export component ServerWindow inherits Window {
select := Rectangle {
x: 0px; y: 0px;
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
height: parent.height; border-radius: 4px;
height: parent.height; border-radius: UiRadius.control;
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
if root.controllers.length > 1: Rectangle {
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
@@ -274,7 +275,7 @@ export component ServerWindow inherits Window {
// Draw outside the scrolling list so its last row can reveal its name.
if root.hovered-name != "": Rectangle {
x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20;
background: #24272d; border-radius: 5px;
background: #24272d; border-radius: UiRadius.control;
tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; }
}
}
@@ -1,4 +1,6 @@
import { CompactComboBox, ToggleSwitch } from "common.slint";
import { UiRadius } from "ui_radius.slint";
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages.
export component SettingsButton inherits Rectangle {
@@ -8,12 +10,13 @@ export component SettingsButton inherits Rectangle {
in property <bool> align-left: false;
in property <bool> enabled: true;
callback clicked();
width: 88px; height: 32px; border-radius: 7px;
width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? #f0f3f7
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
border-width: root.quiet || root.primary ? 0px : 1px;
border-color: #dce3ec;
border-color: !root.enabled ? WorkbenchStyle.border
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
accessible-role: button; accessible-label: root.text;
accessible-action-default => { if root.enabled { root.clicked(); } }
Text {
@@ -25,9 +28,71 @@ export component SettingsButton inherits Rectangle {
touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component SettingsDialog inherits DialogSurface {
in property <string> title;
in property <string> close-label;
in property <int> badge-count: 0;
in property <string> badge-label;
in property <string> header-status;
in property <string> action-text;
in property <bool> action-enabled: true;
out property <length> content-top: 64px;
callback action();
callback close();
background: #f8fafc;
border-color: WorkbenchStyle.separator;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
TouchArea { }
HorizontalLayout {
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
spacing: 10px;
Text {
text: root.title; font-size: 20px; font-weight: 600;
color: WorkbenchStyle.text; vertical-alignment: center;
}
if root.badge-count > 0: Rectangle {
width: max(24px, count-label.preferred-width + 14px); height: 22px;
border-radius: 11px; background: #e8efff;
accessible-role: text; accessible-label: root.badge-label;
count-label := Text {
text: "" + root.badge-count; color: WorkbenchStyle.accent;
font-size: 11px; font-weight: 600;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
Rectangle { horizontal-stretch: 1; }
if root.header-status != "": Text {
text: root.header-status; color: WorkbenchStyle.muted;
font-size: 11px; vertical-alignment: center;
}
if root.action-text != "": SettingsButton {
width: action-label-metrics.preferred-width + 24px; height: 28px;
text: root.action-text; enabled: root.action-enabled;
clicked => { root.action(); }
action-label-metrics := Text { visible: false; text: root.action-text; font-size: 12px; }
}
IconButton {
width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted;
hover-background: WorkbenchStyle.hover-surface;
pressed-background: WorkbenchStyle.pressed-surface;
accessible-role: button; accessible-label: root.close-label;
accessible-action-default => { root.close(); }
clicked => { root.close(); }
}
}
@children
}
export component SettingsSelect inherits CompactComboBox {
subtle: true;
show-arrow: false;
normal-border-color: WorkbenchStyle.control-border;
hover-border-color: WorkbenchStyle.hover-border;
height: 28px;
}
@@ -41,7 +106,7 @@ export component SettingsGroup inherits Rectangle {
}
Rectangle {
y: 24px; width: parent.width; height: parent.height - self.y;
background: white; border-radius: 9px; border-color: #e5eaf1; border-width: 1px;
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
VerticalLayout { padding: 0px; spacing: 0px; @children }
}
}
@@ -58,7 +123,7 @@ export component SettingsRow inherits Rectangle {
font-size: 12px; overflow: elide; vertical-alignment: center;
}
if root.divider: Rectangle {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #eff2f6;
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
}
@children
}
+14 -13
View File
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
import { ComboBox, ScrollView } from "std-widgets.slint";
import { RemoteCursor } from "remote_cursor.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint";
@@ -98,7 +99,7 @@ component ControlBarButton inherits Rectangle {
background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa
: touch.has-hover ? #4296fa
: root.selected ? #4296fa : #4296fa66;
border-radius: 3px;
border-radius: UiRadius.control;
Text {
text: root.icon;
@@ -159,7 +160,7 @@ component VideoSettingRow inherits VerticalLayout {
height: 27px;
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff
: option-touch.has-hover ? #eaf3ff : #f5f6f8;
border-radius: 4px;
border-radius: UiRadius.control;
border-width: 1px;
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6;
Text { text: option; color: root.enabled ? #30343b : #8b8f95;
@@ -967,7 +968,7 @@ export component StreamWindow inherits Window {
width: parent.width;
height: root.stats-header-height;
background: #f3f5f8;
border-radius: 4px;
border-radius: UiRadius.control;
Text {
x: stats-table.label-column-width + 4px;
width: stats-table.value-column-width - 8px;
@@ -1004,7 +1005,7 @@ export component StreamWindow inherits Window {
width: parent.width;
height: stats-table.row-height;
background: index == root.stats-rows.length - 1 ? #edf3fc : transparent;
border-radius: 4px;
border-radius: UiRadius.control;
Text {
x: 4px;
width: stats-table.label-column-width - 8px;
@@ -1133,7 +1134,7 @@ export component StreamWindow inherits Window {
x: root.video-menu-x; y: root.video-menu-y;
width: root.video-menu-width; height: root.video-menu-height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
border-radius: 5px; z: 11;
border-radius: UiRadius.menu; z: 11;
TouchArea {
pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } }
scroll-event(event) => { accept }
@@ -1183,7 +1184,7 @@ export component StreamWindow inherits Window {
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 5px;
border-radius: UiRadius.menu;
clip: true;
z: 11;
@@ -1195,7 +1196,7 @@ export component StreamWindow inherits Window {
: index == display-menu.hovered-index ? #d8e6f8
: index == root.selected-display ? #e8eef9
: transparent;
border-radius: 3px;
border-radius: UiRadius.control;
Text {
x: 8px;
width: parent.width - 34px;
@@ -1246,7 +1247,7 @@ export component StreamWindow inherits Window {
if root.shortcut-menu-open: Rectangle {
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: 68px;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11;
VerticalLayout {
padding: 4px; spacing: 1px;
for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle {
@@ -1280,7 +1281,7 @@ export component StreamWindow inherits Window {
// Anchor to the button row so expanded stats stay beneath the tooltip.
y: control.y + root.control-toolbar-height + self.height + 8px <= video.height
? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px);
background: #30343bf5; border-radius: 5px; z: 12;
background: #30343bf5; border-radius: UiRadius.control; z: 12;
control-tip := Text {
x: 8px; y: 6px; width: parent.width - 16px;
text: root.hovered-control.text;
@@ -1304,7 +1305,7 @@ export component StreamWindow inherits Window {
width: min(520px, parent.width - 48px);
height: max(208px, remote-update-message.y + remote-update-message.height + 92px);
border-color: #e5e9f0;
border-radius: 14px;
border-radius: UiRadius.surface;
drop-shadow-color: #07142b30;
drop-shadow-blur: 32px;
drop-shadow-offset-y: 12px;
@@ -1320,7 +1321,7 @@ export component StreamWindow inherits Window {
}
Rectangle {
x: 32px; y: 32px; width: 48px; height: 48px;
background: #edf4ff; border-radius: 12px;
background: #edf4ff; border-radius: UiRadius.surface;
Path {
x: 12px; y: 12px; width: 24px; height: 24px;
viewbox-width: 24; viewbox-height: 24;
@@ -1355,7 +1356,7 @@ export component StreamWindow inherits Window {
PrimaryButton {
x: parent.width - 136px; y: parent.height - 68px;
width: 104px; height: 36px;
border-radius: 7px;
border-radius: UiRadius.control;
text: StreamStrings.remote-update-dismiss;
text-size: 14px;
clicked => { root.dismiss-remote-update(); }
@@ -1365,7 +1366,7 @@ export component StreamWindow inherits Window {
// Transfer progress overlay at bottom-left.
if root.file-transfer-visible: Rectangle {
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7;
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7;
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } }
ScrollView {
@@ -46,6 +46,7 @@ inline int ApplyMainWindowStrings(
messages.set_messages(Text(localization::messages[language]));
messages.set_unread(Text(localization::message_unread[language]));
messages.set_read(Text(localization::message_read[language]));
messages.set_read_all(Text(localization::message_read_all[language]));
messages.set_empty(Text(localization::message_empty[language]));
messages.set_select_message(Text(localization::message_select[language]));
messages.set_loading(Text(localization::message_loading[language]));
+8
View File
@@ -0,0 +1,8 @@
// Logical pixels: compact controls, popup menus, and larger surfaces.
// Circular indicators, switches, progress bars, and native window chrome
// retain radii derived from their own geometry.
export global UiRadius {
out property <length> control: 6px;
out property <length> menu: 8px;
out property <length> surface: 12px;
}
@@ -0,0 +1,184 @@
import { UiRadius } from "ui_radius.slint";
// Colors and spacing for the connection workbench; shapes are shared app-wide.
export global WorkbenchStyle {
out property <color> canvas: #f2f5f9;
out property <color> surface: #ffffff;
out property <color> border: #c6cfdd;
out property <color> control-border: #b2bfd1;
out property <color> hover-border: #7f97b8;
out property <color> field-surface: #f5f7fb;
out property <color> hover-surface: #eef3fb;
out property <color> pressed-surface: #e4ebf6;
out property <color> separator: #e1e7ef;
out property <color> text: #1c2940;
out property <color> muted: #69788e;
out property <color> accent: #3564e3;
out property <color> success: #18835a;
}
export component WorkbenchButton inherits Rectangle {
in property <string> text;
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-suppressed: false;
in property <color> icon-color: root.primary ? white : WorkbenchStyle.muted;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> enabled: true;
out property <bool> has-focus: button-focus.has-focus;
private property <bool> keyboard-focus: false;
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
private property <bool> tooltip-active: root.enabled && root.tooltip != ""
&& !root.tooltip-suppressed && !touch.pressed
&& (touch.has-hover || root.show-focus);
private property <bool> tooltip-visible: false;
changed tooltip-active => { root.tooltip-visible = false; }
callback clicked;
width: 112px;
height: 36px;
border-radius: UiRadius.control;
border-width: root.show-focus ? 2px : 1px;
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
: root.primary ? WorkbenchStyle.accent
: touch.has-hover ? WorkbenchStyle.hover-border
: root.quiet ? transparent : WorkbenchStyle.control-border;
background: root.primary
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white);
opacity: root.enabled ? 1 : 0.45;
animate background, border-color { duration: 120ms; }
forward-focus: button-focus;
accessible-role: button;
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
accessible-enabled: root.enabled;
accessible-action-default => { if root.enabled { root.clicked(); } }
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
alignment: center;
if root.icon != "": Text {
text: root.icon;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 14px;
color: root.icon-color;
vertical-alignment: center;
accessible-role: none;
}
if root.text != "": Text {
text: root.text;
font-size: 13px;
font-weight: root.primary ? 600 : 500;
color: root.primary ? white : WorkbenchStyle.text;
vertical-alignment: center;
accessible-role: none;
}
}
button-focus := FocusScope {
enabled: root.enabled;
focus-gained(reason) => {
if reason == FocusReason.tab-navigation {
root.keyboard-focus = true;
} else if reason == FocusReason.pointer-click {
root.keyboard-focus = false;
}
}
key-pressed(event) => {
root.keyboard-focus = true;
if event.text == Key.Return || event.text == " " {
root.clicked();
return accept;
}
return reject;
}
touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
pointer-event(event) => {
if event.kind == PointerEventKind.down {
root.keyboard-focus = false;
}
}
// Keep focus for subsequent keyboard activation without leaving a
// keyboard focus ring behind after a pointer click.
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
}
}
tooltip-timer := Timer {
interval: 450ms;
running: root.tooltip-active && !root.tooltip-visible;
triggered => { root.tooltip-visible = true; }
}
if root.tooltip-active && root.tooltip-visible: Rectangle {
x: min(0px, root.width - self.width);
y: root.height + 6px;
width: min(260px, tip.preferred-width + 20px);
height: max(28px, tip.preferred-height + 12px);
background: #24324a;
border-radius: UiRadius.control;
z: 20;
tip := Text {
x: 10px; y: 6px;
width: parent.width - 20px;
height: parent.height - 12px;
text: root.tooltip;
font-size: 12px;
color: white;
wrap: word-wrap;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// A raised surface keeps transient action feedback distinct from the workbench.
export component WorkbenchNotice inherits Rectangle {
in property <string> text;
in property <string> icon;
in property <bool> error: false;
in property <length> width-limit: 360px;
private property <color> foreground: root.error ? #b42318 : #146c49;
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
height: 64px;
background: root.error ? #fff1f0 : #e9f6ef;
border-width: 1px;
border-color: root.error ? #f0c4bf : #b9dfc9;
border-radius: UiRadius.surface;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 6px;
accessible-role: AccessibleRole.text;
accessible-label: root.text;
Text {
x: message.x - 28px; width: 18px; height: parent.height;
text: root.icon;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 16px;
color: root.foreground;
horizontal-alignment: center;
vertical-alignment: center;
accessible-role: none;
}
message := Text {
x: (parent.width - self.width + 28px) / 2;
width: min(self.preferred-width, parent.width - 64px);
height: parent.height;
text: root.text;
font-size: 14px;
font-weight: 600;
color: root.foreground;
overflow: elide;
vertical-alignment: center;
accessible-role: none;
}
}
export component WorkbenchCard inherits Rectangle {
background: WorkbenchStyle.surface;
border-width: 1px;
border-color: WorkbenchStyle.border;
border-radius: UiRadius.surface;
}
+420 -7
View File
@@ -1,5 +1,7 @@
#include "crossdesk_ui.h"
#include "fa_solid_900.h"
#include "features/gallery/gallery_window_state.h"
#include "ui/release_notes_text.h"
#include "ui/ui_localization.h"
#include <algorithm>
@@ -125,9 +127,16 @@ bool ConfigureDocumentationDemo(
{"987654321", "Dev Workstation", "fixtures/source.png"},
{"246813579", "Office PC", "fixtures/website.png"},
{"135792468", "Home Laptop", "web-client.png"},
{"864209753", "Studio Mac", "fixtures/source.png"},
{"753198642", "Build Server", "fixtures/website.png"},
{"642087531", "Test Device", "web-client.png"},
};
std::vector<crossdesk::ui::RecentConnection> connections;
for (const auto &device : devices) {
if (connections.size() == 3 && options.page != "main-recent-overflow" &&
options.page != "main-recent-scrolled") {
break;
}
const std::string path = options.demo_assets_path + "/" + device.thumbnail;
const auto thumbnail =
slint::Image::load_from_path(slint::SharedString(path.c_str()));
@@ -219,8 +228,23 @@ void ConfigureMainCapturePage(
window->set_wayland_titlebar(false);
#endif
if (options.page == "settings") {
if (options.page == "settings" || options.page == "settings-scroll-hover" ||
options.page == "settings-scrolled" || options.page == "settings-scroll-drag") {
window->set_settings_open(true);
} else if (options.page == "main-menu-badges") {
window->set_announcement_unread(4);
window->set_update_available(true);
window->set_update_open(false);
} else if (options.page == "settings-deskhub" ||
options.page == "settings-deskhub-error") {
window->set_settings_open(true);
window->set_settings_tab(1);
if (options.page == "settings-deskhub-error") {
window->set_gallery_settings_error(
window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
"无法保存设置,请重试。", "Unable to save settings. Please try again.",
"Не удалось сохранить настройки. Повторите попытку."));
}
} else if (options.page == "self-hosted") {
window->set_settings_open(true);
window->set_self_host_settings_open(true);
@@ -238,11 +262,52 @@ void ConfigureMainCapturePage(
? crossdesk::ui::UpdateCheckStatus::Failed
: crossdesk::ui::UpdateCheckStatus::Available);
}
} else if (options.page == "update") {
} else if (options.page == "update" || options.page == "update-scrolled" ||
options.page == "update-short") {
window->set_update_available(true);
window->set_latest_version("v0.0.1");
window->set_latest_version("v1.6.1-20261010");
window->set_release_name("CrossDesk Preview");
window->set_release_date("2026-08-25");
window->set_release_date("2026-10-10");
// Illustrative notes exercise the production Markdown parser and wrapping,
// including enough content to inspect both ends of the scrolling viewport.
const char *notes[] = {
"**新增功能与改进**\n\n"
"1. 增强信令快速重连与连接恢复能力;桌面端配置修改无需重新连接信令服务。\n"
"2. Windows 安装版新增登录前远程控制支持,被控端可随系统启动,在尚未登录时接受远程连接。\n"
"3. Linux 提供普通版与 Xfce 虚拟桌面版独立安装包,普通版不再强制安装 Xfce 和 Xvfb。\n"
"4. 被控端会话窗口改为紧凑的屏幕边缘状态栏。\n"
"5. 完善 iOS / Android 原生控制端,优化远程画面、触控与外接键鼠体验。\n\n"
"**问题修复**\n\n"
"6. 修复连接中断后的状态显示,改善高分辨率屏幕上的文字与按钮边界。\n"
"7. 统一窗口、输入框与按钮的圆角,优化更新提示与长文本滚动体验。",
"**Features and improvements**\n\n"
"1. Improve signaling reconnection and session recovery. Desktop configuration changes no longer require reconnecting to the signaling service.\n"
"2. Add remote control before Windows sign-in, allowing the installed client to accept connections when the system starts.\n"
"3. Provide separate Linux packages for standard installations and Xfce virtual desktops.\n"
"4. Replace the controlled session window with a compact status bar at the edge of the screen.\n"
"5. Improve the native iOS and Android clients, including video, touch, and external keyboard and mouse support.\n\n"
"**Fixes**\n\n"
"6. Correct connection status after interruptions and improve text and control borders on high resolution displays.\n"
"7. Unify corner radii and improve update notifications and scrolling for long release notes.",
"**Новые возможности и улучшения**\n\n"
"1. Улучшено восстановление соединения. Изменение настроек больше не требует повторного подключения к серверу сигнализации.\n"
"2. Добавлено удалённое управление Windows до входа пользователя в систему.\n"
"3. Подготовлены отдельные пакеты Linux для стандартной установки и виртуальных рабочих столов Xfce.\n"
"4. Окно управляемого сеанса заменено компактной строкой состояния у края экрана.\n"
"5. Улучшены клиенты iOS и Android, работа с сенсорным экраном, внешней клавиатурой и мышью.\n\n"
"**Исправления**\n\n"
"6. Исправлено отображение состояния после обрыва соединения, улучшена читаемость на экранах высокого разрешения.\n"
"7. Унифицированы скругления элементов и улучшена прокрутка длинных описаний обновлений."};
std::vector<crossdesk::ui::ReleaseNoteBlock> blocks;
for (const auto &parsed : crossdesk::release_notes_text::Parse(notes[language])) {
crossdesk::ui::ReleaseNoteBlock block{};
block.content = parsed.content;
block.section_gap = parsed.section_gap;
blocks.push_back(std::move(block));
if (options.page == "update-short" && blocks.size() == 2) break;
}
window->set_release_note_blocks(
std::make_shared<slint::VectorModel<crossdesk::ui::ReleaseNoteBlock>>(blocks));
window->set_update_open(true);
} else if (options.page == "reset-password") {
window->set_reset_password_open(true);
@@ -363,10 +428,72 @@ int RunCaptureMode(
slint::ComponentHandle<crossdesk::ui::MainWindow> &window,
slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
slint::ComponentHandle<crossdesk::ui::ServerWindow> &server) {
window->on_format_remote_id([](slint::SharedString id) { return id; });
ConfigureMainCapturePage(window, options);
if (!ConfigureDocumentationDemo(window, options)) {
return 8;
}
// Preview DeskHub with the same demo images, without opening any sessions.
if (options.page.starts_with("gallery")) {
window->set_gallery_mode(true);
std::vector<crossdesk::ui::GalleryTile> tiles;
const auto recent = window->get_recent_connections();
for (size_t index = 0; index < recent->row_count(); ++index) {
const auto device = recent->row_data(index).value();
crossdesk::ui::GalleryTile tile{};
tile.id = device.remote_id;
tile.name = device.display_name;
tile.online = tile.presence_known = tile.connected = true;
tile.has_frame = device.has_thumbnail;
tile.frame = device.thumbnail;
tile.selected = options.page == "gallery-batch";
tiles.push_back(tile);
}
window->set_gallery_tiles(
std::make_shared<slint::VectorModel<crossdesk::ui::GalleryTile>>(tiles));
window->set_gallery_count(static_cast<int>(tiles.size()));
window->set_gallery_connected(static_cast<int>(tiles.size()));
window->set_gallery_groups(
std::make_shared<slint::VectorModel<slint::SharedString>>(
std::vector{window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
"全部设备", "All devices", "Все устройства")}));
window->set_gallery_batch(options.page == "gallery-batch");
window->set_gallery_selected(options.page == "gallery-batch"
? static_cast<int>(tiles.size()) : 0);
window->set_gallery_adding(options.page == "gallery-add");
if (options.page == "gallery-focus" && !tiles.empty())
window->set_gallery_focused(tiles.front());
// A 960 x 720 preview includes the native 22px macOS titlebar.
const float client_height =
#if defined(__APPLE__)
698.0f;
#else
720.0f;
#endif
window->window().set_size(
slint::LogicalSize(slint::Size<float>{960.0f, client_height}));
}
// Workbench states use the same rendered components and demo-only data.
if (options.page == "main-empty" || options.page == "main-empty-group") {
window->set_recent_connections(
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>());
window->set_recent_group_index(options.page == "main-empty-group" ? 1 : 0);
} else if (options.page == "main-error") {
window->set_remote_id_input("123");
window->set_connection_input_error(
window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
"请输入完整的 9 位设备 ID", "Enter a complete 9-digit device ID",
"Введите полный ID устройства из 9 цифр"));
} else if (options.page == "main-offline") {
window->set_signal_connected(false);
window->set_password_visible(false);
const auto connections = window->get_recent_connections();
for (size_t index = 0; index < connections->row_count(); ++index) {
auto device = connections->row_data(index).value();
device.online = false;
connections->set_row_data(index, device);
}
}
stream->hide();
server->hide();
@@ -422,11 +549,113 @@ int RunCaptureMode(
// taking the snapshot; capturing immediately after show() can yield 1x.
bool snapshot_written = false;
slint::Timer snapshot_timer;
slint::Timer capture_settle_timer;
slint::Timer pointer_move_timer;
snapshot_timer.start(slint::TimerMode::SingleShot,
std::chrono::milliseconds(250), [&] {
snapshot_written =
WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str());
slint::quit_event_loop();
if (options.page == "main-device-tooltip" ||
options.page == "main-device-tooltip-moved" ||
options.page == "main-device-tooltip-edge" ||
options.page == "main-device-tooltip-exit") {
const float titlebar = window->get_minimum_window_height() - 472.0f;
window->window().dispatch_pointer_move_event(
slint::LogicalPosition(slint::Point<float>{90.0f, titlebar + 330.0f}));
if (options.page != "main-device-tooltip") {
// A second event in the same card verifies hover movement without
// pressing the mouse or relying on another enter/leave event.
pointer_move_timer.start(slint::TimerMode::SingleShot,
std::chrono::milliseconds(50), [&, titlebar] {
const bool edge = options.page == "main-device-tooltip-edge";
const bool leave = options.page == "main-device-tooltip-exit";
window->window().dispatch_pointer_move_event(slint::LogicalPosition(
slint::Point<float>{leave ? 2.0f : edge ? window->get_minimum_window_width() - 40.0f : 170.0f,
leave ? 2.0f : titlebar + 340.0f}));
});
}
} else if (options.page == "settings-scroll-hover" ||
options.page == "settings-scrolled" ||
options.page == "settings-scroll-drag") {
const float dialog_x = (window->get_minimum_window_width() - 400.0f) / 2;
const float dialog_y = (window->get_minimum_window_height() - 446.0f) / 2;
const auto scrollbar_position = slint::LogicalPosition(
slint::Point<float>{dialog_x + 389.0f, dialog_y + 150.0f});
if (options.page == "settings-scrolled") {
window->window().dispatch_pointer_scroll_event(
slint::LogicalPosition(slint::Point<float>{320.0f, 260.0f}),
0.0f, -2000.0f);
}
window->window().dispatch_pointer_move_event(scrollbar_position);
if (options.page == "settings-scroll-drag") {
window->window().dispatch_pointer_press_event(
scrollbar_position, slint::PointerEventButton::Left);
const auto drag_end = slint::LogicalPosition(
slint::Point<float>{dialog_x + 389.0f, dialog_y + 340.0f});
window->window().dispatch_pointer_move_event(drag_end);
window->window().dispatch_pointer_release_event(
drag_end, slint::PointerEventButton::Left);
}
} else if (options.page == "update-scrolled") {
window->window().dispatch_pointer_scroll_event(
slint::LogicalPosition(slint::Point<float>{320.0f, 260.0f}),
0.0f, -2000.0f);
window->window().dispatch_pointer_move_event(
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
} else if (options.page == "main-recent-scrolled") {
window->window().dispatch_pointer_scroll_event(
slint::LogicalPosition(slint::Point<float>{
400.0f, window->get_minimum_window_height() - 472.0f + 360.0f}),
-400.0f, 0.0f);
window->window().dispatch_pointer_move_event(
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
} else if (options.page == "main-menu" ||
options.page == "main-menu-badges") {
const auto menu_position = slint::LogicalPosition(
slint::Point<float>{window->get_minimum_window_width() - 38.0f,
window->get_minimum_window_height() - 472.0f + 20.0f});
window->window().dispatch_pointer_press_event(
menu_position, slint::PointerEventButton::Left);
window->window().dispatch_pointer_release_event(
menu_position, slint::PointerEventButton::Left);
window->window().dispatch_pointer_move_event(
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
} else if (options.page == "main-copy-tooltip" ||
options.page == "main-password-tooltip") {
const bool copy = options.page == "main-copy-tooltip";
window->window().dispatch_pointer_move_event(slint::LogicalPosition(
slint::Point<float>{window->get_minimum_window_width() / 2 - (copy ? 48.0f : 42.0f),
window->get_minimum_window_height() - 472.0f +
(copy ? 150.0f : 216.0f)}));
} else if (options.page == "main-copy-released" ||
options.page == "main-copy-released-outside" ||
options.page == "main-keyboard-tab") {
const auto copy_position = slint::LogicalPosition(slint::Point<float>{
window->get_minimum_window_width() / 2 - 48.0f,
window->get_minimum_window_height() - 472.0f + 150.0f});
window->window().dispatch_pointer_move_event(copy_position);
window->window().dispatch_pointer_press_event(
copy_position, slint::PointerEventButton::Left);
window->window().dispatch_pointer_release_event(
copy_position, slint::PointerEventButton::Left);
if (options.page != "main-copy-released") {
window->window().dispatch_pointer_move_event(
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
}
if (options.page == "main-keyboard-tab") {
// Traverse the remote ID input and focus the Connect button.
for (int index = 0; index < 2; ++index) {
window->window().dispatch_key_press_event("\t");
window->window().dispatch_key_release_event("\t");
}
}
}
// Let settings scroll animations settle as well as hover/focus feedback.
capture_settle_timer.start(slint::TimerMode::SingleShot,
std::chrono::milliseconds(
options.page.rfind("settings", 0) == 0 ? 350 : 150), [&] {
snapshot_written =
WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str());
slint::quit_event_loop();
});
});
slint::run_event_loop();
return snapshot_written ? 0 : 7;
@@ -440,10 +669,194 @@ int RunCaptureMode(
return 0;
}
// Sample the native window while the same restoration used by the application
// runs. A component-only screenshot used to miss the conflicting restore width.
bool VerifyMainWindowGeometry() {
for (bool restore : {false, true}) {
auto component = crossdesk::ui::MainWindow::create();
auto &window = component->window();
crossdesk::gallery::WindowState placement;
const float width = component->get_minimum_window_width();
const float height = component->get_minimum_window_height();
if (width != 640.0f || height != 472.0f) return false;
if (restore) {
placement.BeginRestore();
placement.PrepareForShow(
window, false,
placement.RestoreSize(false, window.scale_factor(), height, width),
std::nullopt);
}
window.show();
bool stable = true;
int samples = 0;
slint::Timer timer;
timer.start(slint::TimerMode::Repeated, std::chrono::milliseconds(16), [&] {
const auto actual = window.size();
const auto expected = placement.RestoreSize(
false, window.scale_factor(), height, width);
if (actual.width != expected.width || actual.height != expected.height) {
std::cerr << "Startup geometry changed: restore=" << restore
<< " sample=" << samples << " actual=" << actual.width
<< 'x' << actual.height << " expected=" << expected.width
<< 'x' << expected.height << '\n';
stable = false;
}
if (placement.RestoringNormal()) {
if (!placement.NormalSizeRestored(actual, expected))
window.set_size(expected);
} else if (placement.Restoring()) {
// Match the application's fixed connection-window role. AppKit can
// report a fixed-size window as zoomed even at its normal dimensions.
placement.FinishRestore(false, actual, false, false);
}
if (++samples == 20) slint::quit_event_loop();
});
slint::run_event_loop();
timer.stop();
window.hide();
if (!stable || placement.Restoring()) return false;
}
return true;
}
// Exercise hit targets after the workbench layout change, rather than calling
// callbacks directly: overlapping surfaces must not swallow these actions.
bool VerifyWorkbenchActions() {
auto window = crossdesk::ui::MainWindow::create();
const float width = window->get_minimum_window_width();
window->window().set_size(
slint::LogicalSize(slint::Size<float>{width, window->get_minimum_window_height()}));
auto click = [&](float x, float y) {
const auto position = slint::LogicalPosition(slint::Point<float>{x, y});
window->window().dispatch_pointer_press_event(
position, slint::PointerEventButton::Left);
window->window().dispatch_pointer_release_event(
position, slint::PointerEventButton::Left);
};
int copies = 0, visibility_changes = 0, refreshes = 0, connections = 0;
bool gallery_opened = false;
window->on_copy_local_id([&] { ++copies; return true; });
window->on_toggle_password_visibility([&] { ++visibility_changes; });
window->on_refresh_password([&] { ++refreshes; });
window->on_open_gallery([&] { gallery_opened = true; });
window->set_remote_id_input("987 654 321");
window->on_connect_requested([&](slint::SharedString id) {
if (id == "987 654 321") ++connections;
});
click(width / 2 - 48, 150);
click(width / 2 - 118, 216);
click(width / 2 - 80, 216);
click(width / 2 - 42, 216);
if (copies != 1 || visibility_changes != 1 || refreshes != 1 ||
!window->get_reset_password_open()) return false;
window->set_reset_password_open(false);
window->set_password_change_pending(true);
click(width / 2 - 80, 216);
click(width / 2 - 42, 216);
if (refreshes != 1 || window->get_reset_password_open()) return false;
click(width * 0.75f - 6, 216);
// Space activates the focused primary button; pending state blocks repeats.
window->window().dispatch_key_press_event(" ");
window->window().dispatch_key_release_event(" ");
if (connections != 2) return false;
window->set_connection_pending(true);
click(width * 0.75f - 6, 216);
if (connections != 2) return false;
window->set_connection_pending(false);
click(width - 38, 20);
click(width - 78, 56);
if (!window->get_settings_open()) return false;
// Both tabs must remain at the same position, including with a save error.
click(width / 2 + 87, 89);
if (window->get_settings_tab() != 1) return false;
click(width / 2 - 87, 89);
if (window->get_settings_tab() != 0) return false;
click(width / 2 + 87, 89);
window->set_gallery_settings_error("Unable to save settings. Please try again.");
click(width / 2 - 87, 89);
if (window->get_settings_tab() != 0) return false;
window->set_gallery_settings_error("");
window->set_settings_open(false);
click(60, 20);
if (!gallery_opened) return false;
crossdesk::ui::RecentConnection device{};
device.remote_id = "987654321";
device.display_name = "Workstation";
device.online = true;
window->set_recent_connections(
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
std::vector{device}));
bool recent_connected = false;
window->on_recent_connect([&](slint::SharedString id) {
recent_connected = id == "987654321";
});
click(100, 344);
if (!recent_connected) return false;
bool password_submitted = false, cancelled = false;
window->on_connection_submit_password([&](slint::SharedString value, bool) {
password_submitted = value == "654321";
});
window->on_connection_cancel([&] { cancelled = true; });
window->set_connection_password("654321");
window->set_connection_password_required(true);
window->set_connection_dialog_open(true);
click(width / 2 + 62, 312);
click(width / 2 - 62, 312);
if (!password_submitted || !cancelled) return false;
window->set_connection_dialog_open(false);
// Rounded frames must preserve editor focus, text bindings and Enter actions.
auto type = [&](std::string_view text) {
for (char character : text) {
const slint::SharedString key(std::string(1, character).c_str());
window->window().dispatch_key_press_event(key);
window->window().dispatch_key_release_event(key);
}
};
bool renamed = false;
window->on_recent_edit_alias([&](slint::SharedString, slint::SharedString name) {
renamed = name == "Office";
});
window->set_alias_input("");
window->set_alias_open(true);
click(width / 2, 238);
type("Office\n");
if (!renamed || window->get_alias_open()) {
std::cerr << "Alias editor did not accept the typed name; text="
<< std::string(window->get_alias_input()) << '\n';
return false;
}
bool added = false;
window->on_gallery_add_device([&](slint::SharedString id,
slint::SharedString password, slint::SharedString group, bool) {
added = id == "123456789" && password == "123456" && group == "Lab";
return added;
});
window->set_gallery_mode(true);
window->window().set_size(slint::LogicalSize(slint::Size<float>{width, 448.0f}));
window->set_gallery_adding(true);
click(width / 2, 142);
type("123456789\n123456\nLab\n");
if (!added || window->get_gallery_adding()) {
std::cerr << "Add-device editors did not retain Enter focus traversal\n";
return false;
}
return true;
}
} // namespace
int main() {
const CaptureOptions capture_options = LoadCaptureOptions();
if (!VerifyMainWindowGeometry()) {
std::cerr << "Main window dimensions must stay stable through startup\n";
return 11;
}
if (!VerifyWorkbenchActions()) {
std::cerr << "Workbench controls must retain their pointer and keyboard actions\n";
return 10;
}
auto window = crossdesk::ui::MainWindow::create();
auto stream = crossdesk::ui::StreamWindow::create();
auto server = crossdesk::ui::ServerWindow::create();
+1 -1
View File
@@ -1,6 +1,6 @@
# 桌面墙(DeskHub)
主窗口“近期连接”标题行右侧、分组筛选左边的“桌面墙”按钮打开独立的桌面墙窗口。菜单与上方桌面标题共用一行,连接窗口保持紧凑尺寸。重复点击按钮会唤起已有桌面墙窗口,最小化时恢复显示,不创建重复窗口。每次启动软件只显示连接窗口;桌面墙首次打开时恢复保存的位置、尺寸及全屏状态;没有已保存尺寸时默认采用 960 × 720 逻辑像素(含标题栏,当前 macOS 的内容区为 960 × 688),两个窗口分别记忆位置。关闭桌面墙时释放远程操控并断开全部远程设备连接,不受当前分组、分页和选中状态限制,共用连接的独立串流窗口也随之关闭;同时取消排队连接、密码重试和待重连请求。设备列表、分组与窗口布局保留,再次点击“桌面墙”可打开并手动重新连接设备。最小化窗口不会断开连接。已断开的显示器所对应的位置会修正到可用屏幕内。
主窗口左上角的“桌面墙”按钮打开独立的桌面墙窗口,与右上角菜单共用顶部工具栏,下方是“本桌面”和“远程桌面”卡片,连接窗口保持紧凑尺寸。重复点击按钮会唤起已有桌面墙窗口,最小化时恢复显示,不创建重复窗口。每次启动软件只显示连接窗口;桌面墙首次打开时恢复保存的位置、尺寸及全屏状态;没有已保存尺寸时默认采用 960 × 720 逻辑像素(含标题栏,当前 macOS 的内容区为 960 × 688),两个窗口分别记忆位置。关闭桌面墙时释放远程操控并断开全部远程设备连接,不受当前分组、分页和选中状态限制,共用连接的独立串流窗口也随之关闭;同时取消排队连接、密码重试和待重连请求。设备列表、分组与窗口布局保留,再次点击“桌面墙”可打开并手动重新连接设备。最小化窗口不会断开连接。已断开的显示器所对应的位置会修正到可用屏幕内。
桌面墙内容区不再重复显示“桌面墙”标题,顶部仅保留一行工具栏,距窗口内容区顶部 12 逻辑像素;分组、设备数量、“添加”、“选择/完成”、纯文字“精简模式”按钮和全屏图标共用这一行,不显示菜单及消息、更新角标。“添加”、“选择”和“精简模式”默认统一使用浅灰背景、灰色边框及相同的悬停、按下样式;进入选择或精简模式后,对应按钮以蓝色高亮表示当前状态。按钮按当前语言的文字宽度加统一左右内边距自适应,普通文字按钮宽度至少为 48 逻辑像素;内边距默认为 12 逻辑像素,窗口内容区宽度小于 720 时统一收紧为 8,保证最窄窗口下的多语言操作和已选数量可见。下拉按钮额外预留箭头及间距;“选择/完成”和“精简模式/退出精简”按两种状态中较宽的文字预留空间,切换时保持宽度稳定。“选择/完成”固定在视图控制按钮左侧,设备列表紧接工具栏下方,释放原有标题行占用的空间。桌面墙可以单独调整尺寸及全屏,不改变连接窗口的尺寸和位置。全局设置从主窗口菜单进入;设备操作和桌面墙连接的密码弹窗显示在桌面墙窗口内。
+90 -27
View File
@@ -4,49 +4,51 @@
## 来源 / Sources
- 桌面与 Web 首页截图生成日期:2026-09-25;`fixtures/source.png` 和 `fixtures/website.png` 保留 2026-09-07 的公开页面截图。
- 桌面 UI 源代码:`f73a57ba` 加本次工具栏关闭状态斜杠渲染修复,Slint 1.17.1,macOS arm64;本次扩展截图工具以捕获会话画面设置,并等待窗口完成尺寸与缩放初始化后导出。
- 桌面图片来自 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp) 的 UI 捕获模式,直接渲染仓库中的 Slint 组件;不是旧版本截图或重新绘制的界面。
- 主窗口展示“已连接服务器”和三台在线设备的完整示例状态。ID `123 456 789`、密码 `123456`、连接状态及设备记录均为演示数据,不是真实设备凭据或真实远程会话。示例设置不代表应用运行时默认值。
- 本次主窗口截图包含随机密码刷新按钮;设置截图包含强制中继与自动隐私屏选项;自托管弹窗仅显示服务器地址和信令端口。画质、帧率和画面偏好在会话工具栏的“画面设置”菜单中展示。
- 会话截图使用 `fixtures/website.png` 作为示例远端画面,展示真实工具栏和打开的画面设置菜单,不建立远程连接。“关于”截图中的 `v1.5.2` 和“已是最新版本”均为演示数据,不代表对线上最新版本的检查结果。
- 会话工具栏的隐私屏按钮展示“已关闭”状态。关闭标记使用直接绘制的斜线路径,避免软件渲染器将旋转矩形显示为竖线。
- 设备缩略图使用可公开的页面截图:`fixtures/source.png` 来自 [CrossDesk 的 Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),第三张复用 `web-client.png`。它们用于展示缩略图效果,不代表这些页面正在对应的远程电脑上运行。
- `web-client.png`:2026-09-25 在 [web.crossdesk.cn](https://web.crossdesk.cn/) 重新截取的连接首页,页面显示版本 `2026.09.24.2`,视口为 1280 × 720。ID 与密码输入框为空,没有发起远程连接;使用该图片作为设备缩略图的桌面截图也已重新生成。
- 桌面截图更新于 **2026-10-14**,UI 源代码为 `e76d2bf9`,使用 Slint 1.17.1、macOS arm64 和软件渲染器。图片直接渲染仓库中的 Slint 组件。
- 主窗口、常规设置、桌面墙设置、自托管、关于与会话截图来自现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp) 的捕获模式。主窗口展示左上角桌面墙入口、双卡片连接区和近期连接分组筛选;设置展示“常规 / 桌面墙”选项卡与新的分组布局。
- 桌面墙网格与放大图复用同一捕获代码,在**工程外的临时目录**中复制源文件,仅调整演示设备数量、单元格宽度和网格窗口高度。Slint 组件没有改动,工程内没有新增或修改测试文件。网格展示六台演示设备;放大图展示只读预览、群控和接管入口,没有建立真实连接。
- 本机 ID `123 456 789`、密码 `123456`、设备名称、在线 / 连接状态及记录均为演示数据。主窗口展示三台设备,桌面墙展示六台;它们不是真实设备凭据或真实远程会话。示例配置不代表所有运行时默认值。
- “关于”中的 `v1.5.2` 和“已是最新版本”是捕获工具的示例值,不代表当前发布版本或实际在线检查结果。会话图使用公开页面作为远端画面,展示真实工具栏与画面设置菜单;隐私屏按钮处于关闭状态。
- 缩略图沿用既有公开页面截图:`fixtures/source.png` 来自 [CrossDesk Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),两者采集于 2026-09-07。它们只用于展示预览效果,不代表这些页面正在对应设备上运行,页面中的版本信息也不代表最新发布。
- `web-client.png` 保留 **2026-09-25** 从 [Web 客户端](https://web.crossdesk.cn/) 采集的 1280 × 720 连接首页,页面版本为 `2026.09.24.2`,ID 与密码为空。本次未重新采集 Web 页面;桌面设备预览继续复用这张图片。
Desktop images were refreshed on 2026-09-25 from UI source `f73a57ba`, using Slint 1.17.1 on macOS arm64. They render actual components with demonstration data, including the refreshed password controls, client settings, live video menu, and manual update check. The source-code and website fixtures remain public page captures from 2026-09-07. The Web connection page was recaptured on 2026-09-25 at 1280 × 720, showing version `2026.09.24.2` with empty credential fields; no remote connection was initiated. Desktop images using its thumbnail were regenerated as well. Desktop IDs, device names, settings, version numbers, and update results are illustrative. Native iOS screenshots are not included because this update did not capture a signed app running on a physical device.
The session images also include this change's toolbar slash-rendering fix. The privacy button shows the off state using diagonal paths that render correctly without rectangle rotation.
Desktop images were refreshed on **2026-10-14** from UI source `e76d2bf9`, using Slint 1.17.1 on macOS arm64. They render the actual components with demonstration data. DeskHub images use an external temporary copy of the existing capture program with six sample devices, 300 logical-pixel cells, and a shorter grid window; no UI components or repository tests were changed. IDs, credentials, connection states, versions, and update results are illustrative. No remote sessions were opened. Public-page fixtures remain from 2026-09-07, and the Web screenshot remains from 2026-09-25. This refresh does not include native mobile screenshots.
## 文件 / Files
| 文件 | 页面 | 语言 |
| 文件(`zh` / `en`) | 页面 | PNG 尺寸 |
| --- | --- | --- |
| `desktop-main-zh.png` / `desktop-main-en.png` | Main window / 主窗口 | 中文 / English |
| `desktop-settings-zh.png` / `desktop-settings-en.png` | Settings / 设置 | 中文 / English |
| `desktop-self-hosted-zh.png` / `desktop-self-hosted-en.png` | Self-hosting / 自托管设置 | 中文 / English |
| `desktop-stream-video-settings-zh.png` / `desktop-stream-video-settings-en.png` | Session toolbar and video settings / 会话工具栏与画面设置 | 中文 / English |
| `desktop-about-zh.png` / `desktop-about-en.png` | About and manual update check / 关于与检查更新 | 中文 / English |
| `web-client.png` | Web connection page / Web 连接首页 | 中文 |
| `desktop-main-*.png` | 主窗口 / Main window | 1280 × 944 |
| `desktop-deskhub-*.png`(不含 `focus`) | 六设备桌面墙 / Six-device DeskHub grid | 1920 × 1000 |
| `desktop-deskhub-focus-*.png` | 桌面墙放大视图 / Enlarged DeskHub view | 1920 × 1396 |
| `desktop-settings-*.png`(不含 `deskhub`) | 常规设置 / General settings | 1280 × 944 |
| `desktop-settings-deskhub-*.png` | 桌面墙传输设置 / DeskHub video settings | 1280 × 944 |
| `desktop-self-hosted-*.png` | 自托管设置 / Self-hosting | 1280 × 944 |
| `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 |
| `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 |
| `web-client.png`(中文) | Web 连接首页 / Web connection page | 1280 × 720 |
主窗口及其弹窗图片为 1280 × 900 PNG,会话图片为 1920 × 1200 PNG,均使用 2× 原生渲染缩放保持文字清晰,未放大旧图。Markdown 只设置展示宽度,保持原始比例。设置列表可滚动,截图仅展示首屏。演示配置显示硬件编解码器可用;实际可用性由平台、设备与构建决定。Windows 专属的采集方式与便携版服务入口不会出现在这些 macOS 截图中。
桌面图片使用 2× 原生渲染缩放,捕获窗口内容区,不包含 macOS 原生标题栏。未放大旧 UI 截图,也未重绘控件;演示远端画面由实际组件按比例显示。Markdown 只设置展示宽度。常规设置可滚动,截图仅展示首屏;Windows 专属采集方式与便携版服务入口不会出现在这些 macOS 图片中。
## 重新生成 / Regenerate
更新 Web 图片时,在浏览器中打开 [web.crossdesk.cn](https://web.crossdesk.cn/),等待连接表单和底部版本号加载完成,保持 ID 与密码为空,截取页面视口。当前图片使用 1280 × 720 视口;浏览器导出的 JPEG 仅转换为 PNG,未修改页面布局或内容。替换 `web-client.png` 后,重新生成下方桌面截图以同步设备卡片缩略图,并更新本页的日期与页面版本号。
以下命令在仓库根目录、已配置依赖的 macOS 开发环境执行。所有临时源码、PPM 和额外捕获程序均写入系统临时目录,最终 PNG 才写入 `docs/images`。`sips` 仅做 PPM 到 PNG 的无损格式转换,其他平台可使用支持 PPM 的转换工具。
以下命令在仓库根目录、已完成依赖配置的 macOS 开发环境运行。`sips` 仅将 Slint 导出的 PPM 无损转换为 PNG,不修改 UI。其他平台可以使用支持 PPM 的图片格式转换工具。
先检查 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language`:旧覆盖文件存在且非空时会优先于环境变量,应确认并移开后再捕获。`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须包含上述公开页面图片,缺失图片会导致捕获失败。工具等待窗口尺寸与缩放初始化后再截图。
### 主窗口、设置与会话
```bash
set -e
xmake b -y slint_ui_smoke_test
mkdir -p build/readme-capture docs/images
capture_dir=$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-readme-capture.XXXXXX")
export capture_dir
for locale in zh en; do
language=0
if [ "$locale" = en ]; then language=1; fi
for page in main settings self-hosted about stream-video-settings; do
snapshot="$PWD/build/readme-capture/desktop-$page-$locale.ppm"
for page in main settings settings-deskhub self-hosted about stream-video-settings; do
snapshot="$capture_dir/desktop-$page-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
@@ -60,6 +62,67 @@ for locale in zh en; do
done
```
`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须指向包含上述图片的目录。缺失图片会让捕获命令失败,避免再次导出空白缩略图;不设置该变量时,仍保留原有测试状态。捕获工具会先运行窗口事件循环,等待尺寸与缩放初始化;直接在 `show()` 后截图可能得到 1× 图片。
### 六设备桌面墙
捕获模式兼容 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language` 两个旧覆盖文件;如存在,它们会优先于环境变量,运行前请检查。更新后逐张检查文字、布局、敏感信息和文档引用,并同步修改本页日期与源码版本。
现有捕获模式的 `gallery` / `gallery-focus` 默认使用三台设备。要复现本页的六设备图,在上一步同一 shell 中创建临时副本,只修改演示场景:
```bash
python3 - <<'PY'
import os
from pathlib import Path
source = Path('apps/desktop/tests/slint_ui_smoke_test.cpp').read_text()
changes = [
('options.page != "main-recent-scrolled") {',
'options.page != "main-recent-scrolled" &&\n'
' !options.page.starts_with("gallery")) {'),
('window->set_gallery_mode(true);\n std::vector',
'window->set_gallery_mode(true);\n'
' window->set_gallery_tile_width(300);\n std::vector'),
('698.0f;\n#else\n 720.0f;',
'options.page == "gallery" ? 500.0f : 698.0f;\n#else\n'
' options.page == "gallery" ? 500.0f : 720.0f;'),
]
for old, new in changes:
assert source.count(old) == 1, 'Capture source changed; review the fixture adjustment.'
source = source.replace(old, new, 1)
Path(os.environ['capture_dir'], 'readme_capture.cpp').write_text(source)
PY
```
以下编译路径对应本次的 `macosx / arm64 / debug` 配置。其他配置先用 `xmake show -t slint_ui_smoke_test` 查看当前生成头文件和库目录,再替换路径;不要在工程内添加临时构建入口。
```bash
slint_prefix=$(xmake lua -c 'import("core.project.config"); config.load(); import("core.project.project"); print(project.target("slint_ui_smoke_test"):pkg("slint"):installdir())')
xcrun clang++ -std=c++20 \
-Iapps/desktop/src/gui \
-Iapps/desktop/src/gui/assets/fonts \
-Iapps/desktop/src/gui/assets/localization \
-Ibuild/.gens/crossdesk_ui/macosx/arm64/debug/rules/slint \
-isystem "$slint_prefix/include/slint" \
"$capture_dir/readme_capture.cpp" \
build/macosx/arm64/debug/libcrossdesk_ui.a \
-L"$slint_prefix/lib" -lslint_cpp -Wl,-rpath,"$slint_prefix/lib" \
-o "$capture_dir/readme_capture"
for locale in zh en; do
language=0
if [ "$locale" = en ]; then language=1; fi
for page in gallery gallery-focus; do
name=deskhub
if [ "$page" = gallery-focus ]; then name=deskhub-focus; fi
snapshot="$capture_dir/desktop-$name-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
CROSSDESK_UI_CAPTURE_PAGE="$page" \
CROSSDESK_UI_CAPTURE_LANGUAGE="$language" \
CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \
"$capture_dir/readme_capture"
sips -s format png "$snapshot" \
--out "docs/images/desktop-$name-$locale.png"
done
done
```
更新后逐张检查文字、布局、演示数据和文档引用,并同步日期、源码版本与图片尺寸。若更新 Web 图片,应打开 Web 客户端,等待表单与底部版本号加载完成,保持 ID 与密码为空,再截取页面视口;替换后也需重新生成桌面图以同步缩略图。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

After

Width:  |  Height:  |  Size: 362 KiB