[ui] simplify DeskHub controls and unify device status styling

This commit is contained in:
dijunkun
2026-10-13 18:12:57 +08:00
parent 4e394ca650
commit 435feb419c
6 changed files with 337 additions and 187 deletions
+4 -3
View File
@@ -349,17 +349,18 @@ export component CompactButton inherits Rectangle {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> 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;
@@ -3,6 +3,11 @@ import { GalleryStrings } from "device_strings.slint";
export global DeviceCardStyle {
out property <length> toolbar-width: 104px;
out property <color> offline-preview: #626870;
out property <color> offline-background: #e4e6e9;
out property <color> offline-border: #c9cdd2;
out property <color> offline-text: #68707a;
out property <color> offline-placeholder: #eceef1;
}
component CardAction inherits IconButton {
@@ -0,0 +1,108 @@
import { CompactButton, FontAwesomeIcons } from "common.slint";
export struct GalleryMenuItem { label: string, enabled: bool }
export component GalleryMenuButton inherits Rectangle {
in property <string> text;
in property <bool> primary: false;
in property <bool> enabled: true;
in property <bool> opens-up: false;
in property <length> menu-width: 200px;
in property <[GalleryMenuItem]> items;
private property <int> 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); }
}
}
}
}
}
}
+181 -107
View File
@@ -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 <bool> controlling: false;
in property <bool> simplified: false;
private property <length> grid-gap: root.simplified ? 6px : 12px;
private property <length> rail-card-height: root.simplified ? 92px : 76px;
private property <string> focused-id: root.focused.id;
private property <string> rail-tooltip-id;
private property <string> rail-tooltip-text;
private property <length> rail-tooltip-x;
private property <bool> 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 <string> device-tooltip-id;
private property <string> device-tooltip-text;
private property <length> device-tooltip-x;
private property <length> device-tooltip-y;
private property <bool> 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 <int> pages: 1;
in property <int> count: 0;
in property <int> connected-count: 0;
private property <string> connected-summary: root.connected-count + " / " + root.count
+ GalleryStrings.tr(" 台已连接", " connected", " подключено");
callback network-open;
in property <int> selected-count: 0;
in property <bool> selected-audio-available: false;
in property <bool> 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 <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
private property <string> 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 <string> 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 {}
+29 -68
View File
@@ -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 <string> label;
in property <bool> 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 <length> 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 <bool> available: connection.online || connection.connected;
private property <length> content-width: self.width - 30px;
private property <length> toolbar-width: DeviceCardStyle.toolbar-width;
private property <length> 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 <string> group-names: connection.group-names;
private property <bool> 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;
+10 -9
View File
@@ -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,在保留画面大小的同时将空间留给主预览。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。
- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/静音/分发文件及分页控件在同一底栏显示。声音按钮根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
- 点击“选择”进入批量模式,“完成”退出;批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏中的已选数量和“全选/取消全选”使用固定宽度,切换选中数量或按钮文字时,后续按钮和分页保持原位。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接、断开、“更多”及分页控件在同一底栏显示;静音和文件分发收进“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
## 预览与兼容性