From 61ff9b278796fb7e4a091eea9728805ecd413497 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 14 Oct 2026 04:17:31 +0800 Subject: [PATCH] [feat] add resizable DeskHub focus previews --- .../src/gui/application/gui_application.cpp | 4 + apps/desktop/src/gui/ui/gallery_view.slint | 110 +++++++++++++++--- apps/desktop/src/gui/ui/main_window.slint | 4 +- docs/deskhub.md | 2 +- 4 files changed, 101 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 55e9f9bb..cbcedb1f 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1278,6 +1278,9 @@ void GuiApplication::InitializeUi() { if (std::isfinite(tile_width)) // The rendered grid constrains the restored width to available space. ui_->wall->set_gallery_tile_width(std::max(tile_width, 184.0f)); + const float focus_rail_height = saved.value("focus_rail_height", 70.875f); + if (std::isfinite(focus_rail_height)) + ui_->wall->set_gallery_focus_rail_height(std::max(focus_rail_height, 54.0f)); ui_->connection_window.BeginRestore(); } } catch (...) { @@ -4526,6 +4529,7 @@ void GuiApplication::SaveGalleryWindowState() { const float connection_scale = std::max(ui_->main->window().scale_factor(), 1.0f); nlohmann::json state{{"version", 2}, {"tile_width", ui_->wall->get_gallery_tile_width()}, + {"focus_rail_height", ui_->wall->get_gallery_focus_rail_height()}, {"fullscreen", ui_->gallery_window.wall_fullscreen}, {"maximized", ui_->gallery_window.wall_maximized}}; if (ui_->gallery_window.wall_size) { diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index dc8a1281..8dbc5e3f 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -55,6 +55,8 @@ export component GalleryView inherits Rectangle { in property window-active: true; in-out property tile-width: 216; out property resizing: false; + in-out property focus-rail-height: 70.875; + out property focus-resizing: false; private property preview-aspect-ratio: 16 / 9; private property card-inset: 6px; private property card-chrome-height: root.simplified ? root.card-inset * 2 : 36px; @@ -64,9 +66,19 @@ export component GalleryView inherits Rectangle { private property card-width: clamp(root.tile-width * 1px, 184px, root.max-card-width); private property card-preview-height: (root.card-width - root.card-inset * 2) / root.preview-aspect-ratio; private property card-height: root.card-preview-height + root.card-chrome-height; - private property rail-preview-width: 126px; - private property rail-preview-height: root.rail-preview-width / root.preview-aspect-ratio; - private property rail-card-height: root.rail-preview-height + (root.simplified ? 10px : 24px); + private property focus-rail-visible: root.focused.id != "" && root.tiles.length > 1; + private property rail-chrome-height: root.simplified ? 10px : 24px; + private property min-rail-preview-height: 54px; + // Leave at least 160px for the main preview and keep each thumbnail inside + // the window. Clamp the rendered size without losing the saved preference. + private property max-rail-preview-height: max(root.min-rail-preview-height, + min((focus-view.width - 10px) / root.preview-aspect-ratio, + focus-view.height - 36px - root.rail-chrome-height - 14px - 12px - 160px)); + private property rail-preview-height: clamp(root.focus-rail-height * 1px, + root.min-rail-preview-height, root.max-rail-preview-height); + private property rail-preview-width: root.rail-preview-height * root.preview-aspect-ratio; + private property rail-card-height: root.rail-preview-height + root.rail-chrome-height; + private property rail-slot-width: root.rail-preview-width + 18px; private property focused-id: root.focused.id; private property device-tooltip-id; private property device-tooltip-text; @@ -89,19 +101,20 @@ export component GalleryView inherits Rectangle { private property drag-edge-width: 32px; private property drag-page-direction: root.page-direction(root.drag-x, root.drag-y); private property swap-save-failed: false; - changed focused-id => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } - changed visible => { root.device-tooltip-id = ""; if !root.visible { root.resizing = false; root.cancel-device-drag(); add-menu.close-menu(); batch-menu.close-menu(); } } - changed width => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } - changed height => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } - changed window-active => { if !root.window-active { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } } + changed focused-id => { root.device-tooltip-id = ""; root.resizing = false; root.focus-resizing = false; root.cancel-device-drag(); } + changed focus-rail-visible => { root.focus-resizing = false; } + changed visible => { root.device-tooltip-id = ""; if !root.visible { root.resizing = false; root.focus-resizing = false; root.cancel-device-drag(); add-menu.close-menu(); batch-menu.close-menu(); } } + changed width => { root.device-tooltip-id = ""; root.resizing = false; root.focus-resizing = false; root.cancel-device-drag(); } + changed height => { root.device-tooltip-id = ""; root.resizing = false; root.focus-resizing = false; root.cancel-device-drag(); } + changed window-active => { if !root.window-active { root.device-tooltip-id = ""; root.resizing = false; root.focus-resizing = false; root.cancel-device-drag(); } } changed page => { root.device-tooltip-id = ""; if root.page != root.drag-page { root.cancel-device-drag(); } } changed group-index => { root.device-tooltip-id = ""; root.cancel-device-drag(); } changed card-width => { root.cancel-device-drag(); } changed card-height => { root.cancel-device-drag(); } changed device-tooltip-id => { root.device-tooltip-visible = false; - device-tooltip-timer.running = root.device-tooltip-id == "resize-grid"; - if root.device-tooltip-id == "resize-grid" { device-tooltip-timer.restart(); } + device-tooltip-timer.running = root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"; + if device-tooltip-timer.running { device-tooltip-timer.restart(); } } device-tooltip-timer := Timer { interval: 450ms; running: false; @@ -168,6 +181,7 @@ export component GalleryView inherits Rectangle { } public function reset-transient() { root.resizing = false; + root.focus-resizing = false; root.cancel-device-drag(); root.batch = false; root.adding = false; root.importing = false; root.confirming = false; root.device-tooltip-id = ""; add-menu.close-menu(); batch-menu.close-menu(); @@ -561,7 +575,7 @@ export component GalleryView inherits Rectangle { 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); } } } Rectangle { - x: 0px; y: rail.visible ? rail.y + rail.height + 10px : 36px; + 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; border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : root.focused.online ? #d5dbe3 : DeviceCardStyle.offline-border; @@ -607,10 +621,10 @@ export component GalleryView inherits Rectangle { } } rail := ScrollView { - visible: root.tiles.length > 1; + visible: root.focus-rail-visible; x: 0px; y: 36px; width: parent.width; height: root.rail-card-height + 14px; // Reserve 14px below the cards for the horizontal scrollbar. - viewport-width: max(self.width, max(0, root.tiles.length - 1) * 144px); + viewport-width: max(self.width, max(0, root.tiles.length - 1) * root.rail-slot-width); viewport-height: self.height; horizontal-scrollbar-policy: as-needed; vertical-scrollbar-policy: always-off; @@ -627,13 +641,13 @@ export component GalleryView inherits Rectangle { x: 0px; y: 0px; height: root.rail-card-height; spacing: 0px; padding: 0px; for tile in root.tiles: rail-slot := Rectangle { // Collapse the focused tile and its gap without changing the page model. - width: tile.id == root.focused.id ? 0px : 144px; + width: tile.id == root.focused.id ? 0px : root.rail-slot-width; height: root.rail-card-height; visible: tile.id != root.focused.id; rail-card := Rectangle { private property presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); private property details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence; - width: 136px; height: root.rail-card-height; border-radius: 5px; + width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: 5px; 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; @@ -649,7 +663,7 @@ export component GalleryView inherits Rectangle { } Text { visible: !root.simplified; - x: 7px; y: root.rail-preview-height + 6px; width: 122px; height: 16px; + x: 7px; y: root.rail-preview-height + 6px; width: parent.width - 14px; height: 16px; text: tile.name; color: tile.online ? #303943 : DeviceCardStyle.offline-text; font-size: 11px; overflow: elide; vertical-alignment: center; } @@ -670,6 +684,68 @@ export component GalleryView inherits Rectangle { } } } + focus-divider := Rectangle { + visible: root.focus-rail-visible; + x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px; + private property highlighted: divider-touch.has-hover || root.focus-resizing || divider-focus.has-focus; + Rectangle { + x: 0px; y: 5px; width: parent.width; height: 1px; + background: focus-divider.highlighted ? #a6c9ed : #e6e9ed; + } + Rectangle { + x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px; + background: focus-divider.highlighted ? #6dabea : #bcc4ce; + } + divider-focus := FocusScope { + width: parent.width; height: parent.height; + focus-lost => { root.focus-resizing = false; } + key-pressed(event) => { + if event.text == Key.Escape { root.focus-resizing = false; return accept; } + if event.text == Key.UpArrow || event.text == Key.DownArrow { + root.focus-rail-height = clamp(root.rail-preview-height + + (event.text == Key.UpArrow ? -8px : 8px), + root.min-rail-preview-height, root.max-rail-preview-height) / 1px; + return accept; + } + reject + } + divider-touch := TouchArea { + mouse-cursor: MouseCursor.ns-resize; + private property start-y; + private property start-height; + function show-hint() { + if self.has-hover && !root.focus-resizing { + root.device-tooltip-id = "resize-focus"; + root.device-tooltip-text = GalleryStrings.tr("上下拖动,调整缩略图与主画面大小", "Drag up or down to resize the thumbnails and main preview", "Потяните вверх или вниз, чтобы изменить размер миниатюр и основного экрана"); + root.device-tooltip-x = focus-view.x + self.mouse-x; + root.device-tooltip-y = focus-view.y + focus-divider.y + self.mouse-y + 16px; + } else if root.device-tooltip-id == "resize-focus" { root.device-tooltip-id = ""; } + } + function resize-preview() { + if !root.focus-resizing { return; } + // Use coordinates in the fixed parent: the divider itself + // moves while it retains the pointer grab. + root.focus-rail-height = clamp(self.start-height + + focus-divider.y + self.mouse-y - self.start-y, + root.min-rail-preview-height, root.max-rail-preview-height) / 1px; + } + changed has-hover => { self.show-hint(); } + pointer-event(event) => { + if event.button == PointerEventButton.left && event.kind == PointerEventKind.down { + divider-focus.focus(); + self.start-y = focus-divider.y + self.mouse-y; + self.start-height = root.rail-preview-height; + root.device-tooltip-id = ""; root.focus-resizing = true; + } else if event.kind == PointerEventKind.cancel { + root.focus-resizing = false; + } else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up { + self.resize-preview(); root.focus-resizing = false; + } + } + moved => { self.resize-preview(); self.show-hint(); } + } + } + } } Rectangle { y: parent.height - 44px; height: 44px; background: #fafbfc; @@ -766,7 +842,7 @@ export component GalleryView inherits Rectangle { } } // Device details are rendered by MainWindow; resize hints stay local. - if root.device-tooltip-visible && root.device-tooltip-id == "resize-grid": Rectangle { + if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): 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; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 096240cc..cee42ed7 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -322,7 +322,8 @@ export component MainWindow inherits Window { out property gallery-columns: gallery.columns; out property gallery-rows: gallery.rows; in-out property gallery-tile-width: 216; - out property gallery-resizing: gallery.resizing; + in-out property gallery-focus-rail-height: 70.875; + out property gallery-resizing: gallery.resizing || gallery.focus-resizing; in property gallery-page: 0; in property gallery-pages: 1; in property gallery-count: 0; @@ -1317,6 +1318,7 @@ export component MainWindow inherits Window { toggle-fullscreen => { root.toggle-gallery-fullscreen(); } window-active: root.window-active; tile-width <=> root.gallery-tile-width; + focus-rail-height <=> root.gallery-focus-rail-height; network-open => { root.gallery-network-open = true; } controlling: root.gallery-controlling; input-suspended: root.gallery-transitioning; diff --git a/docs/deskhub.md b/docs/deskhub.md index 636fd99d..f11406cb 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -27,7 +27,7 @@ - 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 - 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 -- 放大视图使用一行设备操作栏,显示返回、设备名称、状态和接管/释放操控按钮,右侧保留精简模式及全屏入口;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 +- 放大视图使用一行设备操作栏,显示返回、设备名称、状态和接管/释放操控按钮,右侧保留精简模式及全屏入口;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,上下区域之间提供带抓手的分隔条:向上拖动扩大下方主画面,向下拖动同步放大上方缩略图;选中分隔条后也可用上下方向键微调。缩略图始终保持 16:9,并保留主画面的最小可用高度;窗口缩小时临时限制尺寸,恢复窗口后沿用原先尺寸。普通和精简模式共用此尺寸,切换设备后保持,松开鼠标后保存并在重启时恢复;与网格的单元格尺寸独立。同页只有一台设备时同时隐藏缩略图栏和分隔条。拖动分隔条不会切换设备或发送远程键鼠,Esc、窗口失焦及窗口大小变化会结束拖动。调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 - 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。 - 点击“选择”进入批量模式,“完成”在同一位置退出;顶部设备管理栏原位切换为批量操作栏,从左到右显示已选数量(包含跨页数量)、“全选/取消全选”、“连接”、“断开”、“更多”和“完成”,无需往返底栏。批量模式暂时收起分组筛选和“添加”入口,退出后恢复;工具栏高度不变,设备卡片的位置、尺寸及分页容量保持不变。已选数量占用剩余空间,批量操作按钮同样按文字宽度和统一内边距自适应;“全选/取消全选”使用与“断开”、“更多”一致的浅灰背景和边框,并预留两种文字中较宽的一种,切换选中数量或按钮文字时,后续按钮保持原位。“连接”保留主操作的蓝色强调,“完成”以蓝色表示当前选择模式。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;底栏始终保留已连接数量、网络状态入口和分页;静音和文件分发收进顶部向下展开的“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。