diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 7188a73c..5cc200e8 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -5229,7 +5229,7 @@ void GuiApplication::SyncGallery() { std::max(1, int((ui_->main->get_gallery_height() - 114) / 144)); wall.ResizePage(columns * rows, ids.size()); // Resizing/fullscreen changes page capacity. Keep the focused device in - // the page model so the rail can still display and reveal its current card. + // the page model so the rail shows the other devices on the same page. if (!ui_->gallery_focus.empty()) { const auto focused = std::find(ids.begin(), ids.end(), ui_->gallery_focus); if (focused != ids.end()) diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index 5f4a7f64..8a781b0f 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -31,27 +31,29 @@ export component GalleryView inherits Rectangle { in property controlling: false; in property simplified: false; private property grid-gap: root.simplified ? 6px : 12px; + private property rail-card-height: root.simplified ? 92px : 76px; + private property focused-id: root.focused.id; + private property rail-tooltip-id; + private property rail-tooltip-text; + private property rail-tooltip-x; + private property rail-tooltip-visible: false; + changed focused-id => { root.rail-tooltip-id = ""; } + changed visible => { root.rail-tooltip-id = ""; } + changed rail-tooltip-id => { + root.rail-tooltip-visible = false; + rail-tooltip-timer.running = root.rail-tooltip-id != ""; + if root.rail-tooltip-id != "" { rail-tooltip-timer.restart(); } + } + rail-tooltip-timer := Timer { + interval: 450ms; running: false; + triggered => { + self.running = false; + root.rail-tooltip-visible = root.rail-tooltip-id != ""; + } + } // Pointer mapping must use the exact image viewport, including its inset. out property video-width: video.width / 1px; out property video-height: video.height / 1px; - private property vertical-rail: focus-view.width > 840px; - private property rail-current-index: 0; - changed rail-current-index => { root.reveal-current(); } - changed width => { root.reveal-current(); } - changed height => { root.reveal-current(); } - function reveal-current() { - if root.vertical-rail { - rail.viewport-x = 0px; - let top = root.rail-current-index * 100px; - rail.viewport-y = clamp(rail.viewport-y, -top, rail.height - top - 92px); - rail.viewport-y = clamp(rail.viewport-y, min(0px, rail.height - rail.viewport-height), 0px); - } else { - rail.viewport-y = 0px; - let left = root.rail-current-index * 144px; - rail.viewport-x = clamp(rail.viewport-x, -left, rail.width - left - 136px); - rail.viewport-x = clamp(rail.viewport-x, min(0px, rail.width - rail.viewport-width), 0px); - } - } callback pointer-input(PointerEventButton, PointerEventKind, length, length); callback scroll-input(length, length, length, length); callback key-input(string, bool, bool, bool, bool, bool); @@ -203,8 +205,8 @@ 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: 36px; width: root.vertical-rail ? parent.width - 158px : parent.width; - height: root.vertical-rail ? parent.height - 36px : parent.height - 152px; + x: 0px; y: rail.visible ? rail.y + rail.height + 10px : 36px; + width: parent.width; height: parent.height - self.y; background: #eff1f4; border-radius: 6px; border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : #d5dbe3; video := Rectangle { @@ -247,45 +249,84 @@ export component GalleryView inherits Rectangle { } } rail := ScrollView { - x: root.vertical-rail ? parent.width - 148px : 0px; - y: root.vertical-rail ? 36px : parent.height - 106px; - width: root.vertical-rail ? 148px : parent.width; - height: root.vertical-rail ? parent.height - 36px : 106px; - // Reserve 14px below or 12px beside cards for the scrollbar. - viewport-width: root.vertical-rail ? self.width : max(self.width, root.tiles.length * 144px - 8px); - viewport-height: root.vertical-rail ? max(self.height, root.tiles.length * 100px - 8px) : self.height; - horizontal-scrollbar-policy: root.vertical-rail ? always-off : as-needed; - vertical-scrollbar-policy: root.vertical-rail ? as-needed : always-off; - for tile[index] in root.tiles: Rectangle { - private property current: tile.id == root.focused.id; - init => { if self.current { root.rail-current-index = index; root.reveal-current(); } } - changed current => { if self.current { root.rail-current-index = index; root.reveal-current(); } } - x: root.vertical-rail ? 0px : index * 144px; - y: root.vertical-rail ? index * 100px : 0px; - width: 136px; height: 92px; border-radius: 5px; - background: tile.id == root.focused.id ? #edf5ff : #f5f6f8; - border-width: tile.id == root.focused.id ? 2px : 1px; - border-color: tile.id == root.focused.id ? #76aeea : #d5dbe3; - Rectangle { - x: 5px; y: 5px; width: 126px; height: root.simplified ? 82px : 52px; background: #24272d; - if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; } - if !root.simplified && (!tile.has-frame || tile.stale || !tile.connected): Text { - text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide; - horizontal-alignment: center; vertical-alignment: center; + visible: root.tiles.length > 1; + 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-height: self.height; + horizontal-scrollbar-policy: as-needed; + vertical-scrollbar-policy: always-off; + changed width => { + root.rail-tooltip-id = ""; + self.viewport-x = clamp(self.viewport-x, min(0px, self.width - self.viewport-width), 0px); + } + changed viewport-width => { + root.rail-tooltip-id = ""; + self.viewport-x = clamp(self.viewport-x, min(0px, self.width - self.viewport-width), 0px); + } + changed viewport-x => { root.rail-tooltip-id = ""; } + HorizontalLayout { + 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; + height: root.rail-card-height; visible: tile.id != root.focused.id; + Rectangle { + private property presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") + : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); + private property details: tile.name + "\nID: " + tile.id + "\n" + self.presence; + width: 136px; height: root.rail-card-height; border-radius: 5px; + background: #f5f6f8; border-width: 1px; border-color: #d5dbe3; + accessible-role: button; + accessible-label: self.details; + accessible-action-default => { root.action("focus", tile.id); } + changed details => { if rail-touch.has-hover { rail-touch.show-details(); } } + Rectangle { + x: 5px; y: 5px; width: 126px; height: root.simplified ? 82px : 52px; background: #24272d; + if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; } + if !root.simplified && (!tile.has-frame || tile.stale || !tile.connected): Text { + text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide; + horizontal-alignment: center; vertical-alignment: center; + } + } + Rectangle { + visible: !root.simplified; + x: 7px; y: 63px; width: 6px; height: 6px; border-radius: 3px; + background: tile.online ? #17854c : tile.presence-known ? #8a939e : transparent; + border-width: 1px; border-color: tile.online ? #17854c : #8a939e; + } + Text { + visible: !root.simplified; + x: 18px; y: 58px; width: 111px; height: 16px; + text: tile.name; color: #303943; font-size: 11px; + overflow: elide; vertical-alignment: center; + } + rail-touch := TouchArea { + function show-details() { + root.rail-tooltip-id = tile.id; + root.rail-tooltip-text = parent.details; + root.rail-tooltip-x = rail.x + rail.viewport-x + rail-slot.x; + } + changed has-hover => { + if self.has-hover { self.show-details(); } + else if root.rail-tooltip-id == tile.id { root.rail-tooltip-id = ""; } + } + moved => { if self.has-hover { self.show-details(); } } + clicked => { root.rail-tooltip-id = ""; root.action("focus", tile.id); } + } } } - Text { visible: !root.simplified; x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; } - Text { - visible: !root.simplified; - x: 7px; y: 78px; width: 86px; height: 12px; font-size: 9px; - text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); - color: tile.online ? #17854c : #7a828d; overflow: elide; - } - if !root.simplified && tile.id == root.focused.id: Text { - x: 99px; y: 78px; width: 30px; height: 12px; - text: GalleryStrings.tr("当前", "Current", "Выбрано"); color: #4d83bd; font-size: 9px; horizontal-alignment: right; overflow: elide; - } - TouchArea { clicked => { root.action("focus", tile.id); } } + } + } + // Keep full device details outside the clipped thumbnail rail. + if root.rail-tooltip-visible && rail.visible && root.rail-tooltip-id != "" && root.rail-tooltip-id != root.focused.id: Rectangle { + x: clamp(root.rail-tooltip-x, 0px, parent.width - self.width); + y: rail.y + rail.height + 4px; + width: min(320px, parent.width); height: rail-tooltip-label.preferred-height + 16px; + background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: 5px; + rail-tooltip-label := Text { + x: 8px; y: 8px; width: parent.width - 16px; + text: root.rail-tooltip-text; color: white; font-size: 11px; wrap: word-wrap; } } } diff --git a/docs/deskhub.md b/docs/deskhub.md index 72e8926c..9d35353d 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -23,7 +23,7 @@ - 顶部“网络状态”按钮打开当前已建立连接的设备列表,不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 - 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 -- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;紧凑窗口的设备缩略图位于下方,宽窗口移到右侧。缩略图显示设备名、在线状态和当前设备标记,缺少画面或画面停滞时有状态占位;滚动条单独留出空间。调整尺寸后当前设备仍在列表中,选中时自动滚入可见区域。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 +- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示状态圆点和设备名:绿色为在线、灰色为离线、空心灰色为未知,长名称省略;悬停可查看完整名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通模式缩略图栏高 90px,在保留画面大小的同时将空间留给主预览。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 - 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。 - 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/静音/分发文件及分页控件在同一底栏显示。声音按钮根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。