[ui] refine desktop layouts and unify control styling

This commit is contained in:
kunkundi
2026-10-14 19:29:08 +08:00
parent 3043b5f110
commit 2cf0d79543
19 changed files with 1366 additions and 816 deletions
+420 -7
View File
@@ -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 <algorithm>
@@ -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<crossdesk::ui::RecentConnection> 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<crossdesk::ui::GalleryStrings>().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<crossdesk::ui::ReleaseNoteBlock> 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<slint::VectorModel<crossdesk::ui::ReleaseNoteBlock>>(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<crossdesk::ui::MainWindow> &window,
slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
slint::ComponentHandle<crossdesk::ui::ServerWindow> &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<crossdesk::ui::GalleryTile> 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<slint::VectorModel<crossdesk::ui::GalleryTile>>(tiles));
window->set_gallery_count(static_cast<int>(tiles.size()));
window->set_gallery_connected(static_cast<int>(tiles.size()));
window->set_gallery_groups(
std::make_shared<slint::VectorModel<slint::SharedString>>(
std::vector{window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
"全部设备", "All devices", "Все устройства")}));
window->set_gallery_batch(options.page == "gallery-batch");
window->set_gallery_selected(options.page == "gallery-batch"
? static_cast<int>(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<float>{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<slint::VectorModel<crossdesk::ui::RecentConnection>>());
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<crossdesk::ui::GalleryStrings>().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<float>{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<float>{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<float>{dialog_x + 389.0f, dialog_y + 150.0f});
if (options.page == "settings-scrolled") {
window->window().dispatch_pointer_scroll_event(
slint::LogicalPosition(slint::Point<float>{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<float>{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<float>{320.0f, 260.0f}),
0.0f, -2000.0f);
window->window().dispatch_pointer_move_event(
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
} else if (options.page == "main-recent-scrolled") {
window->window().dispatch_pointer_scroll_event(
slint::LogicalPosition(slint::Point<float>{
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<float>{2.0f, 2.0f}));
} else if (options.page == "main-menu" ||
options.page == "main-menu-badges") {
const auto menu_position = slint::LogicalPosition(
slint::Point<float>{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<float>{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<float>{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<float>{
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<float>{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<float>{width, window->get_minimum_window_height()}));
auto click = [&](float x, float y) {
const auto position = slint::LogicalPosition(slint::Point<float>{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<slint::VectorModel<crossdesk::ui::RecentConnection>>(
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<float>{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();