diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index c1cfb567..73c3eae1 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -349,17 +349,18 @@ export component CompactButton inherits Rectangle { in property text; in property text-size: ImGuiFontStyle.body; in property primary: false; + in property quiet: false; in property enabled: true; callback clicked; width: 34px; height: 24px; - background: !root.enabled ? #eceef0 + background: !root.enabled ? (root.quiet ? transparent : #eceef0) : button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd) : button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2) - : root.primary ? #a8d0fa : #e2e4e7; + : root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7; border-width: 1px; - border-color: root.primary ? #9bc3ed : #c8cbd0; + border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0; border-radius: 3px; Text { text: root.text; diff --git a/apps/desktop/src/gui/ui/device_card_actions.slint b/apps/desktop/src/gui/ui/device_card_actions.slint index 3c9970cf..c63ce44b 100644 --- a/apps/desktop/src/gui/ui/device_card_actions.slint +++ b/apps/desktop/src/gui/ui/device_card_actions.slint @@ -3,6 +3,11 @@ import { GalleryStrings } from "device_strings.slint"; export global DeviceCardStyle { out property toolbar-width: 104px; + out property offline-preview: #626870; + out property offline-background: #e4e6e9; + out property offline-border: #c9cdd2; + out property offline-text: #68707a; + out property offline-placeholder: #eceef1; } component CardAction inherits IconButton { diff --git a/apps/desktop/src/gui/ui/gallery_menu_button.slint b/apps/desktop/src/gui/ui/gallery_menu_button.slint new file mode 100644 index 00000000..ffcddbe7 --- /dev/null +++ b/apps/desktop/src/gui/ui/gallery_menu_button.slint @@ -0,0 +1,108 @@ +import { CompactButton, FontAwesomeIcons } from "common.slint"; + +export struct GalleryMenuItem { label: string, enabled: bool } + +export component GalleryMenuButton inherits Rectangle { + in property text; + in property primary: false; + in property enabled: true; + in property opens-up: false; + in property menu-width: 200px; + in property <[GalleryMenuItem]> items; + private property current-index: 0; + callback opened; + callback selected(int); + + height: 24px; + forward-focus: trigger-focus; + accessible-role: button; + accessible-label: root.text; + accessible-enabled: root.enabled; + accessible-expandable: true; + accessible-expanded: popup.is-open; + accessible-action-default => { root.open-menu(); } + changed enabled => { if !root.enabled { popup.close(); } } + changed visible => { if !root.visible { popup.close(); } } + + public function close-menu() { popup.close(); } + function open-menu() { + if !root.enabled || root.items.length == 0 { return; } + if popup.is-open { popup.close(); return; } + root.opened(); + root.current-index = 0; + popup.show(); + } + function choose(index: int) { + if !root.enabled || index < 0 || index >= root.items.length || !root.items[index].enabled { return; } + popup.close(); + trigger-focus.focus(); + root.selected(index); + } + function handle-key(event: KeyEvent) -> EventResult { + if !root.enabled { return reject; } + if event.text == Key.Escape && popup.is-open { + popup.close(); trigger-focus.focus(); return accept; + } + if event.text == Key.Return || event.text == " " { + if popup.is-open { root.choose(root.current-index); } else { root.open-menu(); } + return accept; + } + if event.text == Key.UpArrow || event.text == Key.DownArrow { + if !popup.is-open { root.open-menu(); } + else { root.current-index = clamp(root.current-index + (event.text == Key.UpArrow ? -1 : 1), 0, root.items.length - 1); } + return accept; + } + return reject; + } + trigger-focus := FocusScope { + enabled: root.enabled; + key-pressed(event) => { return root.handle-key(event); } + CompactButton { + width: parent.width; height: parent.height; + enabled: root.enabled; primary: root.primary; + clicked => { trigger-focus.focus(); root.open-menu(); } + } + Text { + x: 6px; width: parent.width - 24px; height: parent.height; + text: root.text; color: root.enabled ? #24272d : #8e9298; + font-size: 11px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; + } + Text { + x: parent.width - 19px; width: 12px; height: parent.height; + text: root.opens-up ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down; + font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 9px; + color: root.enabled ? #62666d : #8e9298; + horizontal-alignment: center; vertical-alignment: center; + } + } + popup := PopupWindow { + x: root.width - self.width; + y: root.opens-up ? -self.height - 4px : root.height + 4px; + width: root.menu-width; height: root.items.length * 30px + 8px; + close-policy: close-on-click-outside; + forward-focus: popup-focus; + popup-focus := FocusScope { + key-pressed(event) => { return root.handle-key(event); } + Rectangle { + background: white; border-width: 1px; border-color: #c9cdd2; border-radius: 4px; + for item[index] in root.items: Rectangle { + x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px; + border-radius: 3px; + 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); } + Text { + x: 10px; width: parent.width - 20px; height: parent.height; + text: item.label; color: item.enabled ? #30343b : #92979f; + font-size: 12px; vertical-alignment: center; + } + TouchArea { + enabled: item.enabled; + changed has-hover => { if self.has-hover { root.current-index = index; } } + clicked => { root.choose(index); } + } + } + } + } + } +} diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index 8a781b0f..1d158ebf 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -1,5 +1,6 @@ import { GalleryStrings } from "device_strings.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 { CompactButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint"; @@ -27,28 +28,32 @@ export struct GalleryActionDevice { name: string, connected: bool, } + 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(); } + private property device-tooltip-id; + private property device-tooltip-text; + private property device-tooltip-x; + private property device-tooltip-y; + private property device-tooltip-visible: false; + changed focused-id => { root.device-tooltip-id = ""; } + changed visible => { root.device-tooltip-id = ""; if !root.visible { add-menu.close-menu(); batch-menu.close-menu(); } } + changed width => { root.device-tooltip-id = ""; } + changed height => { root.device-tooltip-id = ""; } + changed device-tooltip-id => { + root.device-tooltip-visible = false; + device-tooltip-timer.running = root.device-tooltip-id != ""; + if root.device-tooltip-id != "" { device-tooltip-timer.restart(); } } - rail-tooltip-timer := Timer { + device-tooltip-timer := Timer { interval: 450ms; running: false; triggered => { self.running = false; - root.rail-tooltip-visible = root.rail-tooltip-id != ""; + root.device-tooltip-visible = root.device-tooltip-id != ""; } } // Pointer mapping must use the exact image viewport, including its inset. @@ -66,6 +71,9 @@ export component GalleryView inherits Rectangle { in property pages: 1; in property count: 0; in property connected-count: 0; + private property connected-summary: root.connected-count + " / " + root.count + + GalleryStrings.tr(" 台已连接", " connected", " подключено"); + callback network-open; in property selected-count: 0; in property selected-audio-available: false; in property selected-audio-muted: false; @@ -91,7 +99,11 @@ export component GalleryView inherits Rectangle { background: white; changed adding => { if root.adding { root.action("release", ""); } } changed importing => { root.action(root.importing ? "release" : "recent-close", ""); } - public function reset-transient() { root.batch = false; root.adding = false; root.importing = false; root.confirming = false; } + public function reset-transient() { + root.batch = false; root.adding = false; root.importing = false; root.confirming = false; + root.device-tooltip-id = ""; add-menu.close-menu(); batch-menu.close-menu(); + } + changed batch => { root.device-tooltip-id = ""; batch-menu.close-menu(); } function confirm-action(code: string, name: string) { root.action-code = code; root.action-name = name; root.confirming = true; } @@ -102,49 +114,67 @@ export component GalleryView inherits Rectangle { GroupFilter { width: 155px; height: 24px; system-count: 1; model: root.groups; current-index <=> root.group-index; selected(index) => { root.action("group", index); } } + Text { + text: root.count + GalleryStrings.tr(" 台设备", " devices", " устройств"); + font-size: 11px; color: #858c96; vertical-alignment: center; + } Rectangle { horizontal-stretch: 1; } - CompactButton { width: 82px; primary: root.batch; text: root.batch ? GalleryStrings.tr("完成", "Done", "Готово") : GalleryStrings.tr("批量选择", "Select", "Выбрать"); clicked => { root.batch = !root.batch; root.action("selection", ""); } } - CompactButton { width: 116px; text: GalleryStrings.tr("从最近连接添加", "Add from recent", "Из недавних"); clicked => { root.action("recent-open", ""); root.importing = true; } } - CompactButton { width: 90px; primary: true; text: GalleryStrings.tr("添加设备", "Add device", "Добавить"); clicked => { root.action("add-open", ""); root.adding = true; } } + CompactButton { + width: 72px; quiet: true; primary: root.batch; + text: root.batch ? GalleryStrings.tr("完成", "Done", "Готово") : GalleryStrings.tr("选择", "Select", "Выбрать"); + clicked => { root.batch = !root.batch; root.action("selection", ""); } + } + add-menu := GalleryMenuButton { + width: 90px; primary: true; + text: GalleryStrings.tr("添加", "Add", "Добавить"); menu-width: 232px; + items: [ + {label: GalleryStrings.tr("通过设备 ID 添加", "Add by device ID", "Добавить по ID устройства"), enabled: true}, + {label: GalleryStrings.tr("从近期连接添加", "Add from recent", "Добавить из недавних"), enabled: true}, + ]; + opened => { root.device-tooltip-id = ""; root.action("release", ""); } + selected(index) => { + if index == 0 { root.action("add-open", ""); root.adding = true; } + else { root.action("recent-open", ""); root.importing = true; } + } + } } grid := Rectangle { x: root.simplified ? 12px : toolbar.x; y: root.simplified ? 12px : toolbar.y + toolbar.height + 12px; width: parent.width - self.x * 2; - height: (root.simplified && root.pages <= 1 ? parent.height - 12px : footer.y - 12px) - self.y; + height: footer.y - 12px - self.y; visible: root.focused.id == ""; - for tile[index] in root.tiles: Rectangle { + for tile[index] in root.tiles: tile-card := 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; + accessible-role: button; accessible-label: self.details; + accessible-action-default => { root.action(root.batch && !root.simplified ? "select" : "focus", tile.id); } + changed details => { if tile-touch.has-hover { tile-touch.show-details(); } } x: mod(index, root.columns) * (grid.width + root.grid-gap) / root.columns; y: floor(index / root.columns) * (grid.height + root.grid-gap) / root.rows; width: (grid.width - (root.columns - 1) * root.grid-gap) / root.columns; height: (grid.height - (root.rows - 1) * root.grid-gap) / root.rows; border-radius: root.simplified ? 0px : 6px; - background: root.simplified ? #24272d : tile.selected ? #eaf3ff : #eff0f2; + background: !tile.online ? DeviceCardStyle.offline-background : root.simplified ? #24272d : tile.selected ? #eaf3ff : #eff0f2; border-width: root.simplified ? 0px : tile.selected ? 2px : 1px; - border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : #c9cdd2; + border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : !tile.online ? DeviceCardStyle.offline-border : #c9cdd2; Rectangle { x: root.simplified ? 0px : 6px; y: self.x; width: parent.width - self.x * 2; height: root.simplified ? parent.height : parent.height - 36px; - background: #24272d; - if tile.has-frame: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; } + background: tile.online ? #24272d : DeviceCardStyle.offline-preview; + if tile.has-frame && tile.online: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; } if !root.simplified && (!tile.has-frame || !tile.connected || tile.stale): Text { - text: tile.status; color: #ffffff; font-size: 12px; + text: tile.status; color: tile.online ? #ffffff : DeviceCardStyle.offline-placeholder; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; wrap: word-wrap; } } - Text { visible: !root.simplified; x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; } - presence-badge := Rectangle { - visible: !root.simplified && (root.batch || !tile-touch.has-hover); - x: parent.width - (root.batch ? 35px : 9px) - self.width; y: parent.height - 23px; - width: presence-label.preferred-width + 12px; height: 18px; - border-radius: 9px; background: tile.online ? #eaf6ee : #e2e5e9; - presence-label := Text { - x: 6px; height: parent.height; - text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); - color: tile.online ? #17854c : #62666d; font-size: 10px; vertical-alignment: center; - } + Text { + visible: !root.simplified; x: 9px; y: parent.height - 28px; + width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px); + height: 28px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center; } if root.batch && !root.simplified: Rectangle { x: parent.width - 27px; y: parent.height - 23px; @@ -159,7 +189,19 @@ export component GalleryView inherits Rectangle { } } tile-touch := TouchArea { - clicked => { root.action(root.batch && !root.simplified ? "select" : "focus", tile.id); } + function show-details() { + // Avoid covering the per-device action buttons with a tooltip. + if self.mouse-y >= tile-card.height - 28px && !root.simplified { root.device-tooltip-id = ""; return; } + root.device-tooltip-id = tile.id; root.device-tooltip-text = tile-card.details; + root.device-tooltip-x = grid.x + tile-card.x; + root.device-tooltip-y = grid.y + tile-card.y + tile-card.height + 4px; + } + changed has-hover => { + if self.has-hover { self.show-details(); } + else if root.device-tooltip-id == tile.id { root.device-tooltip-id = ""; } + } + moved => { self.show-details(); } + clicked => { root.device-tooltip-id = ""; root.action(root.batch && !root.simplified ? "select" : "focus", tile.id); } if tile-touch.has-hover && !root.batch && !root.simplified: DeviceCardActions { x: parent.width - self.width - 6px; y: parent.height - 26px; remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub"); @@ -182,13 +224,13 @@ export component GalleryView inherits Rectangle { focus-view := Rectangle { visible: root.focused.id != ""; x: 29px; y: 7px; width: parent.width - 58px; - height: parent.height - (root.simplified && root.pages <= 1 ? 19px : 51px); + height: footer.y - 12px - self.y; HorizontalLayout { x: 0px; y: 0px; width: parent.width; height: 28px; spacing: 8px; CompactButton { 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: #303943; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; } + 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; @@ -207,12 +249,12 @@ export component GalleryView inherits Rectangle { Rectangle { 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; + 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; video := Rectangle { x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; - background: #24272d; - if root.focused.has-frame: Image { + background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview; + if root.focused.has-frame && root.focused.online: Image { width: parent.width; height: parent.height; source: root.focused.frame; image-fit: contain; opacity: root.focused.stale || !root.focused.connected ? 0.4 : 1; } @@ -245,7 +287,7 @@ export component GalleryView inherits Rectangle { } } } - if !root.focused.has-frame || root.focused.stale || !root.focused.connected: Text { text: root.focused.status; color: white; horizontal-alignment: center; vertical-alignment: center; } + if !root.focused.has-frame || root.focused.stale || !root.focused.connected: Text { text: root.focused.status; color: root.focused.online ? white : DeviceCardStyle.offline-placeholder; horizontal-alignment: center; vertical-alignment: center; } } } rail := ScrollView { @@ -257,14 +299,14 @@ export component GalleryView inherits Rectangle { horizontal-scrollbar-policy: as-needed; vertical-scrollbar-policy: always-off; changed width => { - root.rail-tooltip-id = ""; + root.device-tooltip-id = ""; self.viewport-x = clamp(self.viewport-x, min(0px, self.width - self.viewport-width), 0px); } changed viewport-width => { - root.rail-tooltip-id = ""; + root.device-tooltip-id = ""; self.viewport-x = clamp(self.viewport-x, min(0px, self.width - self.viewport-width), 0px); } - changed viewport-x => { root.rail-tooltip-id = ""; } + changed viewport-x => { root.device-tooltip-id = ""; } HorizontalLayout { x: 0px; y: 0px; height: root.rail-card-height; spacing: 0px; padding: 0px; for tile in root.tiles: rail-slot := Rectangle { @@ -276,106 +318,138 @@ export component GalleryView inherits Rectangle { : 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; + 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; 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; } + x: 5px; y: 5px; width: 126px; height: root.simplified ? 82px : 52px; background: tile.online ? #24272d : DeviceCardStyle.offline-preview; + if tile.has-frame && tile.online: 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; + text: tile.status; color: tile.online ? #e2e6ed : DeviceCardStyle.offline-placeholder; 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; + x: 7px; y: 58px; width: 122px; height: 16px; + text: tile.name; color: tile.online ? #303943 : DeviceCardStyle.offline-text; 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; + root.device-tooltip-id = tile.id; + root.device-tooltip-text = parent.details; + root.device-tooltip-x = focus-view.x + rail.x + rail.viewport-x + rail-slot.x; + root.device-tooltip-y = focus-view.y + rail.y + rail.height + 4px; } changed has-hover => { if self.has-hover { self.show-details(); } - else if root.rail-tooltip-id == tile.id { root.rail-tooltip-id = ""; } + else if root.device-tooltip-id == tile.id { root.device-tooltip-id = ""; } } moved => { if self.has-hover { self.show-details(); } } - clicked => { root.rail-tooltip-id = ""; root.action("focus", tile.id); } + clicked => { root.device-tooltip-id = ""; 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; - } - } } - // Reuse the footer for selection actions so entering batch mode never - // changes card geometry or moves a device beneath the pointer. - footer := HorizontalLayout { - x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: root.batch ? 8px : 12px; - visible: !root.simplified || root.pages > 1; - if root.batch && !root.simplified: CompactButton { - width: 90px; enabled: root.count > 0; - text: root.count > 0 && root.selected-count == root.count - ? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор") - : GalleryStrings.tr("全选", "Select all", "Выбрать все"); - clicked => { root.action("all-select", ""); } - } - Rectangle { - horizontal-stretch: 1; min-width: 0px; + Rectangle { + y: parent.height - 44px; height: 44px; background: #fafbfc; + Rectangle { y: 0px; width: parent.width; height: 1px; background: #e1e4e8; } + } + // Fixed selection widths keep the cards, actions and pager stationary. + footer := Rectangle { + x: root.simplified ? 12px : toolbar.x; y: parent.height - 34px; + width: parent.width - self.x * 2; height: 24px; + status-area := Rectangle { + x: 0px; y: 0px; width: parent.width - 124px; height: parent.height; + visible: !root.batch || root.simplified; + border-radius: 3px; background: network-touch.has-hover ? #edf0f4 : transparent; + accessible-role: button; + accessible-label: GalleryStrings.tr("网络状态", "Network", "Сеть") + ": " + root.connected-summary; + accessible-action-default => { root.network-open(); } Text { - visible: !root.simplified; - y: 0px; width: parent.width; height: root.batch && root.hidden-selected > 0 ? 13px : parent.height; - text: root.batch ? GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count : root.connected-count + " / " + root.count + GalleryStrings.tr(" 台已连接", " connected", " подключено"); - color: #62666d; vertical-alignment: center; font-size: 12px; overflow: elide; + x: 0px; width: parent.width - 20px; height: parent.height; + text: root.connected-summary + " · " + GalleryStrings.tr("网络状态", "Network", "Сеть"); + color: #62666d; vertical-alignment: center; font-size: 11px; overflow: elide; } - if root.batch && !root.simplified && root.hidden-selected > 0: Text { - y: 14px; width: parent.width; height: 10px; - text: GalleryStrings.tr("跨页 ", "Other pages ", "Вне страницы ") + root.hidden-selected; - color: #80858d; font-size: 10px; overflow: elide; + Text { + x: parent.width - 13px; width: 12px; height: parent.height; + text: FontAwesomeIcons.angle-up; font-family: "Font Awesome 6 Free"; font-weight: 900; + font-size: 9px; color: #858c96; vertical-alignment: center; + } + network-touch := TouchArea { + mouse-cursor: pointer; + changed has-hover => { + if self.has-hover { + root.device-tooltip-id = "network-status"; + root.device-tooltip-text = root.connected-summary + "\n" + GalleryStrings.tr("点击查看网络状态", "Click to view network status", "Нажмите для просмотра состояния сети"); + root.device-tooltip-x = footer.x; root.device-tooltip-y = footer.y; + } else if root.device-tooltip-id == "network-status" { root.device-tooltip-id = ""; } + } + clicked => { root.device-tooltip-id = ""; root.network-open(); } } } - if root.batch && !root.simplified: HorizontalLayout { - width: 280px; spacing: 8px; - CompactButton { width: 58px; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } } - CompactButton { width: 62px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } } - CompactButton { - width: 58px; enabled: root.selected-audio-available; - text: root.selected-audio-muted - ? GalleryStrings.tr("取消静音", "Unmute", "Со звуком") - : GalleryStrings.tr("静音", "Mute", "Без звука"); - clicked => { root.action(root.selected-audio-muted ? "unmute" : "mute", ""); } + selection-actions := HorizontalLayout { + x: 0px; y: 0px; height: parent.height; + visible: root.batch && !root.simplified; width: 408px; spacing: 6px; + Rectangle { + width: 100px; + Text { + x: 0px; y: 0px; width: parent.width; height: root.hidden-selected > 0 ? 13px : parent.height; + text: GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count + GalleryStrings.tr(" 台", "", ""); + color: #62666d; vertical-alignment: center; font-size: 11px; overflow: elide; + } + if root.hidden-selected > 0: Text { + y: 14px; width: parent.width; height: 10px; + text: GalleryStrings.tr("跨页 ", "Other pages ", "Вне страницы ") + root.hidden-selected; + color: #80858d; font-size: 10px; overflow: elide; + } + } + CompactButton { + width: 90px; quiet: true; enabled: root.count > 0; + text: root.count > 0 && root.selected-count == root.count + ? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор") + : GalleryStrings.tr("全选", "Select all", "Выбрать все"); + clicked => { root.action("all-select", ""); } + } + CompactButton { width: 60px; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } } + CompactButton { width: 64px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } } + batch-menu := GalleryMenuButton { + width: 70px; enabled: root.selected-count > 0; opens-up: true; + text: GalleryStrings.tr("更多", "More", "Ещё"); + items: [ + {label: root.selected-audio-muted ? GalleryStrings.tr("取消静音", "Unmute", "Со звуком") : GalleryStrings.tr("静音", "Mute", "Без звука"), enabled: root.selected-audio-available}, + {label: GalleryStrings.tr("分发文件", "Send file", "Отправить файл"), enabled: root.selected-count > 0}, + ]; + opened => { root.device-tooltip-id = ""; root.action("release", ""); } + selected(index) => { + if index == 0 { root.action(root.selected-audio-muted ? "unmute" : "mute", ""); } + else { root.confirm-action("file", GalleryStrings.tr("分发文件", "Send file", "Отправить файл")); } + } } - CompactButton { width: 78px; enabled: root.selected-count > 0; text: GalleryStrings.tr("分发文件", "Send file", "Файл"); clicked => { root.confirm-action("file", self.text); } } } HorizontalLayout { - width: 112px; height: 24px; spacing: 6px; + x: parent.width - self.width; y: 0px; width: 112px; height: 24px; spacing: 6px; CompactButton { 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; } CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } } } } + // Share one delayed tooltip across cards and keep it inside the view. + if root.device-tooltip-visible && root.device-tooltip-id != "": 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; + 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; + } + } if root.adding: Rectangle { background: #00000042; TouchArea {} diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 85200006..200f1a53 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -161,34 +161,6 @@ component DialogPromptText inherits Rectangle { } } -// Explicit device labels make state readable without interpreting a dot. -// Opaque surfaces keep the badge legible over both dark and light previews. -component ConnectionStatusBadge inherits Rectangle { - in property label; - in property online: false; - - preferred-width: badge-label.preferred-width + 16px; - width: self.preferred-width; - height: 20px; - border-radius: self.height / 2; - background: root.online ? #eaf8f0 : #f2f4f7; - border-width: 1px; - border-color: root.online ? #bce3cd : #d5dbe3; - - badge-label := Text { - x: 8px; - width: max(0px, parent.width - 16px); - height: parent.height; - text: root.label; - color: root.online ? #16a34a : #596575; - font-size: ImGuiFontStyle.small; - font-weight: 600; - horizontal-alignment: center; - vertical-alignment: center; - overflow: elide; - } -} - // Keep the green center and fade the surrounding glow into the status bar. component OnlineStatusIndicator inherits Rectangle { in property dot-diameter: 8px; @@ -921,29 +893,22 @@ export component MainWindow inherits Window { mode-switch := Rectangle { x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px; - background: #eef1f5; - border-width: 0.5px; border-color: #d5dbe3; border-radius: 6px; + background: #e2e4e7; + border-width: 1px; border-color: #c8cbd0; border-radius: 3px; Rectangle { x: root.gallery-mode ? 82px : 2px; y: 2px; width: 80px; height: parent.height - 4px; - background: @linear-gradient(180deg, #c4e0fd, #abd0f8); - border-width: 0.5px; border-color: #97bde5; border-radius: 4px; - drop-shadow-color: #315b8b20; - drop-shadow-blur: 2px; drop-shadow-offset-y: 1px; - Rectangle { - x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; - border-width: 0.5px; border-color: #ffffff70; border-radius: 3px; - } + background: #a8d0fa; + border-width: 1px; border-color: #9bc3ed; border-radius: 3px; animate x { duration: 160ms; easing: ease-out; } } for label[index] in [GalleryStrings.tr("连接", "Connect", "Связь"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle { x: 2px + index * 80px; y: 2px; width: 80px; height: parent.height - 4px; Text { text: label; - color: (index == 1) == root.gallery-mode ? #234b75 : #697483; + color: #24272d; font-weight: (index == 1) == root.gallery-mode ? 600 : 400; horizontal-alignment: center; vertical-alignment: center; font-size: 12px; - animate color { duration: 120ms; easing: ease-out; } } TouchArea { clicked => { root.change-mode(index == 1); } @@ -951,25 +916,18 @@ export component MainWindow inherits Window { } } if root.gallery-mode: CompactButton { - x: menu-button.x - 87px - 16px - 94px - 8px - self.width; + x: menu-button.x - 24px - 16px - self.width; y: mode-switch.y; width: 94px; height: mode-switch.height; primary: root.gallery-simplified; text: root.gallery-simplified ? GalleryStrings.tr("退出精简", "Exit compact", "Обычный вид") : GalleryStrings.tr("精简模式", "Compact", "Компактно"); clicked => { root.gallery-simplified = !root.gallery-simplified; } } - if root.gallery-mode: CompactButton { - x: menu-button.x - 87px - 16px - self.width; - y: mode-switch.y; width: 94px; height: mode-switch.height; - text: GalleryStrings.tr("网络状态", "Network", "Сеть"); - clicked => { root.gallery-network-open = true; } - } - if root.gallery-mode: CompactButton { + if root.gallery-mode: IconButton { x: menu-button.x - self.width - 8px; - y: mode-switch.y; - width: 87px; - height: mode-switch.height; - text: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit full", "Выйти") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); + y: mode-switch.y; width: 24px; height: mode-switch.height; + icon: root.gallery-fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; + tooltip: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); clicked => { root.toggle-gallery-fullscreen(); } } Rectangle { @@ -1157,7 +1115,7 @@ export component MainWindow inherits Window { } } - // Recent connections: horizontal cards with thumbnail, state and actions. + // Recent connections: horizontal cards with thumbnail and hover actions. recent-panel := Rectangle { y: 234px; height: parent.height - self.y; @@ -1209,6 +1167,7 @@ export component MainWindow inherits Window { width: 178px; height: 117px; background: transparent; + private property available: connection.online || connection.connected; private property content-width: self.width - 30px; private property toolbar-width: DeviceCardStyle.toolbar-width; private property toolbar-right-padding: 3px; @@ -1226,7 +1185,8 @@ export component MainWindow inherits Window { y: 0px; width: parent.width; height: 84px; - background: connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) : + background: !connection-card.available ? DeviceCardStyle.offline-preview : + connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) : connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) : connection.platform == "linux" ? @linear-gradient(135deg, #a14f14, #ba631f) : connection.platform == "ios" ? @linear-gradient(135deg, #146e66, #1f8278) : @@ -1236,6 +1196,8 @@ export component MainWindow inherits Window { height: parent.height; source: connection.thumbnail; image-fit: cover; + // Keep the saved preview recognizable under the offline gray. + opacity: connection-card.available ? 1 : 0.16; } if !connection.has-thumbnail: Text { x: 8px; @@ -1245,7 +1207,7 @@ export component MainWindow inherits Window { connection.platform == "macos" ? "macOS" : connection.platform == "linux" ? "Linux" : connection.platform == "ios" ? "iOS" : UiStrings.unknown-platform; - color: white; + color: connection-card.available ? white : DeviceCardStyle.offline-placeholder; font-size: 20px; font-weight: 600; horizontal-alignment: center; @@ -1258,7 +1220,7 @@ export component MainWindow inherits Window { y: 84px; width: parent.width; height: 25px; - background: #00000018; + background: connection-card.available ? #00000018 : DeviceCardStyle.offline-background; Text { x: 4px; y: 1px; @@ -1268,7 +1230,7 @@ export component MainWindow inherits Window { ? connection-card.toolbar-width + connection-card.toolbar-right-padding : 0px)); text: connection.display-name; - color: #30343b; + color: connection-card.available ? #30343b : DeviceCardStyle.offline-text; // The ImGui card footer uses its own 0.52 font // scale and renders slightly larger than the // compact body text used in dialogs. @@ -1277,13 +1239,6 @@ export component MainWindow inherits Window { vertical-alignment: center; } } - ConnectionStatusBadge { - x: 6px; - y: 6px; - width: min(self.preferred-width, parent.width - 12px); - label: connection.online ? UiStrings.online : UiStrings.offline; - online: connection.online; - } // The software renderer only clips rectangular bounds. // This outer border masks the four corners there too. Rectangle { @@ -1306,6 +1261,12 @@ export component MainWindow inherits Window { width: parent.width; height: 84px; private property group-names: connection.group-names; + private property available: connection-card.available; + changed available => { + if self.has-hover && root.recent-tooltip-id == connection.remote-id { + root.recent-tooltip-online = self.available; + } + } changed group-names => { if self.has-hover && root.recent-tooltip-id == connection.remote-id { root.recent-tooltip-groups = self.group-names; @@ -1318,7 +1279,7 @@ export component MainWindow inherits Window { root.recent-tooltip-host = connection.host-name; root.recent-tooltip-id = connection.remote-id; root.recent-tooltip-groups = self.group-names; - root.recent-tooltip-online = connection.online; + root.recent-tooltip-online = self.available; root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x + recent-scroll.x + recent-scroll.viewport-x + connection-card.x + card-touch.x + self.mouse-x; @@ -1377,9 +1338,10 @@ export component MainWindow inherits Window { } gallery := GalleryView { y: root.content-header-height; width: parent.width; - height: parent.height - self.y - (root.gallery-simplified ? 0px : 24px); + height: parent.height - self.y - 24px; visible: root.gallery-mode; simplified: root.gallery-simplified; + network-open => { root.gallery-network-open = true; } controlling: root.gallery-controlling; pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); } scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); } @@ -1407,9 +1369,8 @@ export component MainWindow inherits Window { root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true; } } - // Status bar. + // Shared server status bar stays visible in every connection and DeskHub view. Rectangle { - visible: !root.gallery-mode || !root.gallery-simplified; y: parent.height - 24px; height: 24px; background: #fafbfc; diff --git a/docs/deskhub.md b/docs/deskhub.md index 9d35353d..e8260d31 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -1,6 +1,6 @@ # 桌面墙(DeskHub) -主窗口标题栏下方可以切换“连接”和“桌面墙”,菜单位于同一行右侧。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。 +主窗口标题栏下方可以切换“连接”和“桌面墙”,同一行右侧保留纯文字“精简模式”按钮、全屏图标和菜单。设备管理栏集中显示分组、设备数量、“选择”和“添加”入口。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。 全屏按钮通过短暂淡出、等待窗口尺寸稳定、淡入完成切换,常规总时长约 0.2 秒;过渡期间屏蔽设备操作与重复全屏点击。窗口尺寸变化会立即更新网格,避免在新窗口中先显示旧的行列布局。过渡复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会恢复内容与交互。 @@ -8,24 +8,25 @@ ## 使用 -- 在桌面墙中点“添加设备”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。 -- “从最近连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“批量选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。 +- 在桌面墙中点“添加 → 通过设备 ID 添加”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。 +- “添加 → 从近期连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。 - 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。 - “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。 -- 卡片名称栏以文字标签显示“在线/离线”,与画面中的连接状态分开,不遮挡预览。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示“未知”,已有活动连接的设备仍显示在线。 +- 卡片名称栏只显示设备名,长名称省略,不再显示状态圆点。未在线的设备(离线或在线状态未知)整张卡片使用灰色,包括预览背景、边框和名称;在线但尚未连接的设备保持正常样式。选中边框、复选框和操作入口仍可用。悬停画面可查看完整名称、ID 和在线状态,精简模式也支持。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示未知,已有活动连接的设备仍显示在线。 - 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。 -- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“从最近连接添加”或“添加设备”才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。 +- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“添加”菜单导入或按设备 ID 添加,才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。 +- 近期连接卡片使用与桌面墙相同的离线灰色,不再在缩略图上叠加在线/离线标签。不在线时减淡历史缩略图并将名称栏置灰;没有缩略图时,平台占位也使用相同灰色。在线或已连接的设备正常显示,悬停仍可查看在线状态和操作设备。 - 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。 - “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。 - 网格随窗口大小增加行列,紧凑窗口为 3 × 2;翻页不关闭连接。分组内顺序不会按在线状态自动重排。 -- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏、连接计数与底部服务器状态栏;画面完整等比显示,不裁切。顶部保留“退出精简”、网络状态、全屏及菜单入口,多页时保留分页。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。 +- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部固定宽度的文字按钮高亮显示“退出精简”,不带图标;全屏及菜单入口仍可见。最底部保留与连接页一致的独立服务器状态栏,其上方保留连接计数、网络详情入口和分页。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。 - 菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,在连接页面以及桌面墙普通、精简、展开和全屏视图中均可打开。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。 -- 顶部“网络状态”按钮打开当前已建立连接的设备列表,不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 +- 窗口最底部常驻与连接页一致的服务器状态栏,普通、精简、展开、批量选择和全屏视图均显示连接指示灯、服务器连接状态及断开时的重连入口。桌面墙操作栏位于其上方,显示已连接设备数量和“网络状态”;点击可打开当前已建立连接的设备列表。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 - 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 -- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示状态圆点和设备名:绿色为在线、灰色为离线、空心灰色为未知,长名称省略;悬停可查看完整名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通模式缩略图栏高 90px,在保留画面大小的同时将空间留给主预览。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 +- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看完整名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通模式缩略图栏高 90px,在保留画面大小的同时将空间留给主预览。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 - 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。 -- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/静音/分发文件及分页控件在同一底栏显示。声音按钮根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。 +- 点击“选择”进入批量模式,“完成”退出;批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏中的已选数量和“全选/取消全选”使用固定宽度,切换选中数量或按钮文字时,后续按钮和分页保持原位。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接、断开、“更多”及分页控件在同一底栏显示;静音和文件分发收进“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。 ## 预览与兼容性