From 2cf0d79543ecaafe6c9c8f1f409c6a2501ae4cc4 Mon Sep 17 00:00:00 2001 From: kunkundi Date: Wed, 14 Oct 2026 19:29:08 +0800 Subject: [PATCH] [ui] refine desktop layouts and unify control styling --- .../src/gui/application/gui_application.cpp | 17 +- .../features/gallery/gallery_window_state.h | 21 +- apps/desktop/src/gui/ui/common.slint | 37 +- .../src/gui/ui/device_card_actions.slint | 4 +- .../src/gui/ui/device_group_dialog.slint | 24 +- .../src/gui/ui/gallery_menu_button.slint | 6 +- .../src/gui/ui/gallery_network_dialog.slint | 8 +- .../desktop/src/gui/ui/gallery_settings.slint | 14 +- apps/desktop/src/gui/ui/gallery_view.slint | 82 +- apps/desktop/src/gui/ui/group_filter.slint | 13 +- apps/desktop/src/gui/ui/main_window.slint | 1284 ++++++++--------- apps/desktop/src/gui/ui/message_panel.slint | 12 +- .../src/gui/ui/rounded_line_edit.slint | 50 + apps/desktop/src/gui/ui/server_window.slint | 11 +- .../src/gui/ui/settings_controls.slint | 13 +- apps/desktop/src/gui/ui/stream_window.slint | 27 +- apps/desktop/src/gui/ui/ui_radius.slint | 8 + .../src/gui/ui/workbench_controls.slint | 124 ++ apps/desktop/tests/slint_ui_smoke_test.cpp | 427 +++++- 19 files changed, 1366 insertions(+), 816 deletions(-) create mode 100644 apps/desktop/src/gui/ui/rounded_line_edit.slint create mode 100644 apps/desktop/src/gui/ui/ui_radius.slint create mode 100644 apps/desktop/src/gui/ui/workbench_controls.slint diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 75e4826d..6d9802d6 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -4973,6 +4973,7 @@ void GuiApplication::RestoreWindowGeometry(bool before_show, auto& window = component->window(); const auto current_size = window.size(); const bool wall_mode = gallery_window; + const float compact_width = component->get_minimum_window_width(); const float compact_height = component->get_minimum_window_height(); if (!before_show && ui_->loaded_window_scale[gallery_window] > 0) { const float ratio = window.scale_factor() / ui_->loaded_window_scale[gallery_window]; @@ -4986,7 +4987,8 @@ void GuiApplication::RestoreWindowGeometry(bool before_show, } ui_->loaded_window_scale[gallery_window] = 0; } - auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); + auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), + compact_height, compact_width); const auto& position = gallery_window ? placement.wall_position : placement.connection_position; @@ -5024,7 +5026,8 @@ void GuiApplication::RestoreWindowGeometry(bool before_show, // placement. Only recover coordinates when its display is unavailable. if (!found && available.w > 0 && available.h > 0) { const auto margin = uint32_t(32 * window.scale_factor()); - const auto minimum = placement.RestoreSize(false, window.scale_factor(), compact_height); + const auto minimum = placement.RestoreSize(false, window.scale_factor(), + compact_height, compact_width); safe_size.width = std::max(minimum.width, std::min(safe_size.width, uint32_t(available.w))); safe_size.height = std::max(minimum.height, std::min( @@ -5080,6 +5083,7 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) { auto& placement = gallery_window ? ui_->gallery_window : ui_->connection_window; const bool wall_mode = gallery_window; const bool transitioning = gallery_window && ui_->gallery_transition.Active(); + const float compact_width = component->get_minimum_window_width(); const float compact_height = component->get_minimum_window_height(); #if defined(__APPLE__) const bool fullscreen = gallery_window && IsGalleryWindowFullscreen(); @@ -5091,16 +5095,17 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) { if (!transitioning && placement.RecoverUndersized(wall_mode, window.position(), current_size, window.scale_factor(), window.is_visible(), window.is_minimized(), fullscreen, maximized, - compact_height)) { + compact_height, compact_width)) { ui_->restore_ticks[gallery_window] = 0; const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), - compact_height); + compact_height, compact_width); LOG_WARN("DeskHub undersized window: wall={} client={}x{} scale={}; restoring={}x{}", wall_mode, current_size.width, current_size.height, window.scale_factor(), target.width, target.height); } if (placement.Restoring() && ++ui_->restore_ticks[gallery_window] == 120) { - const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); + const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), + compact_height, compact_width); LOG_WARN("DeskHub window restore delayed: wall={} client={}x{} target={}x{} scale={} fullscreen={} maximized={}", wall_mode, current_size.width, current_size.height, target.width, target.height, window.scale_factor(), fullscreen, maximized); @@ -5114,7 +5119,7 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) { current_size.height, window.scale_factor(), fullscreen, maximized); } else if (window.is_visible() && !window.is_minimized() && !transitioning) { placement.Observe(wall_mode, window.position(), current_size, window.scale_factor(), - fullscreen, maximized, compact_height); + fullscreen, maximized, compact_height, compact_width); } } diff --git a/apps/desktop/src/gui/features/gallery/gallery_window_state.h b/apps/desktop/src/gui/features/gallery/gallery_window_state.h index fbcbd980..c708da9d 100644 --- a/apps/desktop/src/gui/features/gallery/gallery_window_state.h +++ b/apps/desktop/src/gui/features/gallery/gallery_window_state.h @@ -24,14 +24,16 @@ struct WindowState { void Observe(bool wall, slint::PhysicalPosition position, slint::PhysicalSize size, float scale, bool fullscreen, - bool maximized, float compact_height = 486) { + bool maximized, float compact_height = 486, + float compact_width = 640) { if (Restoring()) return; if (!wall) { connection_position = position; return; } - if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height)) + if (!fullscreen && !maximized && + IsUndersized(size, scale, compact_height, compact_width)) return; scale = std::max(scale, 1.0f); wall_fullscreen = fullscreen; @@ -46,12 +48,13 @@ struct WindowState { bool RecoverUndersized(bool wall, slint::PhysicalPosition position, slint::PhysicalSize size, float scale, bool visible, bool minimized, bool fullscreen, bool maximized, - float compact_height = 486) { + float compact_height = 486, + float compact_width = 640) { // Hidden/minimized/uninitialized windows can report temporary tiny bounds. // Leave those alone, and let an in-flight restoration retain its target. if (Restoring() || !visible || minimized || fullscreen || maximized || size.width == 0 || size.height == 0 || - !IsUndersized(size, scale, compact_height)) + !IsUndersized(size, scale, compact_height, compact_width)) return false; if (wall) { wall_position = position; @@ -66,9 +69,11 @@ struct WindowState { } slint::PhysicalSize RestoreSize(bool wall, float scale, - float compact_height = 486) const { + float compact_height = 486, + float compact_width = 640) const { const float width = - wall && wall_size ? std::max(640.0f, wall_size->width) : 640; + wall && wall_size ? std::max(compact_width, wall_size->width) + : compact_width; const float height = wall && wall_size ? std::max(compact_height, wall_size->height) : compact_height; @@ -157,9 +162,9 @@ private: std::optional target_, last_size_; int matches_ = 0; static bool IsUndersized(slint::PhysicalSize size, float scale, - float compact_height) { + float compact_height, float compact_width) { scale = std::max(scale, 1.0f); - return double(size.width) + 1 < 640 * scale || + return double(size.width) + 1 < compact_width * scale || double(size.height) + 1 < compact_height * scale; } static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) { diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 68a62ef9..27c09434 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; // The legacy client uses ImGui::StyleColorsLight(). Keep the structural line // colors in one place so the Slint renderer produces the same visual weight. export global ImGuiLineStyle { @@ -8,7 +9,7 @@ export global ImGuiLineStyle { } export global FloatingBarStyle { - out property corner-radius: 6px; + out property corner-radius: UiRadius.menu; } // ImGui loads a 32px font atlas and applies per-window font scales. These @@ -76,7 +77,7 @@ export component IconButton inherits Rectangle { background: touch.pressed ? (danger ? #d92d20 : root.pressed-background) : touch.has-hover ? (danger ? #f04438 : root.hover-background) : root.normal-background; - border-radius: 4px; + border-radius: UiRadius.control; opacity: root.enabled ? 1 : 0.4; Text { @@ -109,7 +110,7 @@ export component IconButton inherits Rectangle { width: tip.preferred-width + 12px; height: 24px; background: #22252be8; - border-radius: 4px; + border-radius: UiRadius.control; z: 20; tip := Text { text: root.tooltip; @@ -136,7 +137,7 @@ export component ImageButton inherits Rectangle { background: touch.pressed ? (danger ? #d92d20 : #d9dde5) : touch.has-hover ? (danger ? #f04438 : #edf0f5) : transparent; - border-radius: 4px; + border-radius: UiRadius.control; Image { x: (parent.width - self.width) / 2; @@ -157,7 +158,7 @@ export component ImageButton inherits Rectangle { width: tip.preferred-width + 12px; height: 24px; background: #22252be8; - border-radius: 4px; + border-radius: UiRadius.control; z: 20; tip := Text { text: root.tooltip; @@ -175,6 +176,8 @@ export component CompactComboBox inherits Rectangle { in property enabled: true; in property subtle: false; in property show-arrow: true; + in property normal-border-color: root.subtle ? #dfe6ef : #bfc2c7; + in property hover-border-color: root.normal-border-color; property popup-animation-enabled: false; property popup-progress: 0.0; callback selected(int); @@ -189,8 +192,8 @@ export component CompactComboBox inherits Rectangle { height: 24px; background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1; border-width: 1px; - border-color: root.subtle ? #dfe6ef : #bfc2c7; - border-radius: root.subtle ? 6px : 3px; + border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color; + border-radius: UiRadius.control; Text { x: root.subtle ? 10px : 5px; @@ -207,6 +210,8 @@ export component CompactComboBox inherits Rectangle { height: parent.height - 2px; y: 1px; background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc; + border-top-right-radius: UiRadius.control - 1px; + border-bottom-right-radius: UiRadius.control - 1px; Text { text: FontAwesomeIcons.angle-down; color: root.subtle ? #79879a : #202124; @@ -223,7 +228,7 @@ export component CompactComboBox inherits Rectangle { } } } - TouchArea { + combo-touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.popup-animation-enabled = false; @@ -263,8 +268,8 @@ export component CompactComboBox inherits Rectangle { opacity: root.popup-progress; background: white; border-width: 1px; - border-color: root.subtle ? #dfe6ef : #bfc2c7; - border-radius: root.subtle ? 6px : 3px; + border-color: root.normal-border-color; + border-radius: UiRadius.menu; clip: true; for choice[index] in root.model: Rectangle { y: index * 23px; @@ -288,8 +293,8 @@ export component CompactComboBox inherits Rectangle { height: parent.height; background: transparent; border-width: 1px; - border-color: root.subtle ? #dfe6ef : #bfc2c7; - border-radius: root.subtle ? 6px : 3px; + border-color: root.normal-border-color; + border-radius: UiRadius.menu; z: 2; } } @@ -361,7 +366,7 @@ export component CompactButton inherits Rectangle { : root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7; border-width: 1px; border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0; - border-radius: 3px; + border-radius: UiRadius.control; Text { text: root.text; color: root.enabled ? #24272d : #8e9298; @@ -385,7 +390,7 @@ export component PrimaryButton inherits Rectangle { : touch.pressed ? #1849a9 : touch.has-hover ? #2970d6 : #2463c7; - border-radius: 5px; + border-radius: UiRadius.control; Text { text: root.text; @@ -414,7 +419,7 @@ export component SecondaryButton inherits Rectangle { : touch.has-hover ? #f3f4f6 : white; border-width: 1px; border-color: #c9ced7; - border-radius: 5px; + border-radius: UiRadius.control; clip: true; Text { @@ -471,5 +476,5 @@ export component DialogSurface inherits Rectangle { background: white; border-width: 1px; border-color: ImGuiLineStyle.border; - border-radius: 8px; + border-radius: UiRadius.surface; } diff --git a/apps/desktop/src/gui/ui/device_card_actions.slint b/apps/desktop/src/gui/ui/device_card_actions.slint index c63ce44b..66053f14 100644 --- a/apps/desktop/src/gui/ui/device_card_actions.slint +++ b/apps/desktop/src/gui/ui/device_card_actions.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { IconButton, FontAwesomeIcons } from "common.slint"; import { GalleryStrings } from "device_strings.slint"; @@ -12,6 +13,7 @@ export global DeviceCardStyle { component CardAction inherits IconButton { width: 26px; height: 23px; + border-radius: UiRadius.control; icon-size: 12px; icon-color: white; hover-background: #ffffff2e; pressed-background: #598cf273; @@ -28,7 +30,7 @@ export component DeviceCardActions inherits Rectangle { callback connect-device(); callback disconnect-device(); width: DeviceCardStyle.toolbar-width; height: 23px; - background: #4a4e55dc; border-radius: 5px; + background: #4a4e55dc; border-radius: UiRadius.control; HorizontalLayout { spacing: 0px; CardAction { diff --git a/apps/desktop/src/gui/ui/device_group_dialog.slint b/apps/desktop/src/gui/ui/device_group_dialog.slint index 47cacd17..8eb54586 100644 --- a/apps/desktop/src/gui/ui/device_group_dialog.slint +++ b/apps/desktop/src/gui/ui/device_group_dialog.slint @@ -1,4 +1,6 @@ -import { ScrollView, LineEdit } from "std-widgets.slint"; +import { UiRadius } from "ui_radius.slint"; +import { ScrollView } from "std-widgets.slint"; +import { RoundedLineEdit } from "rounded_line_edit.slint"; import { CompactButton, ImGuiCheckBox } from "common.slint"; import { GalleryStrings } from "device_strings.slint"; @@ -23,7 +25,7 @@ export component DeviceGroupDialog inherits Rectangle { y: (parent.height - self.height) / 2; width: min(360px, parent.width - 40px); height: 208px + group-list.height + (root.error == "" ? 0px : 32px); - background: white; border-radius: 8px; + background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; TouchArea {} Text { @@ -42,7 +44,8 @@ export component DeviceGroupDialog inherits Rectangle { group-list := Rectangle { x: 18px; y: 95px; width: parent.width - 36px; height: min(5, max(1, root.options.length)) * 32px + 2px; - background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px; + background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface; + clip: true; ScrollView { x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; viewport-width: self.width; @@ -64,8 +67,21 @@ export component DeviceGroupDialog inherits Rectangle { } } } + // Keep row hover backgrounds inside the rounded list on every renderer. + Rectangle { + x: -UiRadius.surface; y: -UiRadius.surface; + width: parent.width + 2 * UiRadius.surface; + height: parent.height + 2 * UiRadius.surface; + border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface; + border-color: white; + } + Rectangle { + width: parent.width; height: parent.height; + border-width: 1px; border-radius: UiRadius.surface; + border-color: #e1e5eb; + } } - LineEdit { + RoundedLineEdit { x: 18px; y: group-list.y + group-list.height + 12px; width: parent.width - 36px; height: 30px; text <=> root.new-group; diff --git a/apps/desktop/src/gui/ui/gallery_menu_button.slint b/apps/desktop/src/gui/ui/gallery_menu_button.slint index bd17af3b..a94c3f0e 100644 --- a/apps/desktop/src/gui/ui/gallery_menu_button.slint +++ b/apps/desktop/src/gui/ui/gallery_menu_button.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { CompactButton, FontAwesomeIcons } from "common.slint"; export struct GalleryMenuItem { label: string, enabled: bool } @@ -61,6 +62,7 @@ export component GalleryMenuButton inherits Rectangle { key-pressed(event) => { return root.handle-key(event); } CompactButton { width: parent.width; height: parent.height; + border-radius: UiRadius.control; enabled: root.enabled; primary: root.primary; clicked => { trigger-focus.focus(); root.open-menu(); } } @@ -86,10 +88,10 @@ export component GalleryMenuButton inherits Rectangle { popup-focus := FocusScope { key-pressed(event) => { return root.handle-key(event); } Rectangle { - background: white; border-width: 1px; border-color: #c9cdd2; border-radius: 4px; + background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu; for item[index] in root.items: Rectangle { x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px; - border-radius: 3px; + border-radius: UiRadius.control; 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); } diff --git a/apps/desktop/src/gui/ui/gallery_network_dialog.slint b/apps/desktop/src/gui/ui/gallery_network_dialog.slint index 42ee97c6..fe9e3d7b 100644 --- a/apps/desktop/src/gui/ui/gallery_network_dialog.slint +++ b/apps/desktop/src/gui/ui/gallery_network_dialog.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { GalleryStrings } from "device_strings.slint"; import { CompactButton } from "common.slint"; import { ScrollView } from "std-widgets.slint"; @@ -137,7 +138,7 @@ component NetworkRow inherits Rectangle { } if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle { x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px); - height: 26px; background: #293746; border-radius: 4px; z: 10; + height: 26px; background: #293746; border-radius: UiRadius.control; z: 10; name-tip := Text { x: 10px; width: parent.width - 20px; height: parent.height; text: root.device.name; font-size: 11px; color: white; @@ -172,7 +173,7 @@ export component GalleryNetworkDialog inherits Rectangle { y: (parent.height - self.height) / 2; width: min(760px, parent.width - 32px); height: min(120px + root.list-height, parent.height - 32px); - background: white; border-radius: 8px; + background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #c6cdd5; TouchArea {} FocusScope { @@ -188,6 +189,7 @@ export component GalleryNetworkDialog inherits Rectangle { font-size: 17px; font-weight: 600; color: #293746; } CompactButton { + border-radius: UiRadius.control; x: parent.width - 80px; y: 17px; width: 60px; height: 26px; text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); } } @@ -199,7 +201,7 @@ export component GalleryNetworkDialog inherits Rectangle { } NetworkRow { x: 12px; y: 78px; width: parent.width - 38px; height: 30px; - heading: true; background: #f3f5f8; border-radius: 4px; + heading: true; background: #f3f5f8; border-radius: UiRadius.control; device: { name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"), }; diff --git a/apps/desktop/src/gui/ui/gallery_settings.slint b/apps/desktop/src/gui/ui/gallery_settings.slint index d19f16b5..2c428e7d 100644 --- a/apps/desktop/src/gui/ui/gallery_settings.slint +++ b/apps/desktop/src/gui/ui/gallery_settings.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { GalleryStrings } from "device_strings.slint"; import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint"; @@ -13,6 +14,7 @@ export component GallerySettings inherits Rectangle { label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение"); label-reserve: self.width - 112px; SettingsSelect { + border-radius: UiRadius.control; x: 136px; y: 4px; width: parent.width - 150px; current-index <=> root.resolution-index; model: [GalleryStrings.tr("自动", "Auto", "Авто"), @@ -24,6 +26,7 @@ export component GallerySettings inherits Rectangle { label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров"); label-reserve: self.width - 112px; SettingsSelect { + border-radius: UiRadius.control; x: 136px; y: 4px; width: parent.width - 150px; current-index <=> root.frame-rate-index; model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"]; @@ -33,22 +36,15 @@ export component GallerySettings inherits Rectangle { label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео"); label-reserve: self.width - 112px; divider: false; SettingsSelect { + border-radius: UiRadius.control; x: 136px; y: 4px; width: parent.width - 150px; current-index <=> root.bandwidth-index; model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"]; } } } - Text { - x: 2px; y: 144px; width: parent.width - 4px; height: 70px; - text: GalleryStrings.tr( - "可见设备共享视频预算,优先保障展开画面;不可见设备暂停视频。音频与文件传输不计入,旧版设备不支持限额。瞬时速率可能超过预算。", - "Visible devices share the video budget, with priority for expanded views. Hidden video pauses. Audio, files and older devices are excluded; brief bursts may exceed the budget.", - "Бюджет видео делится между видимыми устройствами с приоритетом крупного вида. Скрытое видео приостановлено. Звук, файлы и старые устройства не учитываются; возможны краткие превышения."); - color: #718096; font-size: 11px; wrap: word-wrap; - } if root.error != "": Text { - x: 2px; y: 224px; width: parent.width - 4px; height: 32px; + x: 2px; y: 144px; width: parent.width - 4px; height: 32px; text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap; } } diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index eb7bb2e1..e9355c4d 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -1,12 +1,18 @@ +import { UiRadius } from "ui_radius.slint"; import { GalleryStrings } from "device_strings.slint"; import { RemoteCursor } from "remote_cursor.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 { ScrollView } from "std-widgets.slint"; +import { RoundedLineEdit } from "rounded_line_edit.slint"; import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint"; -component GalleryToolbarButton inherits CompactButton { +component GalleryButton inherits CompactButton { + border-radius: UiRadius.control; +} + +component GalleryToolbarButton inherits GalleryButton { in property alternate-text; in property horizontal-padding: 12px; // Reserve both labels so toggling a mode never shifts adjacent controls. @@ -487,7 +493,7 @@ export component GalleryView inherits Rectangle { } if root.batch && !root.simplified: Rectangle { x: parent.width - 27px; y: parent.height - 27px; - width: 18px; height: 18px; border-radius: 5px; border-width: 1px; + width: 18px; height: 18px; border-radius: 4px; 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; animate background, border-color { duration: 120ms; } @@ -560,14 +566,14 @@ export component GalleryView inherits Rectangle { height: footer.y - 12px - self.y; HorizontalLayout { x: 0px; y: 0px; width: parent.width - root.view-controls-space; height: 24px; spacing: 8px; - CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } } + GalleryButton { 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: 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; - border-radius: 5px; + border-radius: UiRadius.control; background: root.controlling ? #e7f1fd : #f0f2f5; focus-state := Text { x: 8px; width: parent.width - 16px; height: parent.height; @@ -589,12 +595,12 @@ export component GalleryView inherits Rectangle { accessible-action-default => { self.clicked(); } clicked => { root.action(root.group-controlling ? "group-release" : "group-control", root.focused.id); } } - CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } } + GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } } } Rectangle { x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px; width: parent.width; height: parent.height - self.y; - background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: 6px; + background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface; 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; @@ -666,7 +672,7 @@ export component GalleryView inherits Rectangle { private property presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); private property details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence; - width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: 5px; + width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface; 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; @@ -696,7 +702,8 @@ export component GalleryView inherits Rectangle { if self.has-hover { self.show-details(); } else if root.device-tooltip-id == tile.id { root.device-tooltip-id = ""; } } - moved => { if self.has-hover { self.show-details(); } } + changed mouse-x => { if self.has-hover { self.show-details(); } } + changed mouse-y => { if self.has-hover { self.show-details(); } } clicked => { root.device-tooltip-id = ""; root.action("focus", tile.id); } } } @@ -786,7 +793,7 @@ export component GalleryView inherits Rectangle { 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; + border-color: #c8cbd0; border-radius: UiRadius.control; text: GalleryStrings.tr("网络状态", "Network", "Сеть"); accessible-role: button; accessible-label: self.text; @@ -796,9 +803,9 @@ export component GalleryView inherits Rectangle { } pager := HorizontalLayout { x: parent.width - self.width; y: 0px; width: 112px; height: 24px; spacing: 6px; - previous-page := CompactButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } } + previous-page := GalleryButton { 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; } - 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"); } } + next-page := GalleryButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } } } } // Paint the fullscreen tooltip above the grid/rail, below modal overlays. @@ -815,6 +822,7 @@ export component GalleryView inherits Rectangle { } IconButton { width: 24px; height: 24px; + border-radius: UiRadius.control; icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); clicked => { root.toggle-fullscreen(); } @@ -836,7 +844,7 @@ export component GalleryView inherits 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; + background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface; Rectangle { x: 8px; y: 9px; width: 64px; height: 36px; background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview; @@ -857,7 +865,7 @@ export component GalleryView inherits Rectangle { 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; + background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: UiRadius.surface; 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; } @@ -867,7 +875,7 @@ export component GalleryView inherits 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; + background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: UiRadius.control; 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; @@ -878,7 +886,7 @@ export component GalleryView inherits Rectangle { TouchArea {} add-dialog := Rectangle { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px; - background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2; + background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; function submit() { if root.add-device(device-id.text, password.text, group.text, remember.checked) { password.text = ""; @@ -887,21 +895,21 @@ export component GalleryView inherits Rectangle { } Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; } Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; } - device-id := LineEdit { + device-id := RoundedLineEdit { x: 96px; y: 56px; width: 286px; height: 30px; placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр"); input-type: InputType.number; accepted => { password.focus(); } } Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; } - password := LineEdit { + password := RoundedLineEdit { x: 96px; y: 100px; width: 286px; height: 30px; placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства"); input-type: InputType.password; accepted => { group.focus(); } } Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; } - group := LineEdit { + group := RoundedLineEdit { x: 96px; y: 144px; width: 286px; height: 30px; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); accepted => { add-dialog.submit(); } @@ -911,8 +919,8 @@ export component GalleryView inherits Rectangle { TouchArea { clicked => { remember.checked = !remember.checked; } } } Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; } - CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } } - CompactButton { + GalleryButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } } + GalleryButton { x: 254px; y: 260px; width: 128px; height: 28px; primary: true; text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить"); enabled: device-id.text != "" && password.text != ""; @@ -925,7 +933,7 @@ export component GalleryView inherits Rectangle { TouchArea {} Rectangle { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px; - background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2; + background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; } Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; } ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; } @@ -934,7 +942,7 @@ export component GalleryView inherits Rectangle { Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; } Rectangle { x: 18px; y: 102px; width: 404px; height: 160px; - border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true; + border-width: 1px; border-color: #d8dadd; border-radius: UiRadius.surface; clip: true; recent-list := ScrollView { x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; viewport-width: self.width - 12px; @@ -955,12 +963,24 @@ export component GalleryView inherits Rectangle { } } if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + Rectangle { + x: -UiRadius.surface; y: -UiRadius.surface; + width: parent.width + 2 * UiRadius.surface; + height: parent.height + 2 * UiRadius.surface; + border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface; + border-color: white; + } + Rectangle { + width: parent.width; height: parent.height; + border-width: 1px; border-radius: UiRadius.surface; + border-color: #d8dadd; + } } Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; } - import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); } + import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); } Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; } - CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } } - CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } } + GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } } + GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } } } } if root.confirming: Rectangle { @@ -968,7 +988,7 @@ export component GalleryView inherits Rectangle { Rectangle { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 360px; height: confirmation-list.height + 118px; - background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2; + background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; Text { x: 20px; y: 18px; width: parent.width - 132px; height: 24px; text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name; @@ -976,13 +996,13 @@ export component GalleryView inherits Rectangle { } Rectangle { x: parent.width - 108px; y: 20px; width: 88px; height: 22px; - background: #eff3f8; border-radius: 11px; + background: #eff3f8; border-radius: UiRadius.surface; Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; } } confirmation-list := Rectangle { x: 20px; y: 58px; width: parent.width - 40px; height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px); - background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true; + background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: UiRadius.surface; clip: true; confirmation-scroll := ScrollView { x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; viewport-width: self.width - 12px; @@ -1002,8 +1022,8 @@ export component GalleryView inherits Rectangle { } } } - CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } } - CompactButton { + GalleryButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } } + GalleryButton { x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true; text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить"); clicked => { root.confirming = false; root.action(root.action-code, ""); } diff --git a/apps/desktop/src/gui/ui/group_filter.slint b/apps/desktop/src/gui/ui/group_filter.slint index 5b40a156..c1934db0 100644 --- a/apps/desktop/src/gui/ui/group_filter.slint +++ b/apps/desktop/src/gui/ui/group_filter.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { ScrollView } from "std-widgets.slint"; import { FontAwesomeIcons } from "common.slint"; @@ -6,6 +7,9 @@ export component GroupFilter inherits Rectangle { in property <[string]> model; in property system-count: 2; in property enabled: true; + in property normal-border-color: #c9cdd2; + in property hover-border-color: root.normal-border-color; + in property focus-border-color: #8db5df; in-out property current-index: 0; callback selected(int); private property has-divider: root.system-count > 0 && root.model.length > root.system-count; @@ -23,8 +27,9 @@ export component GroupFilter inherits Rectangle { width: 155px; height: 28px; background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white; border-width: 1px; - border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2; - border-radius: 4px; + border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color + : trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color; + border-radius: UiRadius.control; animate background, border-color { duration: 100ms; easing: ease-out; } forward-focus: trigger-focus; accessible-role: combobox; @@ -117,7 +122,7 @@ export component GroupFilter inherits Rectangle { width: parent.width; height: parent.height; opacity: root.popup-progress; background: white; border-width: 1px; - border-color: #c9cdd2; border-radius: 5px; + border-color: root.normal-border-color; border-radius: UiRadius.menu; ScrollView { x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; viewport-width: self.width; @@ -131,7 +136,7 @@ export component GroupFilter inherits Rectangle { } for choice[index] in root.model: Rectangle { y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px); - width: parent.width; height: 26px; border-radius: 3px; + width: parent.width; height: 26px; border-radius: UiRadius.control; background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent; animate background { duration: 100ms; easing: ease-out; } accessible-role: list-item; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 29d1e037..564ba9bd 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -1,3 +1,5 @@ +import { UiRadius } from "ui_radius.slint"; +import { WorkbenchStyle, WorkbenchButton, WorkbenchCard } from "workbench_controls.slint"; import { GalleryStrings } from "device_strings.slint"; import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint"; import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint"; @@ -6,7 +8,8 @@ import { GalleryNetworkDialog, GalleryNetworkDevice } from "gallery_network_dial import { SettingsButton, SettingsSelect, SettingsGroup, SettingsRow, SettingsToggleRow } from "settings_controls.slint"; import { GallerySettings } from "gallery_settings.slint"; import { GroupFilter } from "group_filter.slint"; -import { LineEdit, ScrollView } from "std-widgets.slint"; +import { ScrollView } from "std-widgets.slint"; +import { RoundedLineEdit } from "rounded_line_edit.slint"; import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint"; import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; @@ -117,21 +120,6 @@ export global UiStrings { in-out property release-date-label: "Release Date: "; } -// Fluent LineEdit draws a blue 2px focus underline. The ImGui-style field -// already has its own border, so cover that underline and restore its bottom -// edge without changing the editor's focus, selection, or clear-button logic. -component LineEditFocusUnderlineMask inherits Rectangle { - height: 3px; - background: white; - z: 2; - - Rectangle { - y: parent.height - 1px; - height: 1px; - background: ImGuiLineStyle.border; - } -} - // Prompt copy in modal dialogs sits slightly low in the legacy ImGui layout. // Offset only the rendered text so input fields and action buttons keep their // established geometry. @@ -161,62 +149,11 @@ component DialogPromptText inherits Rectangle { } } -// Keep the green center and fade the surrounding glow into the status bar. -component OnlineStatusIndicator inherits Rectangle { - in property dot-diameter: 8px; - - width: root.dot-diameter * 2.2; - height: self.width; - border-radius: self.width / 2; - // Slint's default radial radius is half the bounding-box diagonal. - // Fade out at the circle's edge (1 / sqrt(2)), before rounded clipping. - background: @radial-gradient( - circle, - #00ff0040 0%, - #00ff0038 32%, - #00ff0018 50%, - #00ff0007 62%, - #00ff0000 70.7% - ); - - Rectangle { - x: (parent.width - root.dot-diameter) / 2; - y: (parent.height - root.dot-diameter) / 2; - width: root.dot-diameter; - height: root.dot-diameter; - border-radius: root.dot-diameter / 2; - background: #00ff00; - } -} - -// ImGui::Button under StyleColorsLight(), used by both actions in the legacy -// connection-password dialog. The old dialog does not distinguish primary and -// secondary actions visually. -component ConnectionDialogButton inherits Rectangle { - in property text; - callback clicked; - - width: max(40px, button-label.preferred-width + 8px); - height: 24px; - background: button-touch.pressed ? #0f87fa - : button-touch.has-hover ? #4296fa - : #4296fa66; - border-radius: 3px; - - button-label := Text { - text: root.text; - color: #000000de; - font-size: ImGuiFontStyle.base; - horizontal-alignment: center; - vertical-alignment: center; - } - button-touch := TouchArea { clicked => { root.clicked(); } } -} - // Use the same compact, plain-text field for connection and password changes. component PasswordInput inherits Rectangle { in-out property text; in property max-length: 0; + in property text-size: ImGuiFontStyle.base; private property previous-text; callback accepted; @@ -225,7 +162,7 @@ component PasswordInput inherits Rectangle { background: white; border-width: 1px; border-color: ImGuiLineStyle.border; - border-radius: 3px; + border-radius: UiRadius.control; forward-focus: editor; init => { root.previous-text = root.text; } @@ -236,7 +173,7 @@ component PasswordInput inherits Rectangle { input-type: InputType.text; single-line: true; color: #202124; - font-size: ImGuiFontStyle.base; + font-size: root.text-size; vertical-alignment: center; selection-foreground-color: white; selection-background-color: #2463c7; @@ -267,10 +204,10 @@ component PasswordInput inherits Rectangle { export component MainWindow inherits Window { title: root.gallery-mode ? "CrossDesk " + GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub") : "CrossDesk"; icon: @image-url("../../../resources/linux/crossdesk_256x256.png"); - preferred-width: 640px; + preferred-width: root.minimum-window-width; preferred-height: root.minimum-window-height; - min-width: 640px; - max-width: root.gallery-mode ? 16384px : 640px; + min-width: root.minimum-window-width; + max-width: root.gallery-mode ? 16384px : root.minimum-window-width; min-height: root.minimum-window-height; max-height: root.gallery-mode ? 16384px : root.minimum-window-height; no-frame: root.custom-titlebar; @@ -375,8 +312,9 @@ export component MainWindow inherits Window { in property window-active: true; private property titlebar-height: root.custom-titlebar && !root.gallery-fullscreen ? (root.wayland-titlebar ? 32px : 31px) : 0px; + out property minimum-window-width: 640px; out property minimum-window-height: - 448px + root.titlebar-height; + (root.gallery-mode ? 448px : 472px) + root.titlebar-height; private property wayland-corner-radius: 12px; in property local-id: ""; in property local-password: ""; @@ -762,7 +700,7 @@ export component MainWindow inherits Window { Rectangle { width: parent.width; height: parent.height; - background: white; + background: root.gallery-mode ? white : WorkbenchStyle.canvas; border-width: 0px; border-radius: 0px; @@ -800,23 +738,22 @@ export component MainWindow inherits Window { z: 7; } - // The connection page shares its section heading row with the menu. - menu-button := IconButton { + // A compact application toolbar keeps global actions outside device cards. + // Center the 28px buttons above the cards with 6px of space on each side. + open-gallery-button := WorkbenchButton { visible: !root.gallery-mode; - x: parent.width - 29px - self.width; - y: 10.5px; - width: 28px; - height: self.width; + x: 20px; y: 6px; + width: 80px; height: 28px; + text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub"); + z: 8; + clicked => { root.open-gallery(); } + } + menu-button := WorkbenchButton { + visible: !root.gallery-mode; + x: parent.width - 56px; y: open-gallery-button.y; + width: 36px; height: 28px; 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 - ? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "") - : root.update-available ? UiStrings.update-available : ""; + tooltip: GalleryStrings.tr("更多", "More", "Ещё"); z: 8; clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; } } @@ -829,7 +766,7 @@ export component MainWindow inherits Window { border-radius: 8px; border-width: 1px; border-color: white; - background: #2563eb; + background: WorkbenchStyle.accent; opacity: root.menu-open || mod(animation-tick(), 2s) < 1s ? 1 : 0; z: 9; accessible-role: button; @@ -891,275 +828,189 @@ export component MainWindow inherits Window { } Rectangle { - y: 0px; height: parent.height - self.y - 24px; visible: !root.gallery-mode; - // Local and remote desktop panels. + y: 0px; height: parent.height - 28px; visible: !root.gallery-mode; + // Two balanced cards keep identity and connection actions distinct. Rectangle { - y: 0px; - height: 234px; - background: white; - - SectionTitle { x: 29px; y: 9px; width: 256px; height: 31px; text: UiStrings.local-desktop; } - Rectangle { - x: 29px; - y: 42px; - width: 256px; - height: 183px; - background: #eff0f2; - border-radius: 9px; - border-width: 1px; - border-color: ImGuiLineStyle.border; - - Text { x: 8px; y: 10px; width: 160px; height: 26px; text: UiStrings.local-id; color: #111111; font-size: ImGuiFontStyle.panel-label; } - Rectangle { - x: 8px; - y: 42px; - width: 160px; - height: 38px; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 4px; - // Measure the ID using the preferred field size. - // The visible text can then scale only on systems - // whose font metrics would otherwise clip it. - local-id-width-probe := Text { - visible: false; - text: root.local-id; - font-size: ImGuiFontStyle.field-value; - letter-spacing: 1px; - } - Text { - x: 8px; - // Compensate for the font's ascender-heavy - // metrics so digits sit at the optical center. - y: 4px; - width: parent.width - 16px; - height: parent.height - 4px; - text: root.local-id; - color: #20242a; - font-size: max(18px, - ImGuiFontStyle.field-value * - (self.width / max(self.width, local-id-width-probe.preferred-width))); - letter-spacing: 1px; - horizontal-alignment: left; - vertical-alignment: center; - } + x: 20px; y: 40px; width: parent.width - 40px; height: 208px; + // Button tooltips extend below these cards into the recent section. + z: 1; + local-card := WorkbenchCard { + x: 0px; y: 0px; + width: (parent.width - 16px) / 2; height: parent.height; + Text { x: 16px; y: 14px; width: parent.width - 32px; height: 24px; overflow: elide; text: UiStrings.local-desktop; font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; } + Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: GalleryStrings.tr("将 ID 和密码分享给连接方", "Share your ID and password to get connected", "Передайте ID и пароль для подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } + Text { x: 16px; y: 68px; height: 18px; text: UiStrings.local-id; font-size: 12px; color: WorkbenchStyle.muted; } + Rectangle { + x: 16px; y: 90px; width: parent.width - 32px; height: 40px; + // Raise the whole field: a child's z cannot escape its + // parent's stacking order above the following password row. + z: 1; + background: WorkbenchStyle.field-surface; border-radius: UiRadius.control; + border-width: 1px; border-color: WorkbenchStyle.border; + Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; color: WorkbenchStyle.text; vertical-alignment: center; } + Rectangle { x: parent.width - 48px; y: 10px; width: 1px; height: parent.height - 20px; background: WorkbenchStyle.border; } + WorkbenchButton { + x: parent.width - 42px; y: 2px; width: 36px; quiet: true; + icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy; + tooltip: root.copy-toast ? UiStrings.copied : GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID"); + clicked => { if root.copy-local-id() { root.copy-toast = true; copy-timer.restart(); } } } - IconButton { - x: 174px; - y: 46px; - width: 29px; - height: 30px; - icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy; - icon-size: 16px; - tooltip: UiStrings.copied; - clicked => { - if root.copy-local-id() { - root.copy-toast = true; - copy-timer.restart(); - } - } + } + Text { x: 16px; y: 138px; height: 18px; text: UiStrings.password; font-size: 12px; color: WorkbenchStyle.muted; } + Rectangle { + x: 16px; y: 158px; width: parent.width - 32px; height: 36px; + background: WorkbenchStyle.field-surface; border-radius: UiRadius.control; + border-width: 1px; border-color: WorkbenchStyle.border; + Text { x: 12px; width: parent.width - 132px; height: parent.height; text: root.password-visible ? root.local-password : "••••••"; font-size: 20px; font-weight: 500; letter-spacing: 2px; color: WorkbenchStyle.text; vertical-alignment: center; } + Rectangle { x: parent.width - 118px; y: 8px; width: 1px; height: parent.height - 16px; background: WorkbenchStyle.border; } + WorkbenchButton { + x: parent.width - 112px; y: 0px; width: 36px; quiet: true; + icon: root.password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash; + tooltip: root.password-visible ? GalleryStrings.tr("隐藏密码", "Hide password", "Скрыть пароль") : GalleryStrings.tr("显示密码", "Show password", "Показать пароль"); + clicked => { root.toggle-password-visibility(); } } - Rectangle { x: 8px; y: 88px; width: 240px; height: 1px; background: ImGuiLineStyle.separator; } - Text { x: 8px; y: 101px; width: 160px; height: 26px; text: UiStrings.password; color: #111111; font-size: ImGuiFontStyle.panel-label; } - Rectangle { - x: 8px; - y: 129px; - width: 160px; - height: 39px; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 4px; - Text { - x: 8px; - y: 4px; - width: parent.width - 16px; - height: parent.height - 4px; - text: root.password-visible ? root.local-password : "••••••"; - color: #20242a; - font-size: ImGuiFontStyle.field-value; - letter-spacing: 2px; - horizontal-alignment: left; - vertical-alignment: center; - } + WorkbenchButton { + x: parent.width - 74px; y: 0px; width: 36px; quiet: true; + icon: FontAwesomeIcons.rotate; tooltip: UiStrings.refresh-password; + enabled: !root.password-change-pending; + clicked => { root.refresh-password(); } } - IconButton { - x: 174px; - y: 133px; - width: 24px; - height: 30px; - icon: root.password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash; - icon-size: 17px; - clicked => { root.toggle-password-visibility(); } - } - IconButton { - x: 198px; - y: 133px; - width: 24px; - height: 30px; - icon: FontAwesomeIcons.rotate; - icon-size: 15px; - tooltip: UiStrings.refresh-password; - enabled: !root.password-change-pending; - clicked => { root.refresh-password(); } - } - IconButton { - x: 222px; - y: 133px; - width: 24px; - height: 30px; - icon: FontAwesomeIcons.pen; - icon-size: 15px; - enabled: !root.password-change-pending; - clicked => { root.reset-password-open = true; } + WorkbenchButton { + x: parent.width - 36px; y: 0px; width: 36px; quiet: true; + icon: FontAwesomeIcons.pen; + tooltip: GalleryStrings.tr("修改密码", "Change password", "Изменить пароль"); + enabled: !root.password-change-pending; + clicked => { root.reset-password-open = true; } } + } } - - Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.border; } - - SectionTitle { x: 356px; y: 9px; width: menu-button.x - self.x - 8px; height: 31px; text: UiStrings.remote-desktop; } - Rectangle { - x: 356px; - y: 42px; - width: 256px; - height: 183px; - background: #eff0f2; - border-radius: 9px; - border-width: 1px; - border-color: ImGuiLineStyle.border; - - Text { x: 8px; y: 10px; width: 160px; height: 26px; text: UiStrings.remote-id; color: #111111; font-size: ImGuiFontStyle.panel-label; } - Rectangle { - x: 8px; - y: 42px; - width: 160px; - height: 38px; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 4px; - remote-id-editor := TextInput { - x: 10px; - y: 1.5px; - width: parent.width - 20px; - height: parent.height - 3px; - text <=> root.remote-id-input; - font-size: ImGuiFontStyle.field-value; - input-type: InputType.number; - color: #202124; - vertical-alignment: center; - selection-foreground-color: white; - selection-background-color: #2463c7; - // Run formatting after the input event has - // finished so the caret state isn't reset by - // a synchronous two-way binding rewrite. - edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); } - accepted => { root.connect-requested(root.remote-id-input); } - } + WorkbenchCard { + x: local-card.width + 16px; y: 0px; width: local-card.width; height: parent.height; + Text { x: 16px; y: 14px; width: parent.width - 32px; height: 24px; overflow: elide; text: UiStrings.remote-desktop; font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; } + Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: GalleryStrings.tr("输入设备 ID,连接远程桌面", "Enter a device ID to start a remote session", "Введите ID для удалённого подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } + Text { x: 16px; y: 68px; height: 18px; text: UiStrings.remote-id; font-size: 12px; color: WorkbenchStyle.muted; } + Rectangle { + x: 16px; y: 90px; width: parent.width - 32px; height: 40px; + background: white; border-radius: UiRadius.control; border-width: 1px; + border-color: root.connection-input-error != "" ? #d65252 : remote-id-editor.has-focus ? WorkbenchStyle.accent : WorkbenchStyle.control-border; + animate border-color { duration: 120ms; } + if root.remote-id-input == "": Text { + x: 12px; width: parent.width - 24px; height: parent.height; + text: GalleryStrings.tr("请输入 9 位设备 ID", "Enter a 9-digit device ID", "Введите ID из 9 цифр"); + font-size: 13px; color: #8290a4; vertical-alignment: center; } - Text { x: 8px; y: 92px; width: 240px; height: 52px; text: root.connection-input-error; font-size: 12px; color: #a33832; wrap: word-wrap; } - IconButton { - x: 175px; - y: 42px; - width: 64px; - height: 38px; - normal-background: #a7cefc; - hover-background: #b8d8fc; - pressed-background: #76aeea; - border-radius: 4px; - icon: FontAwesomeIcons.arrow-right-long; - icon-size: 25px; - clicked => { root.connect-requested(root.remote-id-input); } + remote-id-editor := TextInput { + x: 12px; width: parent.width - 24px; height: parent.height; + text <=> root.remote-id-input; font-size: 24px; font-weight: 600; letter-spacing: 1px; + input-type: InputType.number; color: WorkbenchStyle.text; + accessible-label: UiStrings.remote-id; + vertical-alignment: center; + selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent; + edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); } + accepted => { if !root.connection-pending { root.connect-requested(root.remote-id-input); } } } + } + Text { + x: 16px; y: 133px; width: parent.width - 32px; height: 24px; + text: root.connection-input-error; + font-size: 11px; color: #be3d42; wrap: word-wrap; + } + WorkbenchButton { + x: 16px; y: 158px; width: parent.width - 32px; height: 36px; + primary: true; icon: FontAwesomeIcons.arrow-right-long; + text: root.connection-pending ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…") : UiStrings.connect; + enabled: !root.connection-pending; + clicked => { root.connect-requested(root.remote-id-input); } + } } } // Recent connections: horizontal cards with thumbnail and hover actions. recent-panel := Rectangle { - y: 234px; + y: 260px; height: parent.height - self.y; - background: white; - border-width: 0px; - border-color: ImGuiLineStyle.border; - Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; } - - 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(); } - } + Text { x: 20px; y: 0px; width: recent-group-filter.x - self.x - 12px; height: 32px; text: UiStrings.recent-connections; font-size: 15px; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; } recent-group-filter := GroupFilter { - x: parent.width - self.width - 29px; y: 8px; - width: 155px; height: 28px; + x: parent.width - self.width - 20px; y: 0px; + width: 155px; height: 32px; + normal-border-color: WorkbenchStyle.control-border; + hover-border-color: WorkbenchStyle.hover-border; + focus-border-color: WorkbenchStyle.accent; model: root.recent-groups; current-index <=> root.recent-group-index; selected(index) => { root.recent-group-selected(index); } } recent-list := Rectangle { - x: 29px; - y: 42px; - width: parent.width - 58px; - height: 139px; - background: #eff0f2; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 10px; + x: 20px; + y: 36px; + width: parent.width - 40px; + height: 142px; + // A continuous inset surface groups the cards into one + // horizontal scrolling lane, including its scrollbar. + background: root.recent-connections.length > 0 ? #e6edf6 : transparent; + border-width: root.recent-connections.length > 0 ? 1px : 0px; + border-color: #c9d4e3; + border-radius: UiRadius.surface; clip: true; - if root.recent-connections.length == 0: Text { - text: root.recent-group-index == 0 - ? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений") - : GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений"); - color: #808080; font-size: ImGuiFontStyle.base; - horizontal-alignment: center; vertical-alignment: center; + if root.recent-connections.length == 0: WorkbenchCard { + x: 0px; y: 6px; width: parent.width; height: 124px; + Rectangle { + x: 20px; y: 38px; width: 48px; height: 48px; + background: #f0f4fb; border-radius: UiRadius.surface; + Text { text: FontAwesomeIcons.display; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 20px; color: #8297b9; horizontal-alignment: center; vertical-alignment: center; } + } + Text { + x: 84px; y: 36px; width: parent.width - 104px; height: 24px; + text: root.recent-group-index == 0 + ? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений") + : GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений"); + color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; + } + Text { + x: 84px; y: 68px; width: parent.width - 104px; height: 20px; + text: root.recent-group-index == 0 + ? GalleryStrings.tr("连接过的设备会显示在这里,方便下次快速访问。", "Devices you connect to will appear here for quick access.", "Устройства появятся здесь после первого подключения.") + : GalleryStrings.tr("切换设备分组,查看其他连接记录。", "Choose another group to see your other connections.", "Выберите другую группу для просмотра подключений."); + color: WorkbenchStyle.muted; font-size: 12px; + } } recent-scroll := ScrollView { - width: parent.width; - height: parent.height; + visible: root.recent-connections.length > 0; + x: 6px; y: 5px; + width: parent.width - 12px; + height: parent.height - 6px; horizontal-stretch: 1; + changed viewport-x => { root.recent-tooltip-visible = false; } HorizontalLayout { - padding-left: 8px; - padding-top: 8px; - padding-bottom: 14px; - spacing: 24px; + padding: 0px; + padding-bottom: 10px; + spacing: 12px; alignment: start; for connection[index] in root.recent-connections: connection-card := Rectangle { - width: 178px; - height: 117px; + width: (recent-scroll.width - 24px) / 3; + height: 126px; background: transparent; private property available: connection.online || connection.connected; - private property content-width: self.width - 30px; + private property content-width: self.width; private property toolbar-width: DeviceCardStyle.toolbar-width; private property toolbar-right-padding: 3px; Rectangle { - x: 7px; - y: 7px; width: connection-card.content-width; - height: 109px; - border-radius: 4px; + height: 124px; + border-radius: UiRadius.surface; + background: white; clip: true; Rectangle { x: 0px; y: 0px; width: parent.width; - height: 84px; + height: 80px; background: !connection-card.available ? DeviceCardStyle.offline-preview : connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) : connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) : @@ -1192,26 +1043,28 @@ export component MainWindow inherits Window { } Rectangle { x: 0px; - y: 84px; + y: 80px; width: parent.width; - height: 25px; - background: connection-card.available ? #00000018 : DeviceCardStyle.offline-background; + height: 44px; + background: white; + Rectangle { x: 0px; y: 0px; width: parent.width; height: 1px; background: WorkbenchStyle.border; } Text { - x: 4px; - y: 1px; - height: parent.height - self.y; - width: max(0px, parent.width - self.x - 4px - - (card-touch.has-hover - ? connection-card.toolbar-width + connection-card.toolbar-right-padding - : 0px)); + x: 12px; y: 4px; height: 19px; + width: parent.width - 24px; text: connection.display-name; - 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. - font-size: ImGuiFontStyle.base; + color: WorkbenchStyle.text; + font-size: 13px; font-weight: 600; + overflow: elide; vertical-alignment: center; + } + Text { + x: 12px; y: 24px; height: 16px; width: parent.width - 92px; + text: connection.remote-id; font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; - vertical-alignment: center; + } + presence-label := Text { + x: parent.width - self.preferred-width - 12px; y: 24px; height: 16px; + text: connection-card.available ? UiStrings.online : UiStrings.offline; + font-size: 10px; color: connection-card.available ? WorkbenchStyle.success : WorkbenchStyle.muted; } } // The software renderer only clips rectangular bounds. @@ -1227,14 +1080,14 @@ export component MainWindow inherits Window { } } card-touch := TouchArea { - x: 7px; - y: 7px; width: connection-card.content-width; - height: 109px; + height: 124px; + mouse-cursor: pointer; clicked => { root.recent-connect(connection.remote-id); } image-touch := TouchArea { + x: 0px; y: 0px; width: parent.width; - height: 84px; + height: 80px; private property group-names: connection.group-names; private property available: connection-card.available; changed available => { @@ -1248,38 +1101,36 @@ export component MainWindow inherits Window { } } clicked => { root.recent-connect(connection.remote-id); } + function show-details() { + if !self.has-hover { return; } + root.recent-tooltip-name = connection.display-name; + 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 = 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.x + self.mouse-x; + root.recent-tooltip-pointer-y = recent-panel.y + recent-list.y + + recent-scroll.y + recent-scroll.viewport-y + + connection-card.y + card-touch.y + self.y + self.mouse-y; + root.recent-tooltip-visible = true; + } changed has-hover => { if self.has-hover { - root.recent-tooltip-name = connection.display-name; - 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 = 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; - root.recent-tooltip-pointer-y = recent-panel.y + recent-list.y - + recent-scroll.y + recent-scroll.viewport-y - + connection-card.y + card-touch.y + self.mouse-y; - root.recent-tooltip-visible = true; + self.show-details(); } else if root.recent-tooltip-id == connection.remote-id { root.recent-tooltip-visible = false; } } - moved => { - if self.has-hover { - 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; - root.recent-tooltip-pointer-y = recent-panel.y + recent-list.y - + recent-scroll.y + recent-scroll.viewport-y - + connection-card.y + card-touch.y + self.mouse-y; - } - } + // moved only reports pressed pointer movement; + // track both coordinates during ordinary hover. + changed mouse-x => { self.show-details(); } + changed mouse-y => { self.show-details(); } } if card-touch.has-hover: DeviceCardActions { x: parent.width - self.width - connection-card.toolbar-right-padding; - y: 85px; + y: 52px; remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись"); connected: connection.connected; edit-alias => { @@ -1297,12 +1148,11 @@ export component MainWindow inherits Window { disconnect-device => { root.recent-disconnect(connection.remote-id); } } } - if index < root.recent-connections.length - 1: Rectangle { - x: 183px; - y: 7px; - width: 1px; - height: 109px; - background: #0000007a; + Rectangle { + width: parent.width; height: 124px; + border-width: 1px; border-radius: UiRadius.surface; + border-color: card-touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.border; + animate border-color { duration: 120ms; } } } } @@ -1356,48 +1206,44 @@ export component MainWindow inherits Window { } // Server status and reconnect actions belong to the connection window. if !root.gallery-mode: Rectangle { - y: parent.height - 24px; - height: 24px; - background: #fafbfc; + y: parent.height - 28px; + height: 28px; + background: white; Rectangle { y: 0px; height: 1px; - background: ImGuiLineStyle.border; + background: WorkbenchStyle.border; } - if root.signal-connected && !root.signal-tls-error: OnlineStatusIndicator { - x: 15px - self.width / 2; - y: 12px - self.height / 2; - dot-diameter: 8px; - } - if !root.signal-connected || root.signal-tls-error: Rectangle { - x: 11px; - y: 8px; - width: 8px; - height: 8px; - border-radius: 4px; - background: root.signal-tls-error || root.signal-reconnecting ? #f59e0b : #ef4444; + Rectangle { + x: 20px; y: 11px; width: 6px; height: 6px; border-radius: 3px; + background: root.signal-tls-error || root.signal-reconnecting ? #d6932b : root.signal-connected ? WorkbenchStyle.success : #d65252; } Text { - x: 28px; - y: 3px; - height: 18px; + x: 34px; + y: 0px; + height: parent.height; text: root.signal-tls-error ? UiStrings.tls-error : root.signal-connected ? UiStrings.signal-connected : root.signal-reconnecting ? UiStrings.signal-reconnecting : UiStrings.signal-disconnected; - color: #343941; - font-size: ImGuiFontStyle.base; + color: WorkbenchStyle.muted; + font-size: 11px; vertical-alignment: center; } + if root.signal-connected && !root.signal-tls-error: Text { + x: parent.width - self.preferred-width - 20px; height: parent.height; + text: root.current-version; + font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center; + } if !root.signal-connected && !root.signal-tls-error: Rectangle { - x: parent.width - self.width - 12px; - y: 3px; - height: 18px; + x: parent.width - self.width - 20px; + y: 0px; + height: parent.height; width: reconnect-label.preferred-width; reconnect-label := Text { text: UiStrings.signal-reconnect; - color: #2563eb; - font-size: ImGuiFontStyle.base; + color: WorkbenchStyle.accent; + font-size: 11px; horizontal-alignment: center; vertical-alignment: center; } @@ -1423,18 +1269,20 @@ export component MainWindow inherits Window { private property presence-known: !root.gallery-mode || gallery.hovered-device.presence-known; private property pointer-x: root.gallery-mode ? gallery.x + gallery.device-tooltip-x : root.recent-tooltip-pointer-x; private property pointer-y: root.gallery-mode ? gallery.y + gallery.device-tooltip-y : root.recent-tooltip-pointer-y; - x: min(root.width - self.width - 8px, - max(8px, self.pointer-x > root.width * 0.7 - ? self.pointer-x - self.width - 12px : self.pointer-x + 12px)); - y: min(root.height - root.titlebar-height - self.height - 8px, - max(8px, self.pointer-y - self.height - 8px)); - width: 260px; + // Follow the pointer with a gap; change sides only when space runs out. + x: clamp(self.pointer-x + self.width + 12px <= parent.width - 8px + ? self.pointer-x + 12px : self.pointer-x - self.width - 12px, + 8px, max(8px, parent.width - self.width - 8px)); + y: clamp(self.pointer-y - self.height - 12px >= 8px + ? self.pointer-y - self.height - 12px : self.pointer-y + 16px, + 8px, max(8px, parent.height - self.height - 8px)); + width: min(260px, parent.width - 16px); height: (self.host-name != "" && self.host-name != self.device-name ? 80px : 64px) + tooltip-groups.preferred-height + 2px; background: #22252bf2; border-width: 1px; border-color: #ffffff38; - border-radius: 5px; + border-radius: UiRadius.control; z: 20; VerticalLayout { @@ -1475,7 +1323,7 @@ export component MainWindow inherits Window { } } - // Dismiss the title menu when clicking anywhere outside of it. + // Dismiss the application menu when clicking anywhere outside of it. if !root.gallery-mode && root.menu-open: TouchArea { x: 0px; y: 0px; @@ -1488,38 +1336,39 @@ export component MainWindow inherits Window { } } - // Title menu. + // Expand downward from the application toolbar. 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, + y: menu-button.y + menu-button.height + 6px; + width: max(128px, max(menu-settings-label.preferred-width, max(menu-messages-label.preferred-width + (root.announcement-unread > 0 ? menu-message-badge.width + 8px : 0px), - menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px))) + 24px); - height: 93px; - background: white; + menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px))) + 28px); + height: application-menu-content.preferred-height; + background: WorkbenchStyle.surface; border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 5px; + border-color: WorkbenchStyle.control-border; + border-radius: UiRadius.menu; z: 10; TouchArea { } - VerticalLayout { - padding: 4px; - spacing: 2px; + application-menu-content := VerticalLayout { + padding: 6px; + spacing: 0px; menu-settings := Rectangle { - height: 27px; - background: settings-touch.pressed ? #d8dadd - : settings-touch.has-hover ? #f0f1f2 - : white; + height: 30px; + background: settings-touch.pressed ? WorkbenchStyle.pressed-surface + : settings-touch.has-hover ? WorkbenchStyle.hover-surface + : transparent; border-width: 0px; - border-radius: 3px; + border-radius: UiRadius.control; + animate background { duration: 120ms; } accessible-role: button; accessible-label: UiStrings.settings; accessible-action-default => { settings-touch.clicked(); } menu-settings-label := Text { - x: 7px; + x: 8px; height: parent.height; text: UiStrings.settings; - color: #2d3138; + color: WorkbenchStyle.text; font-size: 12px; vertical-alignment: center; accessible-role: none; } @@ -1528,19 +1377,27 @@ export component MainWindow inherits Window { clicked => { root.menu-open = false; root.settings-open = true; } } } + Rectangle { + height: 7px; + Rectangle { + x: 8px; y: 3px; width: parent.width - 16px; height: 1px; + background: WorkbenchStyle.border; + } + } menu-messages := Rectangle { - height: 27px; - background: messages-touch.pressed ? #d8dadd - : messages-touch.has-hover ? #f0f1f2 : white; - border-radius: 3px; + height: 30px; + background: messages-touch.pressed ? WorkbenchStyle.pressed-surface + : messages-touch.has-hover ? WorkbenchStyle.hover-surface : transparent; + border-radius: UiRadius.control; + animate background { duration: 120ms; } accessible-role: button; accessible-label: MessageStrings.messages + (root.announcement-unread > 0 ? " · " + MessageStrings.unread + " " + root.announcement-unread : ""); accessible-action-default => { messages-touch.clicked(); } menu-messages-label := Text { - x: 7px; + x: 8px; height: parent.height; text: MessageStrings.messages; - color: #2d3138; + color: WorkbenchStyle.text; font-size: 12px; vertical-alignment: center; accessible-role: none; } @@ -1569,21 +1426,29 @@ export component MainWindow inherits Window { } } } + Rectangle { + height: 7px; + Rectangle { + x: 8px; y: 3px; width: parent.width - 16px; height: 1px; + background: WorkbenchStyle.border; + } + } menu-about := Rectangle { - height: 27px; - background: about-touch.pressed ? #d8dadd - : about-touch.has-hover ? #f0f1f2 - : white; + height: 30px; + background: about-touch.pressed ? WorkbenchStyle.pressed-surface + : about-touch.has-hover ? WorkbenchStyle.hover-surface + : transparent; border-width: 0px; - border-radius: 3px; + border-radius: UiRadius.control; + animate background { duration: 120ms; } accessible-role: button; accessible-label: UiStrings.about + (root.update-available ? " · " + UiStrings.update-available : ""); accessible-action-default => { about-touch.clicked(); } menu-about-label := Text { - x: 7px; + x: 8px; height: parent.height; text: UiStrings.about; - color: #2d3138; + color: WorkbenchStyle.text; font-size: 12px; vertical-alignment: center; accessible-role: none; } @@ -1609,22 +1474,6 @@ export component MainWindow inherits Window { } } } - Rectangle { - x: 4px; - y: 31.5px; - width: parent.width - 8px; - height: 1px; - background: ImGuiLineStyle.separator; - z: 1; - } - Rectangle { - x: 4px; - y: 60.5px; - width: parent.width - 8px; - height: 1px; - background: ImGuiLineStyle.separator; - z: 1; - } } if root.messages-open: Rectangle { @@ -1669,9 +1518,9 @@ export component MainWindow inherits Window { } } if root.copy-toast: Rectangle { - x: 180px; y: 170px; width: 280px; height: 68px; + x: (parent.width - self.width) / 2; y: parent.height - 92px; width: 280px; height: 40px; background: #fffffff2; - border-radius: 8px; + border-radius: UiRadius.surface; border-width: 1px; border-color: ImGuiLineStyle.border; z: 30; @@ -1684,8 +1533,8 @@ export component MainWindow inherits Window { z: 40; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.compact-language ? 205px : 160px; - y: 140px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; width: root.compact-language ? 230px : 320px; height: 170px; VerticalLayout { @@ -1742,35 +1591,20 @@ export component MainWindow inherits Window { z: 40; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px; - y: root.gallery-mode ? (parent.height - self.height) / 2 : 145px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; width: root.compact-language ? 230px : 320px; height: 160px; VerticalLayout { padding: 20px; spacing: 14px; DialogPromptText { prompt: UiStrings.edit-alias; } - Rectangle { + RoundedLineEdit { height: 28px; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: 4px; - - LineEdit { - x: 1px; - y: 1.5px; - width: parent.width - 2px; - height: parent.height - 2px; - text <=> root.alias-input; - accepted => { - root.recent-edit-alias(root.alias-remote-id, root.alias-input); - root.alias-open = false; - } - } - LineEditFocusUnderlineMask { - x: 5px; - y: parent.height - 3px; - width: parent.width - 10px; + normal-border-color: ImGuiLineStyle.border; + text <=> root.alias-input; + accepted => { + root.recent-edit-alias(root.alias-remote-id, root.alias-input); + root.alias-open = false; } } HorizontalLayout { @@ -1788,8 +1622,8 @@ export component MainWindow inherits Window { z: 40; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px; - y: root.gallery-mode ? (parent.height - self.height) / 2 : 155px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; width: root.compact-language ? 230px : 320px; height: 140px; VerticalLayout { @@ -1810,7 +1644,7 @@ export component MainWindow inherits Window { z: 39; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: 185px; y: 150px; width: 270px; height: 150px; + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 270px; height: 150px; VerticalLayout { padding: 20px; spacing: 18px; DialogPromptText { prompt: root.offline-warning; } @@ -1819,80 +1653,57 @@ export component MainWindow inherits Window { } } - // Connection progress and password retry modal from the ImGui client. + // Connection feedback follows the workbench spacing and action hierarchy. if root.connection-dialog-open: Rectangle { - background: #00000042; + background: #15223850; z: 48; TouchArea { clicked => { focus-sink.focus(); } } - DialogSurface { - x: root.connection-password-required ? 211px : 190px; - y: root.connection-password-required ? 143px : 130px; - width: root.connection-password-required ? 211px : 260px; - height: root.connection-password-required ? 158px : 145px; - - if root.connection-password-required: Rectangle { - Text { - y: 38px; - height: 22px; - text: root.connection-status-text; - color: #30343b; - horizontal-alignment: center; - vertical-alignment: center; + WorkbenchCard { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 340px; + height: root.connection-password-required && !root.connection-validating ? 236px : 168px; + Text { + x: 24px; y: 24px; width: parent.width - 48px; height: 46px; + text: root.connection-status-text; + color: WorkbenchStyle.text; font-size: 14px; + wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; + } + if root.connection-password-required && !root.connection-validating: PasswordInput { + x: (parent.width - self.width) / 2; y: 86px; + width: 164px; height: 36px; text-size: 16px; + border-radius: UiRadius.control; border-color: WorkbenchStyle.control-border; + text <=> root.connection-password; + accepted => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + } + if root.connection-password-required && !root.connection-validating: Rectangle { + x: (parent.width - self.width) / 2; y: 134px; + width: remember-label.preferred-width + 29px; height: 21px; + ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.connection-remember-password; } + remember-label := Text { + x: 29px; height: parent.height; + text: UiStrings.remember-password; + color: WorkbenchStyle.muted; font-size: 12px; vertical-alignment: center; } - - if !root.connection-validating: PasswordInput { - x: 71px; y: 63px; - text <=> root.connection-password; - accepted => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + TouchArea { + x: 29px; width: parent.width - 29px; height: parent.height; + clicked => { root.connection-remember-password = !root.connection-remember-password; } } - - if !root.connection-validating: Rectangle { - x: 72px; y: 89px; width: 120px; height: 21px; - ImGuiCheckBox { - x: 0px; y: 0px; - checked <=> root.connection-remember-password; - } - Text { - x: 27px; y: 0px; - width: parent.width - 27px; height: parent.height; - text: UiStrings.remember-password; - color: #30343b; - font-size: ImGuiFontStyle.body; - vertical-alignment: center; - } - TouchArea { - x: 27px; y: 0px; - width: parent.width - 27px; height: parent.height; - clicked => { root.connection-remember-password = !root.connection-remember-password; } - } - } - - if !root.connection-validating: ConnectionDialogButton { - x: 69px; y: 118px; - text: UiStrings.ok; - clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } - } - ConnectionDialogButton { - x: root.connection-validating ? (parent.width - self.width) / 2 : 117px; - y: 118px; + } + HorizontalLayout { + x: 24px; y: parent.height - 60px; width: parent.width - 48px; height: 36px; + spacing: 12px; alignment: center; + if root.connection-password-required || root.connection-pending: WorkbenchButton { text: UiStrings.cancel; clicked => { root.connection-cancel(); } } - } - - if !root.connection-password-required: VerticalLayout { - padding: 20px; spacing: 13px; - DialogPromptText { prompt: root.connection-status-text; } - HorizontalLayout { - alignment: center; spacing: 8px; - if root.connection-pending: SecondaryButton { - text: UiStrings.cancel; - clicked => { root.connection-cancel(); } - } - if !root.connection-pending: PrimaryButton { - text: UiStrings.ok; - clicked => { root.connection-acknowledge(); } - } + if root.connection-password-required && !root.connection-validating: WorkbenchButton { + primary: true; text: UiStrings.ok; + clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + } + if !root.connection-password-required && !root.connection-pending: WorkbenchButton { + primary: true; text: UiStrings.ok; + clicked => { root.connection-acknowledge(); } } } } @@ -1904,20 +1715,20 @@ export component MainWindow inherits Window { z: 50; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.compact-language ? 255px : 190px; - y: 162px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; width: root.compact-language ? 130px : 260px; height: 125px; - border-radius: 7px; + border-radius: UiRadius.surface; Rectangle { x: 0px; y: 0px; width: parent.width; height: 21px; background: #d3d3d3; - border-radius: 7px; + border-top-left-radius: UiRadius.surface; + border-top-right-radius: UiRadius.surface; z: 5; - Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } Text { x: 6px; text: UiStrings.request-permissions; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } } Text { x: 10px; y: 32px; width: root.compact-language ? 110px : 240px; height: 24px; text: UiStrings.permission-required; color: #202124; font-size: 8px; wrap: word-wrap; } @@ -1945,37 +1756,47 @@ export component MainWindow inherits Window { background: #15223850; z: 45; TouchArea { clicked => { focus-sink.focus(); } } - DialogSurface { + settings-dialog := DialogSurface { + // Center every content section; the scrollbar sits in the right margin. + private property content-inset: 26px; + private property scrollbar-gutter: 22px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: min(400px, parent.width - 40px); height: min(446px, parent.height - 16px); - background: #f8fafc; border-radius: 14px; border-color: #e1e7ef; + width: min(400px, parent.width - 40px); + height: min(446px, parent.height - 16px); + background: #f8fafc; border-radius: UiRadius.surface; 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 - 36px; height: 27px; + x: settings-dialog.content-inset; y: 18px; + width: parent.width - 2 * settings-dialog.content-inset; height: 27px; text: UiStrings.settings; font-size: 20px; font-weight: 600; color: #22324a; } Rectangle { - x: 18px; y: 56px; width: parent.width - 36px; height: 40px; - background: #eaf0f6; border-radius: 9px; + x: settings-dialog.content-inset; y: 56px; + width: parent.width - 2 * settings-dialog.content-inset; height: 40px; + background: WorkbenchStyle.canvas; border-radius: UiRadius.menu; + border-width: 1px; border-color: WorkbenchStyle.border; Rectangle { - x: 4px + root.settings-tab * (parent.width - 8px) / 2; - y: 4px; width: (parent.width - 8px) / 2; height: parent.height - 8px; - background: white; border-radius: 6px; - drop-shadow-color: #1e3e6520; drop-shadow-blur: 3px; drop-shadow-offset-y: 1px; + x: 4px + root.settings-tab * (parent.width - 4px) / 2; + y: 4px; width: (parent.width - 12px) / 2; height: parent.height - 8px; + background: WorkbenchStyle.surface; border-radius: UiRadius.control; + border-width: 1px; border-color: WorkbenchStyle.control-border; animate x { duration: 140ms; easing: ease-out; } } for label[index] in [GalleryStrings.tr("常规", "General", "Общие"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle { - x: 4px + index * (parent.width - 8px) / 2; - y: 4px; width: (parent.width - 8px) / 2; height: parent.height - 8px; - border-radius: 6px; - background: tab-touch.has-hover && root.settings-tab != index ? #e1e8f1 : transparent; + x: 4px + index * (parent.width - 4px) / 2; + y: 4px; width: (parent.width - 12px) / 2; height: parent.height - 8px; + border-radius: UiRadius.control; + background: root.settings-tab == index ? transparent + : tab-touch.pressed ? WorkbenchStyle.pressed-surface + : tab-touch.has-hover ? WorkbenchStyle.hover-surface : transparent; + animate background { duration: 100ms; } accessible-role: button; accessible-label: label; accessible-action-default => { tab-touch.clicked(); } Text { - text: label; font-size: 12px; font-weight: root.settings-tab == index ? 600 : 400; - color: root.settings-tab == index ? #2e70cf : #718096; + text: label; font-size: 12px; font-weight: root.settings-tab == index ? 600 : 500; + color: root.settings-tab == index ? WorkbenchStyle.accent : WorkbenchStyle.muted; horizontal-alignment: center; vertical-alignment: center; } tab-touch := TouchArea { @@ -1985,74 +1806,87 @@ export component MainWindow inherits Window { } } if root.settings-tab == 0: settings-scroll := ScrollView { - x: 18px; y: 108px; width: parent.width - 28px; height: parent.height - 174px; + x: settings-dialog.content-inset; y: 108px; + width: parent.width - 2 * settings-dialog.content-inset + settings-dialog.scrollbar-gutter; + height: parent.height - 174px; + viewport-width: self.visible-width; + viewport-height: settings-content.preferred-height; viewport-y <=> root.settings-scroll-y; - VerticalLayout { - width: settings-scroll.width - 12px; - spacing: 14px; padding-bottom: 4px; - SettingsGroup { - title: GalleryStrings.tr("界面", "Interface", "Интерфейс"); rows: 1; - SettingsRow { - label: UiStrings.language; divider: false; - SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; } + horizontal-scrollbar-policy: always-off; + vertical-scrollbar-policy: as-needed; + // Fluent's scrollbar expands over the viewport on hover. + // Keep its full 14px track plus an 8px gap outside the groups. + Rectangle { + x: 0px; y: 0px; + width: settings-scroll.viewport-width; + height: settings-scroll.viewport-height; + settings-content := VerticalLayout { + padding: 0px; padding-right: settings-dialog.scrollbar-gutter; padding-bottom: 4px; + spacing: 14px; + SettingsGroup { + title: GalleryStrings.tr("界面", "Interface", "Интерфейс"); rows: 1; + SettingsRow { + label: UiStrings.language; divider: false; + SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; } + } } - } - SettingsGroup { - title: GalleryStrings.tr("视频", "Video", "Видео"); - rows: root.capture-method-visible ? 3 : 2; - if root.capture-method-visible: SettingsRow { - label: UiStrings.capture-method; enabled: !root.settings-session-active; - SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; } + SettingsGroup { + title: GalleryStrings.tr("视频", "Video", "Видео"); + rows: root.capture-method-visible ? 3 : 2; + if root.capture-method-visible: SettingsRow { + label: UiStrings.capture-method; enabled: !root.settings-session-active; + SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; } + } + SettingsRow { + label: UiStrings.codec; enabled: !root.settings-session-active; + SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } + } + SettingsToggleRow { label: UiStrings.hardware-codec; checked <=> root.hardware-codec-enabled; enabled: root.hardware-codec-available && !root.settings-session-active; divider: false; } } - SettingsRow { - label: UiStrings.codec; enabled: !root.settings-session-active; - SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } + SettingsGroup { + title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3; + SettingsToggleRow { label: UiStrings.turn-relay; checked <=> root.turn-enabled; enabled: !root.settings-session-active; } + SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; } + SettingsToggleRow { + checked <=> root.self-hosted-enabled; enabled: !root.settings-session-active; divider: false; + SettingsButton { x: 0px; y: 2px; width: parent.width - 60px; height: 32px; quiet: true; align-left: true; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } } + } } - SettingsToggleRow { label: UiStrings.hardware-codec; checked <=> root.hardware-codec-enabled; enabled: root.hardware-codec-available && !root.settings-session-active; divider: false; } - } - SettingsGroup { - title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3; - SettingsToggleRow { label: UiStrings.turn-relay; checked <=> root.turn-enabled; enabled: !root.settings-session-active; } - SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; } - SettingsToggleRow { - checked <=> root.self-hosted-enabled; enabled: !root.settings-session-active; divider: false; - SettingsButton { x: 0px; y: 2px; width: parent.width - 60px; height: 32px; quiet: true; align-left: true; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } } + SettingsGroup { + title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 1; + SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; divider: false; } } - } - SettingsGroup { - title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 1; - SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; divider: false; } - } - SettingsGroup { - title: GalleryStrings.tr("本地存储", "Local storage", "Локальное хранение"); rows: 2; - SettingsToggleRow { label: UiStrings.save-remote-previews; checked <=> root.save-remote-previews; } - SettingsRow { - label: UiStrings.file-save-path; enabled: !root.settings-session-active; divider: false; - SettingsButton { x: parent.width - 116px; y: 4px; width: 104px; height: 28px; text: root.file-save-path == "" ? UiStrings.default-desktop : root.file-save-path; enabled: !root.settings-session-active; clicked => { root.browse-save-path(); } } + SettingsGroup { + title: GalleryStrings.tr("本地存储", "Local storage", "Локальное хранение"); rows: 2; + SettingsToggleRow { label: UiStrings.save-remote-previews; checked <=> root.save-remote-previews; } + SettingsRow { + label: UiStrings.file-save-path; enabled: !root.settings-session-active; divider: false; + SettingsButton { x: parent.width - 116px; y: 4px; width: 104px; height: 28px; text: root.file-save-path == "" ? UiStrings.default-desktop : root.file-save-path; enabled: !root.settings-session-active; clicked => { root.browse-save-path(); } } + } + } + SettingsGroup { + title: GalleryStrings.tr("启动与后台", "Startup & background", "Запуск и фоновая работа"); rows: 2; + SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; } + SettingsToggleRow { label: UiStrings.daemon; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; divider: false; } } - } - SettingsGroup { - title: GalleryStrings.tr("启动与后台", "Startup & background", "Запуск и фоновая работа"); rows: 2; - SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; } - SettingsToggleRow { label: UiStrings.daemon; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; divider: false; } } } } if root.settings-tab == 1: GallerySettings { - x: 18px; y: 108px; width: parent.width - 36px; height: parent.height - 174px; + x: settings-dialog.content-inset; y: 108px; + width: parent.width - 2 * settings-dialog.content-inset; 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: parent.height - 64px; width: parent.width - 36px; height: 1px; background: #e6ebf2; } + Rectangle { + x: settings-dialog.content-inset; y: parent.height - 64px; + width: parent.width - 2 * settings-dialog.content-inset; height: 1px; background: #e6ebf2; + } HorizontalLayout { - 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", "По умолчанию"); - clicked => { root.gallery-resolution-draft = 0; root.gallery-frame-rate-draft = 0; root.gallery-bandwidth-draft = 3; } - } + x: settings-dialog.content-inset; y: parent.height - 50px; + width: parent.width - 2 * settings-dialog.content-inset; height: 32px; spacing: 10px; Rectangle { horizontal-stretch: 1; } SettingsButton { width: 80px; text: UiStrings.cancel; @@ -2075,7 +1909,7 @@ export component MainWindow inherits Window { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 360px; height: root.self-host-settings-error == "" ? 266px : 322px; - border-radius: 12px; border-color: #e1e7ef; background: #f8fafc; + border-radius: UiRadius.surface; border-color: #e1e7ef; background: #f8fafc; drop-shadow-color: #14294424; drop-shadow-blur: 18px; drop-shadow-offset-y: 6px; TouchArea { clicked => { focus-sink.focus(); } } Text { @@ -2083,18 +1917,18 @@ export component MainWindow inherits Window { text: UiStrings.self-hosted-settings; font-size: 16px; font-weight: 600; color: #22324a; } Text { x: 18px; y: 56px; width: parent.width - 36px; height: 20px; text: UiStrings.server-host; color: #34445a; font-size: 12px; overflow: elide; vertical-alignment: center; } - Rectangle { + RoundedLineEdit { x: 18px; y: 80px; width: parent.width - 36px; height: 30px; - background: white; border-width: 1px; border-color: #dfe6ef; border-radius: 6px; - LineEdit { x: 1px; y: 2px; width: parent.width - 2px; height: parent.height - 4px; text <=> root.server-host; input-type: InputType.text; font-size: 12px; } - LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } + surrounding-background: #f8fafc; + normal-border-color: WorkbenchStyle.control-border; + text <=> root.server-host; } Text { x: 18px; y: 120px; width: parent.width - 36px; height: 20px; text: UiStrings.server-port; color: #34445a; font-size: 12px; overflow: elide; vertical-alignment: center; } - Rectangle { + RoundedLineEdit { x: 18px; y: 144px; width: parent.width - 36px; height: 30px; - background: white; border-width: 1px; border-color: #dfe6ef; border-radius: 6px; - LineEdit { x: 1px; y: 2px; width: parent.width - 2px; height: parent.height - 4px; text <=> root.server-port; input-type: InputType.number; font-size: 12px; } - LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } + surrounding-background: #f8fafc; + normal-border-color: WorkbenchStyle.control-border; + text <=> root.server-port; input-type: InputType.number; } if root.self-host-settings-error != "": Text { x: 18px; y: 186px; width: parent.width - 36px; height: 48px; @@ -2111,11 +1945,11 @@ export component MainWindow inherits Window { z: 46; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: (root.width - self.width) / 2; - y: (root.height - self.height) / 2; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; width: 320px; height: max(216px, about-content.preferred-height); - border-radius: 7px; + border-radius: UiRadius.surface; Rectangle { x: 0px; @@ -2123,9 +1957,9 @@ export component MainWindow inherits Window { width: parent.width; height: 21px; background: #d3d3d3; - border-radius: 7px; + border-top-left-radius: UiRadius.surface; + border-top-right-radius: UiRadius.surface; z: 5; - Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } Text { x: 6px; text: UiStrings.about; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } } // Grow for update actions and wrapped translations while keeping @@ -2221,76 +2055,132 @@ export component MainWindow inherits Window { // Update notification modal. if root.update-open && root.update-available: Rectangle { - background: transparent; + background: #14233b45; z: 47; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: 120px; - y: 52px; - width: 400px; - height: 346px; - border-radius: 7px; - Text { x: 30px; y: 22px; width: 320px; height: 24px; text: UiStrings.update-available + ": " + root.latest-version; color: #202124; font-size: ImGuiFontStyle.prominent; vertical-alignment: center; } - Rectangle { - x: 30px; - y: 47px; - width: 320px; - height: 22px; - Text { - x: 0px; - width: parent.width; - height: parent.height; - text: UiStrings.access-website + "https://crossdesk.cn"; - color: website-touch.has-hover ? #174ea6 : #334fd1; - font-size: ImGuiFontStyle.body; - horizontal-alignment: left; - vertical-alignment: center; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 464px; + height: 400px; + border-radius: UiRadius.surface; + border-color: WorkbenchStyle.border; + drop-shadow-color: #07142b24; + drop-shadow-blur: 24px; + drop-shadow-offset-y: 8px; + + VerticalLayout { + padding: 24px; + spacing: 16px; + VerticalLayout { + vertical-stretch: 0; + spacing: 6px; + Text { + height: 24px; + text: UiStrings.update-available; + color: WorkbenchStyle.text; + font-size: 18px; + font-weight: 600; + vertical-alignment: center; + } + HorizontalLayout { + height: 20px; + spacing: 12px; + Text { + text: root.latest-version; + color: WorkbenchStyle.muted; + font-size: 13px; + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } + Rectangle { + width: update-website-label.preferred-width; + accessible-role: button; + accessible-label: update-website-label.text; + accessible-action-default => { root.open-download(); } + update-website-label := Text { + text: UiStrings.access-website + "https://crossdesk.cn"; + color: website-touch.has-hover ? #254cc0 : WorkbenchStyle.accent; + font-size: 11px; + vertical-alignment: center; + } + website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } } + } + } } - website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } } - } - Text { - x: 30px; - y: 75px; - width: 320px; - height: 20px; - text: UiStrings.release-notes; - color: #202124; - font-size: 12px; - vertical-alignment: center; - } - Rectangle { - x: 30px; - y: 105px; - width: 360px; - height: 190px; - background: white; - border-width: 1px; - border-color: #b9bdc3; - ScrollView { - x: 1px; - y: 1px; - width: parent.width - 2px; - height: parent.height - 2px; - VerticalLayout { - width: 340px; - padding-left: 8px; - padding-right: 8px; - padding-top: 8px; - padding-bottom: 8px; - spacing: 7px; - VerticalLayout { - width: 312px; - spacing: 10px; - for block in root.release-note-blocks: VerticalLayout { - padding-top: block.section-gap ? 10px : 0px; - StyledText { width: 312px; text: block.content; default-color: #202124; default-font-size: ImGuiFontStyle.body; } + + VerticalLayout { + spacing: 8px; + Text { + height: 18px; + text: UiStrings.release-notes; + color: WorkbenchStyle.text; + font-size: 12px; + font-weight: 600; + vertical-alignment: center; + } + Rectangle { + background: WorkbenchStyle.field-surface; + border-width: 1px; + border-color: WorkbenchStyle.separator; + border-radius: UiRadius.control; + // Inset the viewport so text and the scrollbar stay clear + // of the rounded frame, including at either scroll limit. + update-notes-scroll := ScrollView { + x: 12px; + y: 12px; + width: parent.width - 24px; + height: parent.height - 24px; + viewport-width: self.width - 12px; + viewport-height: update-notes-content.preferred-height; + horizontal-scrollbar-policy: always-off; + vertical-scrollbar-policy: as-needed; + update-notes-content := VerticalLayout { + width: update-notes-scroll.viewport-width; + alignment: start; + spacing: 8px; + padding-bottom: 6px; + for block in root.release-note-blocks: Rectangle { + height: update-note.preferred-height + (block.section-gap ? 6px : 0px); + update-note := StyledText { + y: block.section-gap ? 6px : 0px; + // Measure wrapped paragraphs at the viewport + // width before stacking the following block. + width: update-notes-scroll.viewport-width; + height: self.preferred-height; + text: block.content; + default-color: WorkbenchStyle.text; + default-font-size: 13px; + } + } } } } } + + VerticalLayout { + vertical-stretch: 0; + spacing: 16px; + Rectangle { height: 1px; background: WorkbenchStyle.separator; } + HorizontalLayout { + alignment: end; + spacing: 8px; + WorkbenchButton { + width: 88px; + height: 34px; + text: UiStrings.later; + clicked => { root.update-open = false; } + } + WorkbenchButton { + width: 88px; + height: 34px; + primary: true; + text: UiStrings.download; + clicked => { root.open-download(); root.update-open = false; } + } + } + } } - CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; clicked => { root.open-download(); root.update-open = false; } } - CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.update-open = false; } } } } @@ -2304,7 +2194,7 @@ export component MainWindow inherits Window { y: (parent.height - self.height) / 2; width: 512px; height: wayland-notice-content.preferred-height; - border-radius: 14px; + border-radius: UiRadius.surface; border-color: #dce3ee; drop-shadow-color: #07142b30; drop-shadow-blur: 28px; @@ -2333,7 +2223,7 @@ export component MainWindow inherits Window { background: #fff8eb; border-color: #f0dfba; border-width: 1px; - border-radius: 9px; + border-radius: UiRadius.surface; VerticalLayout { padding: 14px; spacing: 8px; Text { @@ -2356,7 +2246,7 @@ export component MainWindow inherits Window { background: #f3f7fd; border-color: #dfe8f5; border-width: 1px; - border-radius: 9px; + border-radius: UiRadius.surface; VerticalLayout { padding: 14px; spacing: 8px; Text { @@ -2380,7 +2270,7 @@ export component MainWindow inherits Window { height: 34px; alignment: end; PrimaryButton { width: 104px; height: 34px; - border-radius: 7px; + border-radius: UiRadius.control; text-size: ImGuiFontStyle.base; text: UiStrings.wayland-notice-acknowledge; accessible-role: button; @@ -2399,7 +2289,7 @@ export component MainWindow inherits Window { z: 50; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: 80px; y: 75px; width: 480px; height: 300px; + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 480px; height: 300px; VerticalLayout { padding: 24px; spacing: 14px; DialogPromptText { @@ -2409,7 +2299,7 @@ export component MainWindow inherits Window { text-weight: 600; } Rectangle { - background: #f7f8fa; border-color: #d9dde4; border-width: 1px; border-radius: 6px; + background: #f7f8fa; border-color: #d9dde4; border-width: 1px; border-radius: UiRadius.surface; Text { x: 14px; y: 22px; width: parent.width - 28px; height: parent.height - 24px; text: UiStrings.service-setup-message; color: #3d434d; wrap: word-wrap; vertical-alignment: center; } } if root.portable-service-status != "": Text { @@ -2446,7 +2336,7 @@ export component MainWindow inherits Window { z: 51; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: 100px; y: 130px; width: 440px; height: 190px; + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 190px; VerticalLayout { padding: 24px; spacing: 16px; DialogPromptText { diff --git a/apps/desktop/src/gui/ui/message_panel.slint b/apps/desktop/src/gui/ui/message_panel.slint index a8419d33..4713dbcb 100644 --- a/apps/desktop/src/gui/ui/message_panel.slint +++ b/apps/desktop/src/gui/ui/message_panel.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { ScrollView } from "std-widgets.slint"; import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint"; @@ -68,12 +69,11 @@ export component MessagePanel inherits DialogSurface { } } background: white; - border-radius: 7px; + border-radius: UiRadius.surface; TouchArea { } title-bar := Rectangle { x: 0px; y: 0px; width: parent.width; height: 21px; - background: #d3d3d3; border-radius: 7px; - Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } + background: #d3d3d3; border-top-left-radius: UiRadius.surface; border-top-right-radius: UiRadius.surface; message-heading := Text { x: 6px; width: parent.width - 100px; height: parent.height; text: MessageStrings.messages; @@ -101,7 +101,7 @@ export component MessagePanel inherits DialogSurface { } IconButton { x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px; - border-radius: 3px; + border-radius: UiRadius.control; icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base; icon-color: #202124; hover-background: #bfc2c7; pressed-background: #b2b2b2; @@ -113,7 +113,7 @@ export component MessagePanel inherits DialogSurface { } if root.notice-text != "": Rectangle { x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px; - border-radius: 4px; + border-radius: UiRadius.surface; background: #fef2f2; Text { x: 8px; width: parent.width - 16px; height: parent.height; @@ -133,7 +133,7 @@ export component MessagePanel inherits DialogSurface { for item[index] in root.items: Rectangle { height: 54px; background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent; - border-radius: 4px; + border-radius: UiRadius.control; Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; } if !item.read: Rectangle { x: parent.width - 17px; y: 13px; width: 5px; height: 5px; diff --git a/apps/desktop/src/gui/ui/rounded_line_edit.slint b/apps/desktop/src/gui/ui/rounded_line_edit.slint new file mode 100644 index 00000000..58affe67 --- /dev/null +++ b/apps/desktop/src/gui/ui/rounded_line_edit.slint @@ -0,0 +1,50 @@ +import { LineEdit } from "std-widgets.slint"; +import { UiRadius } from "ui_radius.slint"; + +// Retain the standard editor's selection, clipboard, clear button and input +// method support. Only its outer frame is replaced by the shared shape. +export component RoundedLineEdit inherits Rectangle { + in-out property text; + in property placeholder-text; + in property input-type: InputType.text; + in property enabled: true; + in property font-size: 12px; + in property normal-border-color: #dfe6ef; + in property surrounding-background: white; + callback accepted(); + callback edited(); + height: 30px; + border-radius: UiRadius.control; + background: white; + clip: true; + forward-focus: editor; + + editor := LineEdit { + x: -1px; y: -1px; + width: parent.width + 2px; height: parent.height + 2px; + text <=> root.text; + placeholder-text: root.placeholder-text; + input-type: root.input-type; + enabled: root.enabled; + font-size: root.font-size; + accepted => { root.accepted(); } + edited => { root.edited(); } + } + // The software renderer clips children to rectangular bounds. Mask the + // standard editor's corners before drawing our rounded border on top. + Rectangle { + x: -root.border-radius; y: -root.border-radius; + width: parent.width + 2 * root.border-radius; + height: parent.height + 2 * root.border-radius; + border-width: root.border-radius; + border-radius: 2 * root.border-radius; + border-color: root.surrounding-background; + } + Rectangle { + width: parent.width; height: parent.height; + border-radius: root.border-radius; + border-width: editor.has-focus ? 2px : 1px; + border-color: editor.has-focus ? #3564e3 : root.normal-border-color; + background: transparent; + } +} diff --git a/apps/desktop/src/gui/ui/server_window.slint b/apps/desktop/src/gui/ui/server_window.slint index 344cadfd..e79f875b 100644 --- a/apps/desktop/src/gui/ui/server_window.slint +++ b/apps/desktop/src/gui/ui/server_window.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { ScrollView } from "std-widgets.slint"; import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint"; @@ -27,7 +28,7 @@ component SessionButton inherits Rectangle { callback clicked; width: label.preferred-width + 20px; height: 28px; - border-radius: 5px; + border-radius: UiRadius.control; border-width: 1px; border-color: root.danger ? #d92d20 : #c9ced7; background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20) @@ -73,7 +74,7 @@ component SessionButton inherits Rectangle { } if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle { x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px; - border-radius: 3px; border-width: 1px; border-color: root.danger ? white : #2463c7; + border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7; } } @@ -172,7 +173,7 @@ export component ServerWindow inherits Window { Rectangle { width: root.width; height: root.height; background: white; border-width: 1px; border-color: ImGuiLineStyle.border; - border-radius: 8px; + border-radius: UiRadius.surface; Text { x: 10px; y: 10px; width: parent.width - 20px; height: 24px; text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : ""); @@ -189,7 +190,7 @@ export component ServerWindow inherits Window { select := Rectangle { x: 0px; y: 0px; width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px); - height: parent.height; border-radius: 4px; + height: parent.height; border-radius: UiRadius.control; background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent; if root.controllers.length > 1: Rectangle { x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px; @@ -274,7 +275,7 @@ export component ServerWindow inherits Window { // Draw outside the scrolling list so its last row can reveal its name. if root.hovered-name != "": Rectangle { x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20; - background: #24272d; border-radius: 5px; + background: #24272d; border-radius: UiRadius.control; tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; } } } diff --git a/apps/desktop/src/gui/ui/settings_controls.slint b/apps/desktop/src/gui/ui/settings_controls.slint index f8a5249d..5deab0e2 100644 --- a/apps/desktop/src/gui/ui/settings_controls.slint +++ b/apps/desktop/src/gui/ui/settings_controls.slint @@ -1,4 +1,6 @@ +import { UiRadius } from "ui_radius.slint"; import { CompactComboBox, ToggleSwitch } from "common.slint"; +import { WorkbenchStyle } from "workbench_controls.slint"; // Shared visual treatment for the global settings pages. export component SettingsButton inherits Rectangle { @@ -8,12 +10,13 @@ export component SettingsButton inherits Rectangle { in property align-left: false; in property enabled: true; callback clicked(); - width: 88px; height: 32px; border-radius: 7px; + width: 88px; height: 32px; border-radius: UiRadius.control; background: !root.enabled ? #f0f3f7 : root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc) : touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white; border-width: root.quiet || root.primary ? 0px : 1px; - border-color: #dce3ec; + border-color: !root.enabled ? WorkbenchStyle.border + : touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border; accessible-role: button; accessible-label: root.text; accessible-action-default => { if root.enabled { root.clicked(); } } Text { @@ -28,6 +31,8 @@ export component SettingsButton inherits Rectangle { export component SettingsSelect inherits CompactComboBox { subtle: true; show-arrow: false; + normal-border-color: WorkbenchStyle.control-border; + hover-border-color: WorkbenchStyle.hover-border; height: 28px; } @@ -41,7 +46,7 @@ export component SettingsGroup inherits Rectangle { } Rectangle { y: 24px; width: parent.width; height: parent.height - self.y; - background: white; border-radius: 9px; border-color: #e5eaf1; border-width: 1px; + background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px; VerticalLayout { padding: 0px; spacing: 0px; @children } } } @@ -58,7 +63,7 @@ export component SettingsRow inherits Rectangle { font-size: 12px; overflow: elide; vertical-alignment: center; } if root.divider: Rectangle { - x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #eff2f6; + x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec; } @children } diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index 1e3e54ff..1c00938a 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -1,3 +1,4 @@ +import { UiRadius } from "ui_radius.slint"; import { ComboBox, ScrollView } from "std-widgets.slint"; import { RemoteCursor } from "remote_cursor.slint"; import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint"; @@ -98,7 +99,7 @@ component ControlBarButton inherits Rectangle { background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa : touch.has-hover ? #4296fa : root.selected ? #4296fa : #4296fa66; - border-radius: 3px; + border-radius: UiRadius.control; Text { text: root.icon; @@ -159,7 +160,7 @@ component VideoSettingRow inherits VerticalLayout { height: 27px; background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff : option-touch.has-hover ? #eaf3ff : #f5f6f8; - border-radius: 4px; + border-radius: UiRadius.control; border-width: 1px; border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6; Text { text: option; color: root.enabled ? #30343b : #8b8f95; @@ -967,7 +968,7 @@ export component StreamWindow inherits Window { width: parent.width; height: root.stats-header-height; background: #f3f5f8; - border-radius: 4px; + border-radius: UiRadius.control; Text { x: stats-table.label-column-width + 4px; width: stats-table.value-column-width - 8px; @@ -1004,7 +1005,7 @@ export component StreamWindow inherits Window { width: parent.width; height: stats-table.row-height; background: index == root.stats-rows.length - 1 ? #edf3fc : transparent; - border-radius: 4px; + border-radius: UiRadius.control; Text { x: 4px; width: stats-table.label-column-width - 8px; @@ -1133,7 +1134,7 @@ export component StreamWindow inherits Window { x: root.video-menu-x; y: root.video-menu-y; width: root.video-menu-width; height: root.video-menu-height; background: white; border-width: 1px; border-color: ImGuiLineStyle.border; - border-radius: 5px; z: 11; + border-radius: UiRadius.menu; z: 11; TouchArea { pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } scroll-event(event) => { accept } @@ -1183,7 +1184,7 @@ export component StreamWindow inherits Window { background: white; border-width: 1px; border-color: ImGuiLineStyle.border; - border-radius: 5px; + border-radius: UiRadius.menu; clip: true; z: 11; @@ -1195,7 +1196,7 @@ export component StreamWindow inherits Window { : index == display-menu.hovered-index ? #d8e6f8 : index == root.selected-display ? #e8eef9 : transparent; - border-radius: 3px; + border-radius: UiRadius.control; Text { x: 8px; width: parent.width - 34px; @@ -1246,7 +1247,7 @@ export component StreamWindow inherits Window { if root.shortcut-menu-open: Rectangle { x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: 68px; - background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11; + background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11; VerticalLayout { padding: 4px; spacing: 1px; for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle { @@ -1280,7 +1281,7 @@ export component StreamWindow inherits Window { // Anchor to the button row so expanded stats stay beneath the tooltip. y: control.y + root.control-toolbar-height + self.height + 8px <= video.height ? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px); - background: #30343bf5; border-radius: 5px; z: 12; + background: #30343bf5; border-radius: UiRadius.control; z: 12; control-tip := Text { x: 8px; y: 6px; width: parent.width - 16px; text: root.hovered-control.text; @@ -1304,7 +1305,7 @@ export component StreamWindow inherits Window { width: min(520px, parent.width - 48px); height: max(208px, remote-update-message.y + remote-update-message.height + 92px); border-color: #e5e9f0; - border-radius: 14px; + border-radius: UiRadius.surface; drop-shadow-color: #07142b30; drop-shadow-blur: 32px; drop-shadow-offset-y: 12px; @@ -1320,7 +1321,7 @@ export component StreamWindow inherits Window { } Rectangle { x: 32px; y: 32px; width: 48px; height: 48px; - background: #edf4ff; border-radius: 12px; + background: #edf4ff; border-radius: UiRadius.surface; Path { x: 12px; y: 12px; width: 24px; height: 24px; viewbox-width: 24; viewbox-height: 24; @@ -1355,7 +1356,7 @@ export component StreamWindow inherits Window { PrimaryButton { x: parent.width - 136px; y: parent.height - 68px; width: 104px; height: 36px; - border-radius: 7px; + border-radius: UiRadius.control; text: StreamStrings.remote-update-dismiss; text-size: 14px; clicked => { root.dismiss-remote-update(); } @@ -1365,7 +1366,7 @@ export component StreamWindow inherits Window { // Transfer progress overlay at bottom-left. if root.file-transfer-visible: Rectangle { x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32); - background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7; + background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7; Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; } IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } } ScrollView { diff --git a/apps/desktop/src/gui/ui/ui_radius.slint b/apps/desktop/src/gui/ui/ui_radius.slint new file mode 100644 index 00000000..cdb33d22 --- /dev/null +++ b/apps/desktop/src/gui/ui/ui_radius.slint @@ -0,0 +1,8 @@ +// Logical pixels: compact controls, popup menus, and larger surfaces. +// Circular indicators, switches, progress bars, and native window chrome +// retain radii derived from their own geometry. +export global UiRadius { + out property control: 6px; + out property menu: 8px; + out property surface: 12px; +} diff --git a/apps/desktop/src/gui/ui/workbench_controls.slint b/apps/desktop/src/gui/ui/workbench_controls.slint new file mode 100644 index 00000000..e5006563 --- /dev/null +++ b/apps/desktop/src/gui/ui/workbench_controls.slint @@ -0,0 +1,124 @@ +import { UiRadius } from "ui_radius.slint"; +// Colors and spacing for the connection workbench; shapes are shared app-wide. +export global WorkbenchStyle { + out property canvas: #f2f5f9; + out property surface: #ffffff; + out property border: #c6cfdd; + out property control-border: #b2bfd1; + out property hover-border: #7f97b8; + out property field-surface: #f5f7fb; + out property hover-surface: #eef3fb; + out property pressed-surface: #e4ebf6; + out property separator: #e1e7ef; + out property text: #1c2940; + out property muted: #69788e; + out property accent: #3564e3; + out property success: #18835a; +} + +export component WorkbenchButton inherits Rectangle { + in property text; + in property icon; + in property tooltip; + in property primary: false; + in property quiet: false; + in property enabled: true; + private property keyboard-focus: false; + private property show-focus: button-focus.has-focus && root.keyboard-focus; + callback clicked; + width: 112px; + height: 36px; + border-radius: UiRadius.control; + border-width: root.show-focus ? 2px : 1px; + border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent) + : root.primary ? WorkbenchStyle.accent + : touch.has-hover ? WorkbenchStyle.hover-border + : root.quiet ? transparent : WorkbenchStyle.control-border; + background: root.primary + ? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent) + : (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white); + opacity: root.enabled ? 1 : 0.45; + animate background, border-color { duration: 120ms; } + forward-focus: button-focus; + accessible-role: button; + accessible-label: root.tooltip != "" ? root.tooltip : root.text; + accessible-enabled: root.enabled; + accessible-action-default => { if root.enabled { root.clicked(); } } + + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 8px; + alignment: center; + if root.icon != "": Text { + text: root.icon; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: 14px; + color: root.primary ? white : WorkbenchStyle.muted; + vertical-alignment: center; + accessible-role: none; + } + if root.text != "": Text { + text: root.text; + font-size: 13px; + font-weight: root.primary ? 600 : 500; + color: root.primary ? white : WorkbenchStyle.text; + vertical-alignment: center; + accessible-role: none; + } + } + button-focus := FocusScope { + enabled: root.enabled; + focus-gained(reason) => { + if reason == FocusReason.tab-navigation { + root.keyboard-focus = true; + } else if reason == FocusReason.pointer-click { + root.keyboard-focus = false; + } + } + key-pressed(event) => { + root.keyboard-focus = true; + if event.text == Key.Return || event.text == " " { + root.clicked(); + return accept; + } + return reject; + } + touch := TouchArea { + enabled: root.enabled; + mouse-cursor: pointer; + pointer-event(event) => { + if event.kind == PointerEventKind.down { + root.keyboard-focus = false; + } + } + // Keep focus for subsequent keyboard activation without leaving a + // keyboard focus ring behind after a pointer click. + clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); } + } + } + if touch.has-hover && root.tooltip != "": Rectangle { + x: min(0px, root.width - self.width); + y: root.height + 6px; + width: tip.preferred-width + 16px; + height: 28px; + background: #24324a; + border-radius: UiRadius.control; + z: 20; + tip := Text { + text: root.tooltip; + font-size: 11px; + color: white; + horizontal-alignment: center; + vertical-alignment: center; + } + } +} + +export component WorkbenchCard inherits Rectangle { + background: WorkbenchStyle.surface; + border-width: 1px; + border-color: WorkbenchStyle.border; + border-radius: UiRadius.surface; +} diff --git a/apps/desktop/tests/slint_ui_smoke_test.cpp b/apps/desktop/tests/slint_ui_smoke_test.cpp index fdbc55cc..978e3246 100644 --- a/apps/desktop/tests/slint_ui_smoke_test.cpp +++ b/apps/desktop/tests/slint_ui_smoke_test.cpp @@ -1,5 +1,7 @@ #include "crossdesk_ui.h" #include "fa_solid_900.h" +#include "features/gallery/gallery_window_state.h" +#include "ui/release_notes_text.h" #include "ui/ui_localization.h" #include @@ -125,9 +127,16 @@ bool ConfigureDocumentationDemo( {"987654321", "Dev Workstation", "fixtures/source.png"}, {"246813579", "Office PC", "fixtures/website.png"}, {"135792468", "Home Laptop", "web-client.png"}, + {"864209753", "Studio Mac", "fixtures/source.png"}, + {"753198642", "Build Server", "fixtures/website.png"}, + {"642087531", "Test Device", "web-client.png"}, }; std::vector connections; for (const auto &device : devices) { + if (connections.size() == 3 && options.page != "main-recent-overflow" && + options.page != "main-recent-scrolled") { + break; + } const std::string path = options.demo_assets_path + "/" + device.thumbnail; const auto thumbnail = slint::Image::load_from_path(slint::SharedString(path.c_str())); @@ -219,8 +228,23 @@ void ConfigureMainCapturePage( window->set_wayland_titlebar(false); #endif - if (options.page == "settings") { + if (options.page == "settings" || options.page == "settings-scroll-hover" || + options.page == "settings-scrolled" || options.page == "settings-scroll-drag") { window->set_settings_open(true); + } else if (options.page == "main-menu-badges") { + window->set_announcement_unread(4); + window->set_update_available(true); + window->set_update_open(false); + } else if (options.page == "settings-deskhub" || + options.page == "settings-deskhub-error") { + window->set_settings_open(true); + window->set_settings_tab(1); + if (options.page == "settings-deskhub-error") { + window->set_gallery_settings_error( + window->global().invoke_tr( + "无法保存设置,请重试。", "Unable to save settings. Please try again.", + "Не удалось сохранить настройки. Повторите попытку.")); + } } else if (options.page == "self-hosted") { window->set_settings_open(true); window->set_self_host_settings_open(true); @@ -238,11 +262,52 @@ void ConfigureMainCapturePage( ? crossdesk::ui::UpdateCheckStatus::Failed : crossdesk::ui::UpdateCheckStatus::Available); } - } else if (options.page == "update") { + } else if (options.page == "update" || options.page == "update-scrolled" || + options.page == "update-short") { window->set_update_available(true); - window->set_latest_version("v0.0.1"); + window->set_latest_version("v1.6.1-20261010"); window->set_release_name("CrossDesk Preview"); - window->set_release_date("2026-08-25"); + window->set_release_date("2026-10-10"); + // Illustrative notes exercise the production Markdown parser and wrapping, + // including enough content to inspect both ends of the scrolling viewport. + const char *notes[] = { + "**新增功能与改进**\n\n" + "1. 增强信令快速重连与连接恢复能力;桌面端配置修改无需重新连接信令服务。\n" + "2. Windows 安装版新增登录前远程控制支持,被控端可随系统启动,在尚未登录时接受远程连接。\n" + "3. Linux 提供普通版与 Xfce 虚拟桌面版独立安装包,普通版不再强制安装 Xfce 和 Xvfb。\n" + "4. 被控端会话窗口改为紧凑的屏幕边缘状态栏。\n" + "5. 完善 iOS / Android 原生控制端,优化远程画面、触控与外接键鼠体验。\n\n" + "**问题修复**\n\n" + "6. 修复连接中断后的状态显示,改善高分辨率屏幕上的文字与按钮边界。\n" + "7. 统一窗口、输入框与按钮的圆角,优化更新提示与长文本滚动体验。", + "**Features and improvements**\n\n" + "1. Improve signaling reconnection and session recovery. Desktop configuration changes no longer require reconnecting to the signaling service.\n" + "2. Add remote control before Windows sign-in, allowing the installed client to accept connections when the system starts.\n" + "3. Provide separate Linux packages for standard installations and Xfce virtual desktops.\n" + "4. Replace the controlled session window with a compact status bar at the edge of the screen.\n" + "5. Improve the native iOS and Android clients, including video, touch, and external keyboard and mouse support.\n\n" + "**Fixes**\n\n" + "6. Correct connection status after interruptions and improve text and control borders on high resolution displays.\n" + "7. Unify corner radii and improve update notifications and scrolling for long release notes.", + "**Новые возможности и улучшения**\n\n" + "1. Улучшено восстановление соединения. Изменение настроек больше не требует повторного подключения к серверу сигнализации.\n" + "2. Добавлено удалённое управление Windows до входа пользователя в систему.\n" + "3. Подготовлены отдельные пакеты Linux для стандартной установки и виртуальных рабочих столов Xfce.\n" + "4. Окно управляемого сеанса заменено компактной строкой состояния у края экрана.\n" + "5. Улучшены клиенты iOS и Android, работа с сенсорным экраном, внешней клавиатурой и мышью.\n\n" + "**Исправления**\n\n" + "6. Исправлено отображение состояния после обрыва соединения, улучшена читаемость на экранах высокого разрешения.\n" + "7. Унифицированы скругления элементов и улучшена прокрутка длинных описаний обновлений."}; + std::vector blocks; + for (const auto &parsed : crossdesk::release_notes_text::Parse(notes[language])) { + crossdesk::ui::ReleaseNoteBlock block{}; + block.content = parsed.content; + block.section_gap = parsed.section_gap; + blocks.push_back(std::move(block)); + if (options.page == "update-short" && blocks.size() == 2) break; + } + window->set_release_note_blocks( + std::make_shared>(blocks)); window->set_update_open(true); } else if (options.page == "reset-password") { window->set_reset_password_open(true); @@ -363,10 +428,72 @@ int RunCaptureMode( slint::ComponentHandle &window, slint::ComponentHandle &stream, slint::ComponentHandle &server) { + window->on_format_remote_id([](slint::SharedString id) { return id; }); ConfigureMainCapturePage(window, options); if (!ConfigureDocumentationDemo(window, options)) { return 8; } + // Preview DeskHub with the same demo images, without opening any sessions. + if (options.page.starts_with("gallery")) { + window->set_gallery_mode(true); + std::vector tiles; + const auto recent = window->get_recent_connections(); + for (size_t index = 0; index < recent->row_count(); ++index) { + const auto device = recent->row_data(index).value(); + crossdesk::ui::GalleryTile tile{}; + tile.id = device.remote_id; + tile.name = device.display_name; + tile.online = tile.presence_known = tile.connected = true; + tile.has_frame = device.has_thumbnail; + tile.frame = device.thumbnail; + tile.selected = options.page == "gallery-batch"; + tiles.push_back(tile); + } + window->set_gallery_tiles( + std::make_shared>(tiles)); + window->set_gallery_count(static_cast(tiles.size())); + window->set_gallery_connected(static_cast(tiles.size())); + window->set_gallery_groups( + std::make_shared>( + std::vector{window->global().invoke_tr( + "全部设备", "All devices", "Все устройства")})); + window->set_gallery_batch(options.page == "gallery-batch"); + window->set_gallery_selected(options.page == "gallery-batch" + ? static_cast(tiles.size()) : 0); + window->set_gallery_adding(options.page == "gallery-add"); + if (options.page == "gallery-focus" && !tiles.empty()) + window->set_gallery_focused(tiles.front()); + // A 960 x 720 preview includes the native 22px macOS titlebar. + const float client_height = +#if defined(__APPLE__) + 698.0f; +#else + 720.0f; +#endif + window->window().set_size( + slint::LogicalSize(slint::Size{960.0f, client_height})); + } + // Workbench states use the same rendered components and demo-only data. + if (options.page == "main-empty" || options.page == "main-empty-group") { + window->set_recent_connections( + std::make_shared>()); + window->set_recent_group_index(options.page == "main-empty-group" ? 1 : 0); + } else if (options.page == "main-error") { + window->set_remote_id_input("123"); + window->set_connection_input_error( + window->global().invoke_tr( + "请输入完整的 9 位设备 ID", "Enter a complete 9-digit device ID", + "Введите полный ID устройства из 9 цифр")); + } else if (options.page == "main-offline") { + window->set_signal_connected(false); + window->set_password_visible(false); + const auto connections = window->get_recent_connections(); + for (size_t index = 0; index < connections->row_count(); ++index) { + auto device = connections->row_data(index).value(); + device.online = false; + connections->set_row_data(index, device); + } + } stream->hide(); server->hide(); @@ -422,11 +549,113 @@ int RunCaptureMode( // taking the snapshot; capturing immediately after show() can yield 1x. bool snapshot_written = false; slint::Timer snapshot_timer; + slint::Timer capture_settle_timer; + slint::Timer pointer_move_timer; snapshot_timer.start(slint::TimerMode::SingleShot, std::chrono::milliseconds(250), [&] { - snapshot_written = - WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str()); - slint::quit_event_loop(); + if (options.page == "main-device-tooltip" || + options.page == "main-device-tooltip-moved" || + options.page == "main-device-tooltip-edge" || + options.page == "main-device-tooltip-exit") { + const float titlebar = window->get_minimum_window_height() - 472.0f; + window->window().dispatch_pointer_move_event( + slint::LogicalPosition(slint::Point{90.0f, titlebar + 330.0f})); + if (options.page != "main-device-tooltip") { + // A second event in the same card verifies hover movement without + // pressing the mouse or relying on another enter/leave event. + pointer_move_timer.start(slint::TimerMode::SingleShot, + std::chrono::milliseconds(50), [&, titlebar] { + const bool edge = options.page == "main-device-tooltip-edge"; + const bool leave = options.page == "main-device-tooltip-exit"; + window->window().dispatch_pointer_move_event(slint::LogicalPosition( + slint::Point{leave ? 2.0f : edge ? window->get_minimum_window_width() - 40.0f : 170.0f, + leave ? 2.0f : titlebar + 340.0f})); + }); + } + } else if (options.page == "settings-scroll-hover" || + options.page == "settings-scrolled" || + options.page == "settings-scroll-drag") { + const float dialog_x = (window->get_minimum_window_width() - 400.0f) / 2; + const float dialog_y = (window->get_minimum_window_height() - 446.0f) / 2; + const auto scrollbar_position = slint::LogicalPosition( + slint::Point{dialog_x + 389.0f, dialog_y + 150.0f}); + if (options.page == "settings-scrolled") { + window->window().dispatch_pointer_scroll_event( + slint::LogicalPosition(slint::Point{320.0f, 260.0f}), + 0.0f, -2000.0f); + } + window->window().dispatch_pointer_move_event(scrollbar_position); + if (options.page == "settings-scroll-drag") { + window->window().dispatch_pointer_press_event( + scrollbar_position, slint::PointerEventButton::Left); + const auto drag_end = slint::LogicalPosition( + slint::Point{dialog_x + 389.0f, dialog_y + 340.0f}); + window->window().dispatch_pointer_move_event(drag_end); + window->window().dispatch_pointer_release_event( + drag_end, slint::PointerEventButton::Left); + } + } else if (options.page == "update-scrolled") { + window->window().dispatch_pointer_scroll_event( + slint::LogicalPosition(slint::Point{320.0f, 260.0f}), + 0.0f, -2000.0f); + window->window().dispatch_pointer_move_event( + slint::LogicalPosition(slint::Point{2.0f, 2.0f})); + } else if (options.page == "main-recent-scrolled") { + window->window().dispatch_pointer_scroll_event( + slint::LogicalPosition(slint::Point{ + 400.0f, window->get_minimum_window_height() - 472.0f + 360.0f}), + -400.0f, 0.0f); + window->window().dispatch_pointer_move_event( + slint::LogicalPosition(slint::Point{2.0f, 2.0f})); + } else if (options.page == "main-menu" || + options.page == "main-menu-badges") { + const auto menu_position = slint::LogicalPosition( + slint::Point{window->get_minimum_window_width() - 38.0f, + window->get_minimum_window_height() - 472.0f + 20.0f}); + window->window().dispatch_pointer_press_event( + menu_position, slint::PointerEventButton::Left); + window->window().dispatch_pointer_release_event( + menu_position, slint::PointerEventButton::Left); + window->window().dispatch_pointer_move_event( + slint::LogicalPosition(slint::Point{2.0f, 2.0f})); + } else if (options.page == "main-copy-tooltip" || + options.page == "main-password-tooltip") { + const bool copy = options.page == "main-copy-tooltip"; + window->window().dispatch_pointer_move_event(slint::LogicalPosition( + slint::Point{window->get_minimum_window_width() / 2 - (copy ? 48.0f : 42.0f), + window->get_minimum_window_height() - 472.0f + + (copy ? 150.0f : 216.0f)})); + } else if (options.page == "main-copy-released" || + options.page == "main-copy-released-outside" || + options.page == "main-keyboard-tab") { + const auto copy_position = slint::LogicalPosition(slint::Point{ + window->get_minimum_window_width() / 2 - 48.0f, + window->get_minimum_window_height() - 472.0f + 150.0f}); + window->window().dispatch_pointer_move_event(copy_position); + window->window().dispatch_pointer_press_event( + copy_position, slint::PointerEventButton::Left); + window->window().dispatch_pointer_release_event( + copy_position, slint::PointerEventButton::Left); + if (options.page != "main-copy-released") { + window->window().dispatch_pointer_move_event( + slint::LogicalPosition(slint::Point{2.0f, 2.0f})); + } + if (options.page == "main-keyboard-tab") { + // Traverse the remote ID input and focus the Connect button. + for (int index = 0; index < 2; ++index) { + window->window().dispatch_key_press_event("\t"); + window->window().dispatch_key_release_event("\t"); + } + } + } + // Let settings scroll animations settle as well as hover/focus feedback. + capture_settle_timer.start(slint::TimerMode::SingleShot, + std::chrono::milliseconds( + options.page.rfind("settings", 0) == 0 ? 350 : 150), [&] { + snapshot_written = + WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str()); + slint::quit_event_loop(); + }); }); slint::run_event_loop(); return snapshot_written ? 0 : 7; @@ -440,10 +669,194 @@ int RunCaptureMode( return 0; } +// Sample the native window while the same restoration used by the application +// runs. A component-only screenshot used to miss the conflicting restore width. +bool VerifyMainWindowGeometry() { + for (bool restore : {false, true}) { + auto component = crossdesk::ui::MainWindow::create(); + auto &window = component->window(); + crossdesk::gallery::WindowState placement; + const float width = component->get_minimum_window_width(); + const float height = component->get_minimum_window_height(); + if (width != 640.0f || height != 472.0f) return false; + if (restore) { + placement.BeginRestore(); + placement.PrepareForShow( + window, false, + placement.RestoreSize(false, window.scale_factor(), height, width), + std::nullopt); + } + window.show(); + bool stable = true; + int samples = 0; + slint::Timer timer; + timer.start(slint::TimerMode::Repeated, std::chrono::milliseconds(16), [&] { + const auto actual = window.size(); + const auto expected = placement.RestoreSize( + false, window.scale_factor(), height, width); + if (actual.width != expected.width || actual.height != expected.height) { + std::cerr << "Startup geometry changed: restore=" << restore + << " sample=" << samples << " actual=" << actual.width + << 'x' << actual.height << " expected=" << expected.width + << 'x' << expected.height << '\n'; + stable = false; + } + if (placement.RestoringNormal()) { + if (!placement.NormalSizeRestored(actual, expected)) + window.set_size(expected); + } else if (placement.Restoring()) { + // Match the application's fixed connection-window role. AppKit can + // report a fixed-size window as zoomed even at its normal dimensions. + placement.FinishRestore(false, actual, false, false); + } + if (++samples == 20) slint::quit_event_loop(); + }); + slint::run_event_loop(); + timer.stop(); + window.hide(); + if (!stable || placement.Restoring()) return false; + } + return true; +} + +// Exercise hit targets after the workbench layout change, rather than calling +// callbacks directly: overlapping surfaces must not swallow these actions. +bool VerifyWorkbenchActions() { + auto window = crossdesk::ui::MainWindow::create(); + const float width = window->get_minimum_window_width(); + window->window().set_size( + slint::LogicalSize(slint::Size{width, window->get_minimum_window_height()})); + auto click = [&](float x, float y) { + const auto position = slint::LogicalPosition(slint::Point{x, y}); + window->window().dispatch_pointer_press_event( + position, slint::PointerEventButton::Left); + window->window().dispatch_pointer_release_event( + position, slint::PointerEventButton::Left); + }; + int copies = 0, visibility_changes = 0, refreshes = 0, connections = 0; + bool gallery_opened = false; + window->on_copy_local_id([&] { ++copies; return true; }); + window->on_toggle_password_visibility([&] { ++visibility_changes; }); + window->on_refresh_password([&] { ++refreshes; }); + window->on_open_gallery([&] { gallery_opened = true; }); + window->set_remote_id_input("987 654 321"); + window->on_connect_requested([&](slint::SharedString id) { + if (id == "987 654 321") ++connections; + }); + click(width / 2 - 48, 150); + click(width / 2 - 118, 216); + click(width / 2 - 80, 216); + click(width / 2 - 42, 216); + if (copies != 1 || visibility_changes != 1 || refreshes != 1 || + !window->get_reset_password_open()) return false; + window->set_reset_password_open(false); + window->set_password_change_pending(true); + click(width / 2 - 80, 216); + click(width / 2 - 42, 216); + if (refreshes != 1 || window->get_reset_password_open()) return false; + click(width * 0.75f - 6, 216); + // Space activates the focused primary button; pending state blocks repeats. + window->window().dispatch_key_press_event(" "); + window->window().dispatch_key_release_event(" "); + if (connections != 2) return false; + window->set_connection_pending(true); + click(width * 0.75f - 6, 216); + if (connections != 2) return false; + window->set_connection_pending(false); + click(width - 38, 20); + click(width - 78, 56); + if (!window->get_settings_open()) return false; + // Both tabs must remain at the same position, including with a save error. + click(width / 2 + 87, 89); + if (window->get_settings_tab() != 1) return false; + click(width / 2 - 87, 89); + if (window->get_settings_tab() != 0) return false; + click(width / 2 + 87, 89); + window->set_gallery_settings_error("Unable to save settings. Please try again."); + click(width / 2 - 87, 89); + if (window->get_settings_tab() != 0) return false; + window->set_gallery_settings_error(""); + window->set_settings_open(false); + click(60, 20); + if (!gallery_opened) return false; + crossdesk::ui::RecentConnection device{}; + device.remote_id = "987654321"; + device.display_name = "Workstation"; + device.online = true; + window->set_recent_connections( + std::make_shared>( + std::vector{device})); + bool recent_connected = false; + window->on_recent_connect([&](slint::SharedString id) { + recent_connected = id == "987654321"; + }); + click(100, 344); + if (!recent_connected) return false; + bool password_submitted = false, cancelled = false; + window->on_connection_submit_password([&](slint::SharedString value, bool) { + password_submitted = value == "654321"; + }); + window->on_connection_cancel([&] { cancelled = true; }); + window->set_connection_password("654321"); + window->set_connection_password_required(true); + window->set_connection_dialog_open(true); + click(width / 2 + 62, 312); + click(width / 2 - 62, 312); + if (!password_submitted || !cancelled) return false; + window->set_connection_dialog_open(false); + + // Rounded frames must preserve editor focus, text bindings and Enter actions. + auto type = [&](std::string_view text) { + for (char character : text) { + const slint::SharedString key(std::string(1, character).c_str()); + window->window().dispatch_key_press_event(key); + window->window().dispatch_key_release_event(key); + } + }; + bool renamed = false; + window->on_recent_edit_alias([&](slint::SharedString, slint::SharedString name) { + renamed = name == "Office"; + }); + window->set_alias_input(""); + window->set_alias_open(true); + click(width / 2, 238); + type("Office\n"); + if (!renamed || window->get_alias_open()) { + std::cerr << "Alias editor did not accept the typed name; text=" + << std::string(window->get_alias_input()) << '\n'; + return false; + } + + bool added = false; + window->on_gallery_add_device([&](slint::SharedString id, + slint::SharedString password, slint::SharedString group, bool) { + added = id == "123456789" && password == "123456" && group == "Lab"; + return added; + }); + window->set_gallery_mode(true); + window->window().set_size(slint::LogicalSize(slint::Size{width, 448.0f})); + window->set_gallery_adding(true); + click(width / 2, 142); + type("123456789\n123456\nLab\n"); + if (!added || window->get_gallery_adding()) { + std::cerr << "Add-device editors did not retain Enter focus traversal\n"; + return false; + } + return true; +} + } // namespace int main() { const CaptureOptions capture_options = LoadCaptureOptions(); + if (!VerifyMainWindowGeometry()) { + std::cerr << "Main window dimensions must stay stable through startup\n"; + return 11; + } + if (!VerifyWorkbenchActions()) { + std::cerr << "Workbench controls must retain their pointer and keyboard actions\n"; + return 10; + } auto window = crossdesk::ui::MainWindow::create(); auto stream = crossdesk::ui::StreamWindow::create(); auto server = crossdesk::ui::ServerWindow::create();