[feat] add standalone DeskHub window and resizable device table

This commit is contained in:
dijunkun
2026-10-14 02:37:06 +08:00
parent fd86dba212
commit 5199b2df11
11 changed files with 1113 additions and 620 deletions
File diff suppressed because it is too large Load Diff
@@ -50,7 +50,7 @@ private:
void InitializeUi();
void InitializeSystemTray();
bool MinimizeMainWindowToTray();
void BindMainCallbacks();
void BindMainCallbacks(bool gallery_window = false);
void BindStreamCallbacks();
void BindServerCallbacks();
void Tick();
@@ -60,16 +60,21 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
void ChangeMainMode(bool gallery);
void ShowGalleryWindow();
void HideGalleryWindow();
void SyncWindowGeometry(bool gallery_window);
void ToggleGalleryFullscreen();
void RestoreMainWindowGeometry(bool before_show = false);
void RestoreWindowGeometry(bool before_show = false,
bool gallery_window = false);
void SyncGallery();
void GalleryAction(const std::string& action, const std::string& value);
void GalleryAction(const std::string& action, const std::string& value,
bool gallery_window = true);
bool AddGalleryDevice(const std::string& id, const std::string& password,
const std::string& group, bool remember_password);
bool ImportGalleryRecent(const std::string& group);
void DeviceGroupAction(const std::string& action, const std::string& value);
bool SaveDeviceGroups(const std::string& new_group);
void DeviceGroupAction(const std::string& action, const std::string& value,
bool gallery_window);
bool SaveDeviceGroups(const std::string& new_group, bool gallery_window);
void ReleaseControl();
void SyncAudioPlayback();
void SaveGalleryWindowState();
@@ -89,9 +94,9 @@ private:
void SyncXWaylandWindowActivation();
#endif
void UpdateLocalization();
void ResetSettingsUi();
bool ValidateSelfHostedSettingsFromUi();
bool SaveSettingsFromUi();
void ResetSettingsUi(bool gallery_window = false);
bool ValidateSelfHostedSettingsFromUi(bool gallery_window = false);
bool SaveSettingsFromUi(bool gallery_window = false);
void ApplySettingsFromConfig(bool reconnect);
#if _WIN32 && CROSSDESK_PORTABLE
void CheckPortableWindowsService();
@@ -22,26 +22,6 @@ struct WindowState {
std::optional<slint::LogicalSize> wall_size;
std::optional<slint::PhysicalPosition> wall_position, connection_position;
void BeginModeChange(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486) {
// Never capture a half-applied mode transition as the user's wall geometry.
Observe(!wall, position, size, scale, fullscreen, maximized,
compact_height);
if (wall) {
// Modes share the current desktop location. An old wall position must
// not undo a move made while the connection page was visible.
if (connection_position)
wall_position = connection_position;
} else {
// Fullscreen/maximized coordinates describe the screen, not the ordinary
// window. Return to the last normal anchor before restoring compact size.
if (wall_position)
connection_position = wall_position;
}
BeginRestore();
}
void Observe(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486) {
@@ -82,7 +82,39 @@ struct Workspace {
if (wall_ids_.count(m.id) && (group.empty() || m.group == group) &&
seen.insert(m.id).second)
ids.push_back(m.id);
return ids;
return OrderIds(ids);
}
// Order is independent of membership, group assignments and live sessions.
std::vector<std::string> OrderIds(const std::vector<std::string> &ids) const {
if (device_order_.empty())
return ids;
std::set<std::string> remaining(ids.begin(), ids.end());
std::vector<std::string> ordered;
ordered.reserve(ids.size());
for (const auto &id : device_order_)
if (remaining.erase(id)) ordered.push_back(id);
for (const auto &id : ids)
if (remaining.erase(id)) ordered.push_back(id);
return ordered;
}
bool SwapPositions(const std::string &source, const std::string &target,
const std::vector<std::string> &visible_ids) {
if (source == target || !ValidId(source) || !ValidId(target) ||
std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() ||
std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end())
return false;
auto ordered = device_order_;
std::set<std::string> known(ordered.begin(), ordered.end());
// Include hidden groups before applying a swap in a filtered view.
for (const auto &id : Ids())
if (known.insert(id).second) ordered.push_back(id);
// Active-only devices can be arranged without enrolling them in the wall.
for (const auto &id : visible_ids)
if (ValidId(id) && known.insert(id).second) ordered.push_back(id);
std::iter_swap(std::find(ordered.begin(), ordered.end(), source),
std::find(ordered.begin(), ordered.end(), target));
device_order_ = std::move(ordered);
return true;
}
// History filtering uses group assignments, independently of wall enrollment.
// nullopt means all devices; an empty name means no named group.
@@ -147,6 +179,7 @@ struct Workspace {
if (group.empty()) {
// Removing from the wall leaves history's group assignments intact.
wall_ids_.erase(id);
std::erase(device_order_, id);
} else if (MatchesGroup(id, group)) {
// Removing the last named group leaves an ungrouped wall member.
// Reuse the editor path so enrollment and card order stay unchanged.
@@ -198,9 +231,19 @@ struct Workspace {
if (ValidId(id) && loaded.HasAssignments(id))
loaded.wall_ids_.insert(id);
}
if (doc.contains("device_order") && doc["device_order"].is_array()) {
std::set<std::string> seen;
for (const auto &value : doc["device_order"]) {
if (!value.is_string()) continue;
const auto id = value.get<std::string>();
if (ValidId(id) && seen.insert(id).second)
loaded.device_order_.push_back(id);
}
}
members = std::move(loaded.members);
groups = std::move(loaded.groups);
wall_ids_ = std::move(loaded.wall_ids_);
device_order_ = std::move(loaded.device_order_);
stream_settings = loaded.stream_settings;
return true;
} catch (...) {
@@ -212,6 +255,7 @@ struct Workspace {
nlohmann::json doc{{"version", 2},
{"members", nlohmann::json::array()},
{"wall_ids", wall_ids_},
{"device_order", device_order_},
{"stream_settings", stream_settings.ToJson()}};
for (const auto &m : members)
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
@@ -223,6 +267,7 @@ struct Workspace {
private:
std::set<std::string> wall_ids_;
std::vector<std::string> device_order_;
bool HasAssignments(const std::string &id) const {
return std::any_of(members.begin(), members.end(),
+2 -1
View File
@@ -403,6 +403,7 @@ export component PrimaryButton inherits Rectangle {
export component SecondaryButton inherits Rectangle {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> enabled: true;
callback clicked;
@@ -419,7 +420,7 @@ export component SecondaryButton inherits Rectangle {
Text {
text: root.text;
color: root.enabled ? #24272d : #949aa5;
font-size: ImGuiFontStyle.body;
font-size: root.text-size;
horizontal-alignment: center;
vertical-alignment: center;
}
@@ -27,18 +27,18 @@ component NetworkRow inherits Rectangle {
identity := Rectangle {
horizontal-stretch: 1;
device-name := Text {
y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px;
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width; text: root.device.name;
font-size: root.heading ? 11px : 12px; font-weight: 600;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
name-hover := TouchArea {
y: 11px; height: 20px; width: parent.width;
y: 8px; height: 20px; width: parent.width;
enabled: !root.heading;
}
if !root.heading: HorizontalLayout {
x: 0px; y: 34px; width: parent.width; height: 17px; spacing: 4px;
x: 0px; y: 30px; width: parent.width; height: 17px; spacing: 4px;
id-text := Text {
text: root.device.id; font-size: 10px; color: #7a8490;
vertical-alignment: center; overflow: elide;
@@ -77,7 +77,7 @@ component NetworkRow inherits Rectangle {
Rectangle {
width: max(108px, root.width * 0.19);
Text {
y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px;
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("画面", "Video", "Видео") : root.device.resolution;
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
@@ -85,7 +85,7 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
y: 34px; height: 17px; width: parent.width;
y: 30px; height: 17px; width: parent.width;
text: root.device.fps; font-size: 11px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
@@ -99,20 +99,20 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
x: 0px; y: 11px; height: 20px; width: 14px;
x: 0px; y: 8px; height: 20px; width: 14px;
text: "↓"; color: #2676b9; font-size: 12px; vertical-alignment: center;
}
if !root.heading: Text {
x: 17px; y: 11px; height: 20px; width: parent.width - 17px;
x: 17px; y: 8px; height: 20px; width: parent.width - 17px;
text: root.device.inbound; color: #293746; font-size: 12px;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
x: 0px; y: 34px; height: 17px; width: 14px;
x: 0px; y: 30px; height: 17px; width: 14px;
text: "↑"; color: #7a8490; font-size: 11px; vertical-alignment: center;
}
if !root.heading: Text {
x: 17px; y: 34px; height: 17px; width: parent.width - 17px;
x: 17px; y: 30px; height: 17px; width: parent.width - 17px;
text: root.device.outbound; color: #7a8490; font-size: 11px;
vertical-alignment: center; overflow: elide;
}
@@ -120,7 +120,7 @@ component NetworkRow inherits Rectangle {
Rectangle {
width: max(94px, root.width * 0.16);
Text {
y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px;
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("网络质量", "Network quality", "Качество сети") : "RTT " + root.device.latency;
font-size: 11px; font-weight: root.heading ? 600 : 400;
@@ -128,7 +128,7 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
y: 34px; height: 17px; width: parent.width;
y: 30px; height: 17px; width: parent.width;
text: GalleryStrings.tr("丢包 ", "Loss ", "Потери ") + root.device.loss;
font-size: 11px; color: #7a8490;
vertical-alignment: center; overflow: elide;
@@ -162,14 +162,16 @@ component NetworkEmptyState inherits Rectangle {
export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices;
private property <length> row-height: 56px;
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close();
background: #00000042;
TouchArea { clicked => { root.close(); } }
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(960px, parent.width - 32px);
height: min(560px, parent.height - 24px);
width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: 8px;
border-width: 1px; border-color: #c6cdd5;
TouchArea {}
@@ -210,10 +212,10 @@ export component GalleryNetworkDialog inherits Rectangle {
x: 12px; y: 108px; width: parent.width - 24px;
height: parent.height - self.y - 12px;
viewport-width: self.width - 14px;
viewport-height: max(self.height, root.devices.length * 62px);
viewport-height: max(self.height, root.devices.length * root.row-height);
horizontal-scrollbar-policy: always-off;
for device[index] in root.devices: NetworkRow {
y: index * 62px; width: list.viewport-width; height: 62px;
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; }
}
+352 -65
View File
@@ -3,7 +3,7 @@ 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";
import { CompactButton, SecondaryButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
export struct GalleryTile {
id: string,
@@ -29,21 +29,72 @@ export struct GalleryActionDevice {
connected: bool,
}
component DeviceDetailsTip inherits Rectangle {
in property <string> device-name;
in property <string> device-id;
in property <string> presence;
in property <length> available-height;
height: min(root.available-height, 58px);
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: 5px;
clip: true;
tip-name := Text {
x: 8px; y: 8px; width: parent.width - 16px;
height: 14px;
text: root.device-name; color: white; font-size: 11px; overflow: elide; vertical-alignment: top;
}
Text {
x: 8px; y: tip-name.y + tip-name.height; width: parent.width - 16px; height: 14px;
text: "ID: " + root.device-id; color: white; font-size: 11px; overflow: elide;
}
Text {
x: 8px; y: tip-name.y + tip-name.height + 14px; width: parent.width - 16px; height: 14px;
text: root.presence; color: white; font-size: 11px; overflow: elide;
}
}
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;
in property <bool> window-active: true;
in-out property <float> tile-width: 216;
out property <bool> resizing: false;
private property <float> preview-aspect-ratio: 16 / 9;
private property <length> card-inset: 6px;
private property <length> card-chrome-height: root.simplified ? root.card-inset * 2 : 36px;
private property <length> max-card-width: max(184px, min(960px, grid.width,
(grid.height - root.card-chrome-height) * root.preview-aspect-ratio + root.card-inset * 2));
private property <length> card-width: clamp(root.tile-width * 1px, 184px, root.max-card-width);
private property <length> card-preview-height: (root.card-width - root.card-inset * 2) / root.preview-aspect-ratio;
private property <length> card-height: root.card-preview-height + root.card-chrome-height;
private property <length> rail-preview-height: 126px / root.preview-aspect-ratio;
private property <length> rail-card-height: root.rail-preview-height + (root.simplified ? 10px : 24px);
private property <string> focused-id: root.focused.id;
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 = ""; }
private property <string> drag-source;
private property <GalleryTile> dragged-tile;
private property <bool> dragging: false;
private property <length> drag-start-x;
private property <length> drag-start-y;
private property <length> drag-x;
private property <length> drag-y;
private property <int> drag-target: root.drop-index(root.drag-x, root.drag-y);
private property <int> drag-page: -1;
private property <length> drag-edge-width: 32px;
private property <int> drag-page-direction: root.page-direction(root.drag-x, root.drag-y);
private property <bool> swap-save-failed: false;
changed focused-id => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); }
changed visible => { root.device-tooltip-id = ""; if !root.visible { root.resizing = false; root.cancel-device-drag(); add-menu.close-menu(); batch-menu.close-menu(); } }
changed width => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); }
changed height => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); }
changed window-active => { if !root.window-active { root.resizing = false; root.cancel-device-drag(); } }
changed page => { if root.page != root.drag-page { root.cancel-device-drag(); } }
changed group-index => { root.cancel-device-drag(); }
changed card-width => { root.cancel-device-drag(); }
changed card-height => { root.cancel-device-drag(); }
changed device-tooltip-id => {
root.device-tooltip-visible = false;
device-tooltip-timer.running = root.device-tooltip-id != "";
@@ -65,8 +116,9 @@ export component GalleryView inherits Rectangle {
in property <[GalleryTile]> tiles;
in property <[string]> groups;
in-out property <int> group-index: 0;
in property <int> columns: 3;
in property <int> rows: 2;
// Pagination and rendering share the same fixed-aspect cell geometry.
out property <int> columns: max(1, floor(grid.width / root.card-width));
out property <int> rows: max(1, floor(grid.height / root.card-height));
in property <int> page: 0;
in property <int> pages: 1;
in property <int> count: 0;
@@ -91,6 +143,7 @@ export component GalleryView inherits Rectangle {
private property <string> action-code;
in property <[GalleryActionDevice]> selected-devices;
callback action(string, string);
callback swap-devices(string, string) -> bool;
callback add-device(string, string, string, bool) -> bool;
callback import-recent(string) -> bool;
callback edit-alias(string, string);
@@ -100,10 +153,89 @@ export component GalleryView inherits Rectangle {
changed adding => { if root.adding { root.action("release", ""); } }
changed importing => { root.action(root.importing ? "release" : "recent-close", ""); }
public function reset-transient() {
root.resizing = false;
root.cancel-device-drag();
root.batch = false; root.adding = false; root.importing = false; root.confirming = false;
root.device-tooltip-id = ""; add-menu.close-menu(); batch-menu.close-menu();
}
changed batch => { root.device-tooltip-id = ""; batch-menu.close-menu(); }
changed batch => { root.device-tooltip-id = ""; root.cancel-device-drag(); batch-menu.close-menu(); }
function cancel-device-drag() {
root.dragging = false; root.drag-source = ""; root.drag-page = -1;
}
function cell-index(x: length, y: length) -> int {
if x < 0px || y < 0px || x >= grid.table-width || y >= grid.table-height { return -1; }
let column = floor(x / root.card-width);
let row = floor(y / root.card-height);
let index = row * root.columns + column;
if index >= root.tiles.length { return -1; }
// Separators remain resize targets; drop on the device inside a cell.
let cell-x = x - column * root.card-width;
let cell-y = y - row * root.card-height;
if cell-x < 6px || cell-x >= root.card-width - 6px || cell-y < 6px || cell-y >= root.card-height - 6px { return -1; }
return index;
}
function drop-index(x: length, y: length) -> int {
let index = root.cell-index(x, y);
return !root.dragging || root.drag-page-direction != 0 || index < 0 || root.tiles[index].id == root.drag-source ? -1 : index;
}
function page-direction(x: length, y: length) -> int {
if !root.dragging { return 0; }
let view-x = grid.x + x;
let view-y = grid.y + y;
if view-x < 0px || view-x >= root.width || view-y < 0px || view-y >= root.height { return 0; }
if root.page > 0 && view-x < root.drag-edge-width { return -1; }
if root.page + 1 < root.pages && view-x >= root.width - root.drag-edge-width { return 1; }
// Keep the pager buttons as additional hover targets.
let pager-x = view-x - footer.x - pager.x;
let pager-y = view-y - footer.y - pager.y;
if pager-y < 0px || pager-y >= pager.height { return 0; }
if root.page > 0 && pager-x >= previous-page.x && pager-x < previous-page.x + previous-page.width { return -1; }
if root.page + 1 < root.pages && pager-x >= next-page.x && pager-x < next-page.x + next-page.width { return 1; }
return 0;
}
changed drag-page-direction => {
if root.drag-page-direction != 0 { drag-page-timer.restart(); }
}
drag-page-timer := Timer {
interval: 700ms;
running: root.dragging && root.drag-page-direction != 0 && root.page == root.drag-page;
triggered => {
if !root.dragging || root.drag-page-direction == 0 || root.page != root.drag-page { return; }
root.drag-page = root.page + root.drag-page-direction;
root.action("page", root.drag-page-direction > 0 ? "next" : "previous");
}
}
function move-device-drag(x: length, y: length) {
if root.drag-source == "" { return; }
root.drag-x = x; root.drag-y = y;
if abs(x - root.drag-start-x) >= 6px || abs(y - root.drag-start-y) >= 6px {
root.dragging = true; root.device-tooltip-id = "";
}
}
function finish-device-drag() {
let source = root.drag-source;
let target = root.drag-target >= 0 ? root.tiles[root.drag-target].id : "";
// Clear transient state before the callback can replace the page model.
root.cancel-device-drag();
if source != "" && target != "" {
root.swap-save-failed = !root.swap-devices(source, target);
if root.swap-save-failed { swap-error-timer.restart(); }
}
}
drag-focus := FocusScope {
x: 0px; y: 0px; width: 0px; height: 0px;
key-pressed(event) => {
if event.text == Key.Escape && root.drag-source != "" {
root.cancel-device-drag(); return accept;
}
reject
}
focus-lost => { root.cancel-device-drag(); }
}
swap-error-timer := Timer {
interval: 4s; running: false;
triggered => { root.swap-save-failed = false; self.running = false; }
}
function confirm-action(code: string, name: string) {
root.action-code = code; root.action-name = name; root.confirming = true;
}
@@ -138,12 +270,104 @@ export component GalleryView inherits Rectangle {
}
}
}
grid := Rectangle {
grid := TouchArea {
x: root.simplified ? 12px : toolbar.x;
y: root.simplified ? 12px : toolbar.y + toolbar.height + 12px;
width: parent.width - self.x * 2;
height: footer.y - 12px - self.y;
visible: root.focused.id == "";
private property <length> table-width: root.columns * root.card-width;
private property <length> table-height: root.rows * root.card-height;
Rectangle {
visible: root.count > 0;
x: 0px; y: 0px;
width: grid.table-width; height: grid.table-height;
background: white;
}
// One stable pointer owner survives page model replacements and cell resizing.
private property <int> column-line: clamp(round(self.mouse-x / root.card-width), 0, root.columns);
private property <int> row-line: clamp(round(self.mouse-y / root.card-height), 0, root.rows);
private property <bool> over-table: root.count > 0
&& self.mouse-x >= 0px && self.mouse-x <= grid.table-width
&& self.mouse-y >= 0px && self.mouse-y <= grid.table-height;
// Both sides of a shared separator must resize in the same direction.
private property <int> edge-x: !self.over-table || abs(self.mouse-x - self.column-line * root.card-width) > 6px
? 0 : self.column-line == 0 ? -1 : 1;
private property <int> edge-y: !self.over-table || abs(self.mouse-y - self.row-line * root.card-height) > 6px
? 0 : self.row-line == 0 ? -1 : 1;
private property <int> drag-x;
private property <int> drag-y;
private property <int> drag-columns;
private property <int> drag-rows;
private property <length> start-x;
private property <length> start-y;
private property <length> start-width;
private property <int> cursor-x: root.resizing ? self.drag-x : self.edge-x;
private property <int> cursor-y: root.resizing ? self.drag-y : self.edge-y;
private property <int> hover-index: root.cell-index(self.mouse-x, self.mouse-y);
mouse-cursor: root.dragging ? (root.drag-target >= 0 || root.drag-page-direction != 0 ? MouseCursor.grabbing : MouseCursor.not-allowed)
: self.cursor-x != 0 && self.cursor-y != 0
? (self.cursor-x == self.cursor-y ? MouseCursor.nwse-resize : MouseCursor.nesw-resize)
: self.cursor-x != 0 ? MouseCursor.ew-resize : self.cursor-y != 0 ? MouseCursor.ns-resize
: self.hover-index >= 0 ? MouseCursor.grab : MouseCursor.default;
function show-hint() {
if self.has-hover && !self.pressed && (self.edge-x != 0 || self.edge-y != 0) {
root.device-tooltip-id = "resize-grid";
root.device-tooltip-text = GalleryStrings.tr("拖动表格分隔线,同步调整所有设备大小", "Drag a table divider to resize all devices", "Потяните за разделитель таблицы, чтобы изменить размер всех устройств");
root.device-tooltip-x = grid.x + self.mouse-x;
root.device-tooltip-y = grid.y + self.mouse-y + 16px;
} else if root.device-tooltip-id == "resize-grid" { root.device-tooltip-id = ""; }
}
function resize-cells() {
if !root.resizing { return; }
// Distribute the movement across preceding cells so the chosen
// divider follows the pointer while every cell stays equal.
let dx = (self.mouse-x - self.start-x) * self.drag-x / self.drag-columns;
let dy = (self.mouse-y - self.start-y) * self.drag-y / self.drag-rows;
let delta = self.drag-x == 0 ? dy * root.preview-aspect-ratio
: self.drag-y == 0 ? dx
: (dx + dy / root.preview-aspect-ratio) / (1 + 1 / (root.preview-aspect-ratio * root.preview-aspect-ratio));
root.tile-width = clamp(self.start-width + delta, 184px, root.max-card-width) / 1px;
}
changed has-hover => { self.show-hint(); }
changed mouse-x => { self.show-hint(); }
changed mouse-y => { self.show-hint(); }
pointer-event(event) => {
if event.button == PointerEventButton.left && event.kind == PointerEventKind.down {
root.cancel-device-drag();
if self.edge-x != 0 || self.edge-y != 0 {
self.drag-x = self.edge-x; self.drag-y = self.edge-y;
self.drag-columns = max(1, self.column-line);
self.drag-rows = max(1, self.row-line);
self.start-x = self.mouse-x; self.start-y = self.mouse-y;
self.start-width = root.card-width;
root.resizing = true; root.device-tooltip-id = "";
} else if self.hover-index >= 0 {
drag-focus.focus();
root.dragged-tile = root.tiles[self.hover-index];
root.drag-source = root.dragged-tile.id; root.drag-page = root.page;
root.drag-start-x = self.mouse-x; root.drag-start-y = self.mouse-y;
root.drag-x = self.mouse-x; root.drag-y = self.mouse-y;
root.swap-save-failed = false; root.device-tooltip-id = "";
}
} else if event.kind == PointerEventKind.cancel {
root.resizing = false; root.cancel-device-drag();
} else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
if root.resizing { self.resize-cells(); root.resizing = false; }
else if root.drag-source != "" {
root.move-device-drag(self.mouse-x, self.mouse-y);
if !root.dragging && self.hover-index >= 0 && root.tiles[self.hover-index].id == root.drag-source {
let id = root.drag-source;
root.cancel-device-drag();
root.action(root.batch && !root.simplified ? "select" : "focus", id);
} else { root.finish-device-drag(); }
}
}
}
moved => {
if root.resizing { self.resize-cells(); }
else if root.drag-source != "" { root.move-device-drag(self.mouse-x, self.mouse-y); }
}
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", "Неизвестно");
@@ -151,18 +375,18 @@ export component GalleryView inherits Rectangle {
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: !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 : !tile.online ? DeviceCardStyle.offline-border : #c9cdd2;
visible: index < root.columns * root.rows;
x: mod(index, root.columns) * root.card-width;
y: floor(index / root.columns) * root.card-height;
width: root.card-width;
height: root.card-height;
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
background: !tile.online ? DeviceCardStyle.offline-background
: tile.selected ? #eaf3ff : root.batch && tile-touch.has-hover ? #f3f8fe : white;
Rectangle {
x: root.simplified ? 0px : 6px; y: self.x;
x: root.card-inset; y: self.x;
width: parent.width - self.x * 2;
height: root.simplified ? parent.height : parent.height - 36px;
height: root.card-preview-height;
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 {
@@ -170,14 +394,19 @@ export component GalleryView inherits Rectangle {
horizontal-alignment: center; vertical-alignment: center;
wrap: word-wrap;
}
if root.device-tooltip-visible && root.device-tooltip-id == tile.id: DeviceDetailsTip {
x: 4px; y: parent.height - self.height - 4px;
width: min(320px, parent.width - 8px); available-height: parent.height - 8px;
device-name: tile.name; device-id: tile.id; presence: tile-card.presence;
}
}
Text {
visible: !root.simplified; x: 9px; y: parent.height - 28px;
visible: !root.simplified; x: 9px; y: parent.height - 30px;
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;
height: 24px; 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;
x: parent.width - 27px; y: parent.height - 27px;
width: 18px; height: 18px; border-radius: 5px; border-width: 1px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
@@ -188,22 +417,23 @@ export component GalleryView inherits Rectangle {
horizontal-alignment: center; vertical-alignment: center;
}
}
tile-touch := TouchArea {
tile-touch := Rectangle {
x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 12px;
private property <bool> has-hover: grid.has-hover && grid.hover-index == index;
private property <bool> pressed: grid.pressed && root.drag-source == 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;
if root.resizing || root.drag-source != "" || (grid.mouse-y - tile-card.y >= tile-card.height - 30px && !root.simplified) { root.device-tooltip-id = ""; return; }
root.device-tooltip-id = tile.id;
}
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;
private property <bool> over-actions: grid.mouse-y - tile-card.y >= tile-card.height - 30px;
changed over-actions => { if self.has-hover { self.show-details(); } }
if tile-touch.has-hover && !root.dragging && !root.resizing && !root.batch && !root.simplified: DeviceCardActions {
x: parent.width - self.width; y: parent.height - 23px;
remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub");
connected: tile.connected;
edit-alias => { root.action("release", ""); root.edit-alias(tile.id, tile.name); }
@@ -214,6 +444,29 @@ export component GalleryView inherits Rectangle {
}
}
}
// Draw each table line once, continuously across occupied and empty cells.
for column in root.columns + 1: Rectangle {
visible: root.count > 0;
x: min(column * root.card-width, grid.table-width - 1px);
y: 0px;
width: 1px; height: grid.table-height;
background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
? #76aeea : #c9cdd2;
}
for row in root.rows + 1: Rectangle {
visible: root.count > 0;
x: 0px;
y: min(row * root.card-height, grid.table-height - 1px);
width: grid.table-width; height: 1px;
background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
? #76aeea : #c9cdd2;
}
if root.drag-target >= 0: Rectangle {
x: mod(root.drag-target, root.columns) * root.card-width + 1px;
y: floor(root.drag-target / root.columns) * root.card-height + 1px;
width: root.card-width - 2px; height: root.card-height - 2px;
background: #76aeea26; border-width: 2px; border-color: #4a94de;
}
if root.count == 0: Text {
text: root.simplified
? GalleryStrings.tr("暂无设备,退出精简模式后可添加", "No devices. Exit compact mode to add devices", "Нет устройств. Выйдите из компактного режима, чтобы добавить их")
@@ -313,7 +566,7 @@ export component GalleryView inherits 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 {
rail-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;
@@ -324,25 +577,27 @@ export component GalleryView inherits Rectangle {
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: tile.online ? #24272d : DeviceCardStyle.offline-preview;
x: 5px; y: 5px; width: 126px; height: root.rail-preview-height; 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: tile.online ? #e2e6ed : DeviceCardStyle.offline-placeholder; font-size: 10px; overflow: elide;
horizontal-alignment: center; vertical-alignment: center;
}
if root.device-tooltip-visible && root.device-tooltip-id == tile.id: DeviceDetailsTip {
x: 4px; y: parent.height - self.height - 4px;
width: parent.width - 8px; available-height: parent.height - 8px;
device-name: tile.name; device-id: tile.id; presence: rail-card.presence;
}
}
Text {
visible: !root.simplified;
x: 7px; y: 58px; width: 122px; height: 16px;
x: 7px; y: root.rail-preview-height + 6px; 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.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(); }
@@ -367,29 +622,19 @@ export component GalleryView inherits Rectangle {
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 {
x: 0px; width: parent.width - 20px; height: parent.height;
text: root.connected-summary + " · " + GalleryStrings.tr("网络状态", "Network", "Сеть");
connection-count := Text {
x: 0px; width: min(self.preferred-width, parent.width - network-button.width - 12px); height: parent.height;
text: root.connected-summary;
color: #62666d; vertical-alignment: center; font-size: 11px; 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 = ""; }
}
network-button := SecondaryButton {
x: connection-count.x + connection-count.width + 12px;
min-width: 64px; width: 64px; height: parent.height;
border-color: #c8cbd0; border-radius: 3px;
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
accessible-role: button;
accessible-label: self.text;
accessible-action-default => { self.clicked(); }
clicked => { root.device-tooltip-id = ""; root.network-open(); }
}
}
@@ -432,15 +677,57 @@ export component GalleryView inherits Rectangle {
}
}
}
HorizontalLayout {
pager := HorizontalLayout {
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"); } }
previous-page := CompactButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; }
CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
next-page := CompactButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
}
}
// Share one delayed tooltip across cards and keep it inside the view.
if root.device-tooltip-visible && root.device-tooltip-id != "": Rectangle {
for edge in 2: Rectangle {
visible: root.dragging && (edge == 0 ? root.page > 0 : root.page + 1 < root.pages);
private property <bool> active: root.drag-page-direction == (edge == 0 ? -1 : 1);
x: edge == 0 ? 0px : parent.width - self.width; y: 0px;
width: root.drag-edge-width; height: parent.height; z: 8;
background: self.active ? #76aeea38 : #76aeea14;
Text {
text: edge == 0 ? "‹" : "›"; font-size: 24px;
color: parent.active ? #357bc0 : #7ba2c9;
horizontal-alignment: center; vertical-alignment: center;
}
}
if root.dragging: Rectangle {
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
width: 224px; height: 54px; z: 10;
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: 4px;
Rectangle {
x: 8px; y: 9px; width: 64px; height: 36px;
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
if root.dragged-tile.has-frame && root.dragged-tile.online: Image {
x: 0px; y: 0px; width: parent.width; height: parent.height;
source: root.dragged-tile.frame; image-fit: contain;
}
}
Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; }
Text {
x: 80px; y: 29px; width: parent.width - 88px; height: 16px;
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page", "Удерживайте для перелистывания")
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство");
font-size: 10px; color: #527ca8; overflow: elide;
}
}
if root.swap-save-failed: Rectangle {
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
width: min(420px, parent.width - 32px); height: 36px; z: 10;
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: 4px;
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
}
}
// Resize hints follow the pointer; device details stay inside their preview.
if root.device-tooltip-visible && root.device-tooltip-id == "resize-grid": 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;
+69 -67
View File
@@ -265,19 +265,20 @@ component PasswordInput inherits Rectangle {
}
export component MainWindow inherits Window {
title: "CrossDesk";
title: root.gallery-mode ? "CrossDesk · " + GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub") : "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 640px;
preferred-height: 486px + root.titlebar-height;
preferred-height: root.minimum-window-height;
min-width: 640px;
max-width: root.gallery-mode ? 16384px : 640px;
min-height: 486px + root.titlebar-height;
max-height: root.gallery-mode ? 16384px : 486px + root.titlebar-height;
min-height: root.minimum-window-height;
max-height: root.gallery-mode ? 16384px : root.minimum-window-height;
no-frame: root.custom-titlebar;
background: root.wayland-titlebar ? transparent : white;
default-font-size: ImGuiFontStyle.base;
in-out property <bool> gallery-mode: false;
// Fixed role for this window instance; opening DeskHub creates another window.
in property <bool> gallery-mode: false;
in-out property <bool> gallery-simplified: false;
changed gallery-simplified => {
root.reset-gallery-transient();
@@ -316,8 +317,10 @@ export component MainWindow inherits Window {
in property <[GalleryTile]> gallery-tiles;
in property <[string]> gallery-groups;
in-out property <int> gallery-group-index: 0;
in property <int> gallery-columns: 3;
in property <int> gallery-rows: 2;
out property <int> gallery-columns: gallery.columns;
out property <int> gallery-rows: gallery.rows;
in-out property <float> gallery-tile-width: 216;
out property <bool> gallery-resizing: gallery.resizing;
in property <int> gallery-page: 0;
in property <int> gallery-pages: 1;
in property <int> gallery-count: 0;
@@ -335,11 +338,10 @@ export component MainWindow inherits Window {
in property <[GalleryRecentDevice]> gallery-recent-devices;
in property <int> gallery-recent-selected: 0;
in property <string> gallery-import-group;
out property <float> gallery-width: root.width / 1px;
out property <float> gallery-height: gallery.height / 1px;
callback change-mode(bool);
callback open-gallery();
callback toggle-gallery-fullscreen();
callback gallery-action(string, string);
callback gallery-swap(string, string) -> bool;
callback gallery-add-device(string, string, string, bool) -> bool;
callback gallery-import-recent(string) -> bool;
in-out property <bool> device-group-open: false;
@@ -367,7 +369,9 @@ export component MainWindow inherits Window {
in property <bool> window-active: true;
private property <length> titlebar-height:
root.custom-titlebar && !root.gallery-fullscreen ? (root.wayland-titlebar ? 32px : 31px) : 0px;
private property <length> content-header-height: 38px;
private property <length> content-header-height: root.gallery-mode ? 38px : 0px;
out property <length> minimum-window-height:
448px + root.content-header-height + root.titlebar-height;
private property <length> wayland-corner-radius: 12px;
in property <string> local-id: "";
in property <string> local-password: "";
@@ -585,7 +589,7 @@ export component MainWindow inherits Window {
x: 80px;
width: parent.width - 160px;
height: parent.height;
text: "CrossDesk";
text: root.title;
color: root.window-active ? #3d3d3d : #747474;
font-size: 12px;
font-weight: 600;
@@ -686,7 +690,7 @@ export component MainWindow inherits Window {
x: 30px;
width: parent.width - 122px;
height: parent.height;
text: "CrossDesk";
text: root.title;
color: #202124;
font-size: 12px;
vertical-alignment: center;
@@ -804,15 +808,19 @@ export component MainWindow inherits Window {
z: 7;
}
// App actions live in the content header; window controls and dragging
// remain in the native or Windows-specific title bar above it.
// The connection page shares its section heading row with the menu.
// Only DeskHub needs a separate toolbar above its content.
menu-button := IconButton {
x: parent.width - mode-switch.x - self.width;
y: mode-switch.y;
width: mode-switch.height;
height: mode-switch.height;
visible: !root.gallery-mode;
x: parent.width - 29px - self.width;
y: 10.5px;
width: 28px;
height: self.width;
icon: FontAwesomeIcons.bars;
icon-size: 14px;
border-width: 1px;
border-color: #c9cdd2;
border-radius: 4px;
hover-background: #e1e5eb;
pressed-background: #cbd1d9;
tooltip: root.announcement-unread > 0
@@ -822,7 +830,7 @@ export component MainWindow inherits Window {
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
// Unread messages take precedence so the menu has only one indicator.
if root.update-available && root.announcement-unread <= 0: Rectangle {
if !root.gallery-mode && root.update-available && root.announcement-unread <= 0: Rectangle {
x: menu-button.x + menu-button.width - self.width + 4px;
y: menu-button.y - 1px;
width: 16px;
@@ -863,7 +871,7 @@ export component MainWindow inherits Window {
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
}
if root.announcement-unread > 0: Rectangle {
if !root.gallery-mode && root.announcement-unread > 0: Rectangle {
x: menu-button.x + menu-button.width - self.width + 4px;
y: menu-button.y - 1px;
width: max(16px, unread-badge-label.preferred-width + 8px);
@@ -891,41 +899,23 @@ export component MainWindow inherits Window {
}
}
mode-switch := Rectangle {
if root.gallery-mode: Text {
x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px;
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: #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: #24272d;
font-weight: (index == 1) == root.gallery-mode ? 600 : 400;
horizontal-alignment: center; vertical-alignment: center; font-size: 12px;
}
TouchArea {
clicked => { root.change-mode(index == 1); }
}
}
text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub");
color: #24272d; font-weight: 600; font-size: 12px;
vertical-alignment: center;
}
if root.gallery-mode: CompactButton {
x: menu-button.x - 24px - 16px - self.width;
y: mode-switch.y; width: 94px; height: mode-switch.height;
x: parent.width - 29px - 24px - 8px - self.width;
y: root.content-header-height - self.height; width: 94px; height: 24px;
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: IconButton {
x: menu-button.x - self.width - 8px;
y: mode-switch.y; width: 24px; height: mode-switch.height;
x: parent.width - 29px - self.width;
y: root.content-header-height - self.height; width: 24px; height: 24px;
icon: root.gallery-fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
tooltip: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-gallery-fullscreen(); }
@@ -1058,7 +1048,7 @@ export component MainWindow inherits Window {
Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.border; }
SectionTitle { x: 356px; y: 9px; width: 256px; height: 31px; text: UiStrings.remote-desktop; }
SectionTitle { x: 356px; y: 9px; width: menu-button.x - self.x - 8px; height: 31px; text: UiStrings.remote-desktop; }
Rectangle {
x: 356px;
y: 42px;
@@ -1125,8 +1115,23 @@ export component MainWindow inherits Window {
Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; }
SectionTitle { x: 29px; y: 7px; height: 28px; text: UiStrings.recent-connections; }
GroupFilter {
SectionTitle { x: 29px; y: 7px; width: open-gallery-button.x - self.x - 12px; height: 28px; text: UiStrings.recent-connections; }
open-gallery-button := SecondaryButton {
x: recent-group-filter.x - self.width - 8px;
y: recent-group-filter.y;
min-width: 60px;
width: root.compact-language ? 60px : 72px;
height: recent-group-filter.height;
text-size: ImGuiFontStyle.base;
border-color: #c9cdd2;
border-radius: 4px;
text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub");
accessible-role: button;
accessible-label: self.text;
accessible-action-default => { root.open-gallery(); }
clicked => { root.open-gallery(); }
}
recent-group-filter := GroupFilter {
x: parent.width - self.width - 29px; y: 8px;
width: 155px; height: 28px;
model: root.recent-groups;
@@ -1338,9 +1343,11 @@ export component MainWindow inherits Window {
}
gallery := GalleryView {
y: root.content-header-height; width: parent.width;
height: parent.height - self.y - 24px;
height: parent.height - self.y;
visible: root.gallery-mode;
simplified: root.gallery-simplified;
window-active: root.window-active;
tile-width <=> root.gallery-tile-width;
network-open => { root.gallery-network-open = true; }
controlling: root.gallery-controlling;
pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); }
@@ -1348,7 +1355,6 @@ export component MainWindow inherits Window {
key-input(text, pressed, ctrl, alt, shift, meta) => { root.gallery-key(text, pressed, ctrl, alt, shift, meta); }
tiles: root.gallery-tiles; groups: root.gallery-groups;
group-index <=> root.gallery-group-index;
columns: root.gallery-columns; rows: root.gallery-rows;
page: root.gallery-page; pages: root.gallery-pages; count: root.gallery-count;
connected-count: root.gallery-connected; selected-count: root.gallery-selected;
selected-audio-available: root.gallery-selected-audio-available;
@@ -1359,6 +1365,7 @@ export component MainWindow inherits Window {
importing <=> root.gallery-importing; recent-devices: root.gallery-recent-devices;
recent-selected: root.gallery-recent-selected; import-group: root.gallery-import-group;
action(code, value) => { root.gallery-action(code, value); }
swap-devices(source, target) => { root.gallery-swap(source, target) }
add-device(id, password, group, remember) => { root.gallery-add-device(id, password, group, remember) }
import-recent(group) => { root.gallery-import-recent(group) }
edit-alias(id, name) => {
@@ -1369,8 +1376,8 @@ export component MainWindow inherits Window {
root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true;
}
}
// Shared server status bar stays visible in every connection and DeskHub view.
Rectangle {
// Server status and reconnect actions belong to the connection window.
if !root.gallery-mode: Rectangle {
y: parent.height - 24px;
height: 24px;
background: #fafbfc;
@@ -1481,7 +1488,7 @@ export component MainWindow inherits Window {
}
// Dismiss the title menu when clicking anywhere outside of it.
if root.menu-open: TouchArea {
if !root.gallery-mode && root.menu-open: TouchArea {
x: 0px;
y: 0px;
width: parent.width;
@@ -1494,7 +1501,7 @@ export component MainWindow inherits Window {
}
// Title menu.
if root.menu-open: Rectangle {
if !root.gallery-mode && root.menu-open: Rectangle {
x: menu-button.x + menu-button.width - self.width;
y: menu-button.y + menu-button.height + 2px;
width: max(80px, max(menu-settings-label.preferred-width,
@@ -1953,19 +1960,14 @@ export component MainWindow inherits Window {
DialogSurface {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(400px, parent.width - 40px); height: 446px;
width: min(400px, parent.width - 40px); height: min(446px, parent.height - 16px);
background: #f8fafc; border-radius: 14px; border-color: #e1e7ef;
drop-shadow-color: #14294424; drop-shadow-blur: 24px; drop-shadow-offset-y: 8px;
TouchArea { clicked => { focus-sink.focus(); } }
Text {
x: 18px; y: 18px; width: parent.width - 80px; height: 27px;
x: 18px; y: 18px; width: parent.width - 36px; height: 27px;
text: UiStrings.settings; font-size: 20px; font-weight: 600; color: #22324a;
}
SettingsButton {
x: parent.width - 90px; y: 18px; width: 76px; height: 28px; quiet: true;
text: GalleryStrings.tr("关闭", "Close", "Закрыть");
clicked => { root.cancel-settings(); root.settings-open = false; root.self-host-settings-open = false; }
}
Rectangle {
x: 18px; y: 56px; width: parent.width - 36px; height: 40px;
background: #eaf0f6; border-radius: 9px;
@@ -1995,7 +1997,7 @@ export component MainWindow inherits Window {
}
}
if root.settings-tab == 0: settings-scroll := ScrollView {
x: 18px; y: 108px; width: parent.width - 28px; height: 272px;
x: 18px; y: 108px; width: parent.width - 28px; height: parent.height - 174px;
viewport-y <=> root.settings-scroll-y;
VerticalLayout {
width: settings-scroll.width - 12px;
@@ -2049,15 +2051,15 @@ export component MainWindow inherits Window {
}
}
if root.settings-tab == 1: GallerySettings {
x: 18px; y: 108px; width: parent.width - 36px; height: 272px;
x: 18px; y: 108px; width: parent.width - 36px; height: parent.height - 174px;
resolution-index <=> root.gallery-resolution-draft;
frame-rate-index <=> root.gallery-frame-rate-draft;
bandwidth-index <=> root.gallery-bandwidth-draft;
error: root.gallery-settings-error;
}
Rectangle { x: 18px; y: 382px; width: parent.width - 36px; height: 1px; background: #e6ebf2; }
Rectangle { x: 18px; y: parent.height - 64px; width: parent.width - 36px; height: 1px; background: #e6ebf2; }
HorizontalLayout {
x: 18px; y: 396px; width: parent.width - 36px; height: 32px; spacing: 10px;
x: 18px; y: parent.height - 50px; width: parent.width - 36px; height: 32px; spacing: 10px;
if root.settings-tab == 1: SettingsButton {
width: 110px; quiet: true;
text: GalleryStrings.tr("恢复默认", "Reset defaults", "По умолчанию");
@@ -19,12 +19,11 @@
namespace crossdesk {
namespace {
__weak NSWindow *main_window = nil;
bool main_fullscreen = false;
bool main_gallery = false;
NSRect main_restore_frame = NSZeroRect;
NSWindowStyleMask main_restore_style = NSWindowStyleMaskTitled;
NSApplicationPresentationOptions main_restore_presentation;
__weak NSWindow *gallery_window = nil;
bool gallery_fullscreen = false;
NSRect gallery_restore_frame = NSZeroRect;
NSWindowStyleMask gallery_restore_style = NSWindowStyleMaskTitled;
NSApplicationPresentationOptions gallery_restore_presentation;
__weak NSWindow *stream_window = nil;
__weak NSView *stream_slint_view = nil;
CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil;
@@ -118,9 +117,8 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
if (zoom_button == nil) return false;
main_window = window;
main_gallery = gallery;
if (main_fullscreen) return true;
if (gallery) gallery_window = window;
if (gallery && gallery_fullscreen) return true;
if (gallery)
window.styleMask |= NSWindowStyleMaskResizable;
else
@@ -149,63 +147,63 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
}
}
bool SetMainWindowFullscreen(bool fullscreen) {
bool SetGalleryWindowFullscreen(bool fullscreen) {
@autoreleasepool {
if (!main_window || (fullscreen && !main_gallery)) return false;
if (main_fullscreen == fullscreen) return true;
if (!gallery_window) return false;
if (gallery_fullscreen == fullscreen) return true;
if (fullscreen) {
NSScreen *screen = main_window.screen ?: NSScreen.mainScreen;
NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen;
if (!screen) return false;
main_restore_frame = main_window.frame;
main_restore_style = main_window.styleMask;
main_restore_presentation = NSApp.presentationOptions;
main_fullscreen = true;
main_window.styleMask |= NSWindowStyleMaskFullSizeContentView;
main_window.titleVisibility = NSWindowTitleHidden;
main_window.movable = NO;
SetTitlebarButtonsHidden(main_window, YES);
gallery_restore_frame = gallery_window.frame;
gallery_restore_style = gallery_window.styleMask;
gallery_restore_presentation = NSApp.presentationOptions;
gallery_fullscreen = true;
gallery_window.styleMask |= NSWindowStyleMaskFullSizeContentView;
gallery_window.titleVisibility = NSWindowTitleHidden;
gallery_window.movable = NO;
SetTitlebarButtonsHidden(gallery_window, YES);
NSApp.presentationOptions |=
NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar;
// Let the next event-loop frame paint the updated Slint layout. A
// synchronous draw here exposes stale grid geometry during the resize.
[main_window setFrame:screen.frame display:NO animate:NO];
[gallery_window setFrame:screen.frame display:NO animate:NO];
} else {
main_fullscreen = false;
NSApp.presentationOptions = main_restore_presentation;
main_window.styleMask = main_restore_style;
main_window.titleVisibility = NSWindowTitleVisible;
main_window.movable = YES;
[main_window standardWindowButton:NSWindowCloseButton].hidden = NO;
[main_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO;
// The main window uses the in-app fullscreen action in both modes.
HideMainWindowZoomButton(main_window);
[main_window setFrame:main_restore_frame display:NO animate:NO];
gallery_fullscreen = false;
NSApp.presentationOptions = gallery_restore_presentation;
gallery_window.styleMask = gallery_restore_style;
gallery_window.titleVisibility = NSWindowTitleVisible;
gallery_window.movable = YES;
[gallery_window standardWindowButton:NSWindowCloseButton].hidden = NO;
[gallery_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO;
// DeskHub uses its in-app fullscreen action.
HideMainWindowZoomButton(gallery_window);
[gallery_window setFrame:gallery_restore_frame display:NO animate:NO];
}
main_window.contentView.needsDisplay = YES;
gallery_window.contentView.needsDisplay = YES;
return true;
}
}
bool IsMainWindowFullscreen() { return main_fullscreen; }
bool IsMainWindowActive() { return main_window && [NSApp isActive] && [main_window isKeyWindow]; }
bool IsGalleryWindowFullscreen() { return gallery_fullscreen; }
bool IsGalleryWindowActive() { return gallery_window && [NSApp isActive] && [gallery_window isKeyWindow]; }
bool MaximizeMainWindowWithoutAnimation() {
bool MaximizeGalleryWindowWithoutAnimation() {
@autoreleasepool {
if (!main_window || !main_gallery || main_fullscreen) return false;
NSScreen *screen = main_window.screen ?: NSScreen.mainScreen;
if (!gallery_window || gallery_fullscreen) return false;
NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen;
if (!screen) return false;
[main_window setFrame:screen.visibleFrame display:YES animate:NO];
[gallery_window setFrame:screen.visibleFrame display:YES animate:NO];
return true;
}
}
bool IsMainWindowMaximized() {
bool IsGalleryWindowMaximized() {
@autoreleasepool {
if (!main_window || !main_gallery || main_fullscreen) return false;
NSScreen *screen = main_window.screen;
if (!gallery_window || gallery_fullscreen) return false;
NSScreen *screen = gallery_window.screen;
if (!screen) return false;
// isZoomed can report true for a constrained compact window. Only treat
// the ordinary window as maximized when it fills the usable screen.
const NSRect frame = main_window.frame;
const NSRect frame = gallery_window.frame;
const NSRect available = screen.visibleFrame;
return std::abs(frame.origin.x - available.origin.x) < 1 &&
std::abs(frame.origin.y - available.origin.y) < 1 &&
+7 -7
View File
@@ -8,20 +8,20 @@ namespace crossdesk {
// left-mouse-down event. Returns false when no suitable native event exists.
bool StartNativeWindowDrag();
// Sets a white native titlebar and hides its zoom button in both main-window
// modes. DeskHub remains resizable. |slint_view| is its AppKit NSView*.
// Configures the connection or DeskHub window with a white native titlebar.
// Only DeskHub is resizable. |slint_view| is its AppKit NSView*.
// Returns false while the native window or its titlebar controls are not ready.
bool ConfigureMainWindowChrome(void* slint_view, bool gallery = false);
// Winit's initial macOS position is a content origin, while subsequent moves
// use the outer frame. Return the logical titlebar inset for initial placement.
float MainWindowInitialPositionYOffset();
bool SetMainWindowFullscreen(bool fullscreen);
bool IsMainWindowFullscreen();
bool IsMainWindowActive();
bool SetGalleryWindowFullscreen(bool fullscreen);
bool IsGalleryWindowFullscreen();
bool IsGalleryWindowActive();
// AppKit's animated zoom spins a nested run loop and cannot be called from a
// Slint timer. Restore maximization by setting the visible screen frame directly.
bool MaximizeMainWindowWithoutAnimation();
bool IsMainWindowMaximized();
bool MaximizeGalleryWindowWithoutAnimation();
bool IsGalleryWindowMaximized();
// Configures live resize and replaces the stream window's native Space
// fullscreen action with an immediate, single-window fullscreen transition.
+13 -10
View File
@@ -1,6 +1,8 @@
# 桌面墙(DeskHub)
主窗口标题栏下方可以切换“连接”和“桌面墙”,同一行右侧保留纯文字“精简模式”按钮、全屏图标和菜单。设备管理栏集中显示分组、设备数量、“选择”和“添加”入口。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。
主窗口“近期连接”标题行右侧、分组筛选左边的“桌面墙”按钮打开独立的桌面墙窗口。菜单与上方桌面标题共用一行,连接窗口保持紧凑尺寸。重复点击按钮会唤起已有桌面墙窗口,最小化时恢复显示,不创建重复窗口。每次启动软件只显示连接窗口;桌面墙首次打开时恢复保存的位置、尺寸及全屏状态;没有已保存尺寸时默认采用 960 × 720 逻辑像素(含标题栏,当前 macOS 的内容区为 960 × 688),两个窗口分别记忆位置。关闭桌面墙只隐藏该窗口并释放远程操控,保留设备连接;再次点击“桌面墙”即可打开。已断开的显示器所对应的位置会修正到可用屏幕内。
桌面墙窗口顶部显示“桌面墙”标题,右侧保留纯文字“精简模式”按钮和全屏图标,不显示菜单及消息、更新角标。设备管理栏集中显示分组、设备数量、“选择”和“添加”入口。桌面墙可以单独调整尺寸及全屏,不改变连接窗口的尺寸和位置。全局设置从主窗口菜单进入;设备操作和桌面墙连接的密码弹窗显示在桌面墙窗口内。
全屏按钮通过短暂淡出、等待窗口尺寸稳定、淡入完成切换,常规总时长约 0.2 秒;过渡期间屏蔽设备操作与重复全屏点击。窗口尺寸变化会立即更新网格,避免在新窗口中先显示旧的行列布局。过渡复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会恢复内容与交互。
@@ -12,19 +14,20 @@
- “添加 → 从近期连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。
- 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。
- “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。
- 卡片名称栏只显示设备名,长名称省略,不再显示状态圆点。未在线的设备(离线或在线状态未知)整张卡片使用灰色,包括预览背景、边框和名称;在线但尚未连接的设备保持正常样式。选中边框、复选框和操作入口仍可用。悬停画面可查看完整名称、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 或点击列表外关闭;打开列表会释放远程操控。
- 网格中每台设备的预览区域统一固定为 16:9,普通模式的设备名称栏位于画面下方;其他比例的远端桌面留边完整显示。设备信息提示显示在该设备预览区域内部左下角,宽高受画面范围限制;过长的名称省略显示,保留 ID 和在线状态,不遮挡相邻设备或底部操作栏,精简模式及放大视图的缩略设备同样适用。设备直接放在连续表格的单元格中,单元格之间没有卡片间距,共用贯通整行、整列的单条分隔线;空单元格保留表格线,不再为每台设备绘制独立边框和右下角拖拽标记。拖动任意横向、竖向分隔线或交点,可同步放大、缩小全部单元格,预览区域始终保持 16:9;分隔线两侧的拖动方向一致。普通和精简模式共用单元格宽度,表格按实际尺寸自动换行、调整分页容量;窗口较小时限制格子大小,避免超出可用区域,窗口恢复后继续使用原尺寸。宽度至少为 184 逻辑像素、至多为 960 逻辑像素,并受当前可用区域限制;松开鼠标后保存尺寸,重启后恢复。拖动分隔线不会触发展开、选择或设备操作。翻页不关闭连接,分组内顺序不会按在线状态自动重排。
- 按住单元格内的设备画面或名称拖动,可与另一台设备交换位置;目标单元格高亮,鼠标旁显示设备缩略图和松开提示。跨页时保持按住鼠标,将设备拖到窗口内容区左、右边缘内侧 32 逻辑像素范围,或上一页、下一页按钮,悬停 700 毫秒后翻页;边缘显示方向提示并在触发区域高亮,继续停留可连续翻页,到首尾页自动停止。翻页期间保留拖动源,末页设备不足一整页时也可继续拖动,移到目标设备后松开完成交换;离开翻页区域会重新计算悬停时间,在可翻页的边缘区域或按钮上松开只取消拖动,不会误与边缘下方的设备换位;窗口外不触发翻页。普通、精简及批量选择模式均支持,短距离点击仍执行原来的查看或选择操作。拖到自身、空单元格、分隔线或表格外不会换位;Esc、窗口失焦、切换分组或调整窗口大小会取消拖动。交换后的顺序保存到本地,并应用于所有分组视图,不修改分组、批量选择、连接或静音状态;保存失败时保持原顺序并提示重试。仅活动会话也可换位,排序不会自动将它加入保存的桌面墙成员。
- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部固定宽度的文字按钮高亮显示“退出精简”,不带图标;全屏入口仍可见。底部保留连接计数、网络详情入口和分页,不显示服务器状态栏。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。
- 主窗口菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,桌面墙处于普通、精简、展开或全屏视图时,也可通过主窗口菜单打开设置。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。
- 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。
- 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。
- 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。
- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看完整名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通模式缩略图栏高 90px,在保留画面大小的同时将空间留给主预览。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。
- 点击“选择”进入批量模式,“完成”退出;批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏中的已选数量和“全选/取消全选”使用固定宽度,切换选中数量或按钮文字时,后续按钮和分页保持原位。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接、断开、“更多”及分页控件在同一底栏显示;静音和文件分发收进“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
@@ -36,7 +39,7 @@
15 帧订阅使用编码前发送节流和稳定的逐显示流时间预算,并采用保持分辨率策略,避免低帧率触发交互视频的画质降级。30/60 帧选项使用编码器自身的交互帧率控制。仅向声明 `supports_deskhub_fps15` 的对端发送 `fps=15`;缺少该能力的 v1/v2 对端使用原有 `fps=60, frame_rate=30` 格式回退到 30 FPS,未声明预览能力的更旧版本保持原行为。协议仍接受旧控制端的 1/8 帧请求,但设置界面不再提供这两个档位。
预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;外层圆角卡片与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。
预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;表格单元格与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。
连接旧版被控端时,若仅声明 `supports_deskhub_preview`,控制端发送原有 v1 订阅;若未声明预览能力,则不发送该订阅,沿用其视频策略。旧控制端连接新版被控端时继续使用原分辨率;v1 订阅也会清除此前的尺寸限制。只有同时声明 `supports_deskhub_preview` 与 `supports_deskhub_resolution` 的被控端才会收到 v2 尺寸请求,旧版本不承诺发送端节省资源。
@@ -66,9 +69,9 @@ MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, he
连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。
批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。
批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态(含共享格子宽度)保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。
`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,并通过可选的 `stream_settings` 保存分辨率预设及帧率。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。
`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,通过可选的 `device_order` 保存手动排列顺序,并通过可选的 `stream_settings` 保存分辨率预设及帧率。没有手动顺序的旧配置继续使用原有排列。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。
批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。