From 5199b2df11e3129c961569236a5a5ca6e741f480 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 14 Oct 2026 02:37:06 +0800 Subject: [PATCH] [feat] add standalone DeskHub window and resizable device table --- .../src/gui/application/gui_application.cpp | 934 +++++++++++------- .../src/gui/application/gui_application.h | 23 +- .../features/gallery/gallery_window_state.h | 20 - .../gui/features/gallery/gallery_workspace.h | 47 +- apps/desktop/src/gui/ui/common.slint | 3 +- .../src/gui/ui/gallery_network_dialog.slint | 32 +- apps/desktop/src/gui/ui/gallery_view.slint | 417 ++++++-- apps/desktop/src/gui/ui/main_window.slint | 136 +-- .../src/platform/macos/gui/window_drag_mac.mm | 84 +- apps/desktop/src/platform/window_drag.h | 14 +- docs/deskhub.md | 23 +- 11 files changed, 1113 insertions(+), 620 deletions(-) diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 322d2e45..582956c9 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -241,6 +241,51 @@ bool X11WindowMatches(Display* display, ::Window x11_window, std::abs(attributes.height - static_cast(size.height)) <= kGeometryTolerance; } + +void X11ActivateGalleryWindow(Display* display) { + if (!display) return; + Atom type = None; + int format = 0; + unsigned long count = 0, remaining = 0; + unsigned char* data = nullptr; + const auto root = DefaultRootWindow(display); + if (XGetWindowProperty(display, root, XInternAtom(display, "_NET_CLIENT_LIST", False), + 0, 4096, False, XA_WINDOW, &type, &format, &count, + &remaining, &data) != Success || !data) { + if (data) XFree(data); + return; + } + if (type == XA_WINDOW && format == 32) { + const auto* windows = reinterpret_cast(data); + for (unsigned long i = 0; i < count; ++i) { + if (X11GetWindowProcessId(display, windows[i]) != + static_cast(getpid())) continue; + unsigned char* name = nullptr; + unsigned long length = 0; + Atom name_type = None; + int name_format = 0; + XGetWindowProperty(display, windows[i], XInternAtom(display, "_NET_WM_NAME", False), + 0, 256, False, XInternAtom(display, "UTF8_STRING", False), + &name_type, &name_format, &length, &remaining, &name); + const bool matches = name && name_format == 8 && + std::string_view(reinterpret_cast(name), length).starts_with("CrossDesk · "); + if (name) XFree(name); + if (!matches) continue; + XEvent event{}; + event.xclient.type = ClientMessage; + event.xclient.window = windows[i]; + event.xclient.message_type = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + event.xclient.format = 32; + event.xclient.data.l[0] = 1; + event.xclient.data.l[1] = CurrentTime; + XSendEvent(display, root, False, SubstructureRedirectMask | SubstructureNotifyMask, &event); + XFlush(display); + break; + } + } + XFree(data); +} + #endif #if _WIN32 @@ -851,9 +896,12 @@ struct GuiApplication::SlintUi { std::filesystem::path gallery_path; std::vector gallery_ids; std::set gallery_hidden_active_ids; - std::string group_edit_id; - std::shared_ptr> group_options = - std::make_shared>(); + struct GroupEditor { + std::string id; + std::shared_ptr> options = + std::make_shared>(); + }; + GroupEditor group_editors[2]; // Snapshot while the import picker is open; never includes credentials. std::shared_ptr> gallery_recent_model = std::make_shared>(); @@ -863,11 +911,12 @@ struct GuiApplication::SlintUi { std::chrono::steady_clock::time_point gallery_network_synced{}; std::shared_ptr> gallery_network_model = std::make_shared>(); - gallery::WindowState gallery_window; + gallery::WindowState connection_window, gallery_window; gallery::FullscreenTransition gallery_transition; - int main_restore_ticks = 0; - float loaded_window_scale = 0; - std::optional observed_main_size; + int restore_ticks[2] = {}; + float loaded_window_scale[2] = {}; + bool gallery_shown = false; + std::optional observed_gallery_size; struct Preview { std::weak_ptr session; uint64_t sequence = 0; @@ -905,7 +954,11 @@ struct GuiApplication::SlintUi { std::set pressed_mouse_buttons; uint64_t announcement_version = UINT64_MAX; std::string announcement_selected_id, announcement_selected_revision; + // Share the window shell and dialogs, but keep each component's UI state and + // native window independent. Only the wall instance ever uses gallery mode. slint::ComponentHandle main = ui::MainWindow::create(); + slint::ComponentHandle wall = ui::MainWindow::create(); + auto& Window(bool gallery) { return gallery ? wall : main; } std::shared_ptr> release_note_blocks_model = std::make_shared>(); @@ -932,16 +985,18 @@ struct GuiApplication::SlintUi { std::string controller_model_signature; std::string connection_dialog_remote_id; bool connection_password_initialized = false; + bool connection_dialog_gallery = false; bool portable_service_dialog_initialized = false; std::string recent_model_signature; std::optional recent_group_filter; std::vector recent_filter_groups; slint::Timer timer; slint::Timer server_file_dialog_timer; - WindowDragState main_drag; + WindowDragState main_drag, gallery_drag; WindowDragState stream_drag; WindowDragState server_drag; int main_native_titlebar_attempts = 30; + int gallery_native_titlebar_attempts = 0; int stream_live_resize_configuration_attempts = 0; int stream_initial_position_attempts = 0; SDL_DisplayID server_display = 0; @@ -1030,7 +1085,7 @@ int GuiApplication::Run() { InitializeUi(); // Configure the initial geometry before the native window becomes visible. // Subsequent ticks only acknowledge that placement and its resolved DPI. - RestoreMainWindowGeometry(true); + RestoreWindowGeometry(true); ui_->timer.start(slint::TimerMode::Repeated, 16ms, [this] { Tick(); }); ScheduleNextVideoFrame(); @@ -1041,8 +1096,7 @@ int GuiApplication::Run() { #if _WIN32 ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #elif defined(__APPLE__) - ConfigureMainWindowChrome(ui_->main->window().appkit_view(), - ui_->main->get_gallery_mode()); + ConfigureMainWindowChrome(ui_->main->window().appkit_view()); #endif slint::run_event_loop(slint::EventLoopMode::RunUntilQuit); Cleanup(); @@ -1172,20 +1226,33 @@ void GuiApplication::InitializeUi() { ui_->gallery_path = std::filesystem::path(cache_path_) / "deskhub.json"; if (!ui_->gallery.Load(ui_->gallery_path)) LOG_WARN("Could not load DeskHub workspace; original file retained"); - ui_->main->set_gallery_tiles(ui_->gallery_model); - ui_->main->set_gallery_selected_devices(ui_->gallery_selected_model); - ui_->main->set_gallery_network_devices(ui_->gallery_network_model); - // Always start on Connection; saved wall geometry is restored only when - // the user switches to DeskHub, regardless of the previous launch's mode. - ui_->main->set_gallery_mode(false); + ui_->wall->set_gallery_tiles(ui_->gallery_model); + ui_->wall->set_gallery_selected_devices(ui_->gallery_selected_model); + ui_->wall->set_gallery_network_devices(ui_->gallery_network_model); + ui_->wall->set_gallery_mode(true); + ui_->wall->set_custom_titlebar(ui_->main->get_custom_titlebar()); + ui_->wall->set_wayland_titlebar(ui_->main->get_wayland_titlebar()); + ui_->wall->set_release_note_blocks(ui_->release_note_blocks_model); + ui_->wall->set_update_open(false); + RegisterFontAwesome(ui_->wall->window()); + // The first DeskHub window is 960 x 720 logical pixels including its titlebar. + // Custom titlebars are part of the Slint client; AppKit's titlebar is not. + auto default_gallery_size = + slint::LogicalSize(slint::Size{960.0f, 720.0f}); +#if defined(__APPLE__) + default_gallery_size.height -= MainWindowInitialPositionYOffset(); +#endif + ui_->gallery_window.wall_size = default_gallery_size; try { std::ifstream file(ui_->gallery_path.string() + ".window"); if (file) { const auto saved = nlohmann::json::parse(file); const float scale = std::max(ui_->main->window().scale_factor(), 1.0f); - ui_->loaded_window_scale = scale; - const int width = std::clamp(saved.value("width", 640), 640, 7680); - const int height = std::clamp(saved.value("height", 486), 486, 4320); + ui_->loaded_window_scale[0] = ui_->loaded_window_scale[1] = scale; + const int width = std::clamp( + saved.value("width", int(default_gallery_size.width)), 640, 7680); + const int height = std::clamp( + saved.value("height", int(default_gallery_size.height)), 486, 4320); ui_->gallery_window.wall_size = slint::LogicalSize(slint::Size{float(width), float(height)}); ui_->gallery_window.wall_fullscreen = saved.value("fullscreen", false); @@ -1203,17 +1270,22 @@ void GuiApplication::InitializeUi() { int(saved.at("wall_x").get() * scale), int(saved.at("wall_y").get() * scale)}); if (saved.contains("connection_x")) - ui_->gallery_window.connection_position = + ui_->connection_window.connection_position = slint::PhysicalPosition(slint::Point{ int(saved.at("connection_x").get() * scale), int(saved.at("connection_y").get() * scale)}); - ui_->gallery_window.BeginRestore(); + const float tile_width = saved.value("tile_width", 216.0f); + if (std::isfinite(tile_width)) + ui_->wall->set_gallery_tile_width(std::clamp(tile_width, 184.0f, 960.0f)); + ui_->connection_window.BeginRestore(); } } catch (...) { LOG_WARN("Could not restore DeskHub window state"); } ResetSettingsUi(); + ResetSettingsUi(true); BindMainCallbacks(); + BindMainCallbacks(true); InitializeSystemTray(); UpdateLocalization(); SyncMainWindow(); @@ -1234,8 +1306,7 @@ void GuiApplication::InitializeSystemTray() { #endif ui_->main->window().set_minimized(false); #if defined(__APPLE__) - ConfigureMainWindowChrome(ui_->main->window().appkit_view(), - ui_->main->get_gallery_mode()); + ConfigureMainWindowChrome(ui_->main->window().appkit_view()); MacActivateWindow(ui_->main->window().appkit_view()); // A window restored from the tray may only obtain its NSWindow after the // callback returns, so let the regular UI tick configure it. @@ -1293,94 +1364,111 @@ bool GuiApplication::MinimizeMainWindowToTray() { #endif } -void GuiApplication::ResetSettingsUi() { +void GuiApplication::ResetSettingsUi(bool gallery_window) { if (!ui_) { return; } - ui_->main->set_language_index(language_button_value_); - ui_->main->set_codec_index(video_encode_format_button_value_); + auto& main = ui_->Window(gallery_window); + main->set_language_index(language_button_value_); + main->set_codec_index(video_encode_format_button_value_); const bool hardware_codec_available = ConfigCenter::IsHardwareVideoCodecAvailable(); - ui_->main->set_hardware_codec_available(hardware_codec_available); - ui_->main->set_hardware_codec_enabled(hardware_codec_available && - enable_hardware_video_codec_); - ui_->main->set_turn_enabled(enable_turn_); - ui_->main->set_force_relay_enabled(config_center_->IsForceRelay()); - ui_->main->set_self_hosted_enabled(enable_self_hosted_); - ui_->main->set_autostart_enabled(enable_autostart_); - ui_->main->set_daemon_enabled(enable_daemon_); - ui_->main->set_save_remote_previews(config_center_->IsSaveRemotePreviews()); - ui_->main->set_privacy_on_connect_enabled( + main->set_hardware_codec_available(hardware_codec_available); + main->set_hardware_codec_enabled(hardware_codec_available && + enable_hardware_video_codec_); + main->set_turn_enabled(enable_turn_); + main->set_force_relay_enabled(config_center_->IsForceRelay()); + main->set_self_hosted_enabled(enable_self_hosted_); + main->set_autostart_enabled(enable_autostart_); + main->set_daemon_enabled(enable_daemon_); + main->set_save_remote_previews(config_center_->IsSaveRemotePreviews()); + main->set_privacy_on_connect_enabled( config_center_->IsEnablePrivacyScreen()); #ifdef _WIN32 - ui_->main->set_capture_method_visible(true); - ui_->main->set_capture_method_index( + main->set_capture_method_visible(true); + main->set_capture_method_index( static_cast(config_center_->GetScreenCaptureMethod())); #endif #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) - ui_->main->set_privacy_setting_available(true); + main->set_privacy_setting_available(true); #else - ui_->main->set_privacy_setting_available(false); + main->set_privacy_setting_available(false); #endif - ui_->main->set_file_save_path(file_transfer_save_path_buf_); - ui_->main->set_server_host(signal_server_ip_self_); - ui_->main->set_server_port(signal_server_port_self_); - ui_->main->set_self_host_settings_error(""); - ui_->main->set_gallery_resolution_index(ui_->gallery.stream_settings.resolution); - ui_->main->set_gallery_frame_rate_index(ui_->gallery.stream_settings.FrameRateIndex()); - ui_->main->set_gallery_resolution_draft(ui_->gallery.stream_settings.resolution); - ui_->main->set_gallery_frame_rate_draft(ui_->gallery.stream_settings.FrameRateIndex()); - ui_->main->set_gallery_bandwidth_index(ui_->gallery.stream_settings.BandwidthIndex()); - ui_->main->set_gallery_bandwidth_draft(ui_->gallery.stream_settings.BandwidthIndex()); - ui_->main->set_gallery_settings_error(""); + main->set_file_save_path(file_transfer_save_path_buf_); + main->set_server_host(signal_server_ip_self_); + main->set_server_port(signal_server_port_self_); + main->set_self_host_settings_error(""); + main->set_gallery_resolution_index(ui_->gallery.stream_settings.resolution); + main->set_gallery_frame_rate_index(ui_->gallery.stream_settings.FrameRateIndex()); + main->set_gallery_resolution_draft(ui_->gallery.stream_settings.resolution); + main->set_gallery_frame_rate_draft(ui_->gallery.stream_settings.FrameRateIndex()); + main->set_gallery_bandwidth_index(ui_->gallery.stream_settings.BandwidthIndex()); + main->set_gallery_bandwidth_draft(ui_->gallery.stream_settings.BandwidthIndex()); + main->set_gallery_settings_error(""); } -void GuiApplication::BindMainCallbacks() { - ui_->main->on_gallery_pointer([this](slint::PointerEventButton button, - slint::PointerEventKind kind, float x, - float y) { +void GuiApplication::BindMainCallbacks(bool gallery_window) { + auto* main = ui_->Window(gallery_window).operator->(); + main->on_gallery_pointer([this](slint::PointerEventButton button, + slint::PointerEventKind kind, float x, + float y) { if (!ui_->gallery_control_id.empty()) SendPointerInput(static_cast(button), static_cast(kind), x, y); }); - ui_->main->on_gallery_scroll([this](float dx, float dy, float x, float y) { + main->on_gallery_scroll([this](float dx, float dy, float x, float y) { if (!ui_->gallery_control_id.empty()) SendScrollInput(dx, dy, x, y); }); - ui_->main->on_gallery_key([this](slint::SharedString text, bool pressed, - bool ctrl, bool alt, bool shift, bool meta) { + main->on_gallery_key([this](slint::SharedString text, bool pressed, + bool ctrl, bool alt, bool shift, bool meta) { if (!ui_->gallery_control_id.empty()) SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta); }); - ui_->main->on_change_mode([this](bool wall) { ChangeMainMode(wall); }); - ui_->main->on_toggle_gallery_fullscreen( + main->on_open_gallery([this] { ShowGalleryWindow(); }); + main->on_toggle_gallery_fullscreen( [this] { ToggleGalleryFullscreen(); }); - ui_->main->on_gallery_action( - [this](slint::SharedString action, slint::SharedString value) { - GalleryAction(std::string(action), std::string(value)); + main->on_gallery_action( + [this, main](slint::SharedString action, slint::SharedString value) { + GalleryAction(std::string(action), std::string(value), + main->get_gallery_mode()); }); - ui_->main->on_gallery_add_device( + main->on_gallery_swap([this, main](slint::SharedString source, + slint::SharedString target) { + if (!main->get_gallery_mode()) return true; + auto updated = ui_->gallery; + if (!updated.SwapPositions(std::string(source), std::string(target), + ui_->gallery_ids)) + return true; + if (!updated.Save(ui_->gallery_path)) return false; + ui_->gallery = std::move(updated); + ui_->gallery_synced = {}; + SyncGallery(); + return true; + }); + main->on_gallery_add_device( [this](slint::SharedString id, slint::SharedString password, slint::SharedString group, bool remember) { return AddGalleryDevice(std::string(id), std::string(password), std::string(group), remember); }); - ui_->main->on_gallery_import_recent([this](slint::SharedString group) { + main->on_gallery_import_recent([this](slint::SharedString group) { return ImportGalleryRecent(std::string(group)); }); - ui_->main->on_device_group_action( - [this](slint::SharedString action, slint::SharedString value) { - DeviceGroupAction(std::string(action), std::string(value)); + main->on_device_group_action( + [this, main](slint::SharedString action, slint::SharedString value) { + DeviceGroupAction(std::string(action), std::string(value), + main->get_gallery_mode()); }); - ui_->main->on_device_group_save([this](slint::SharedString group) { - return SaveDeviceGroups(std::string(group)); + main->on_device_group_save([this, main](slint::SharedString group) { + return SaveDeviceGroups(std::string(group), main->get_gallery_mode()); }); - ui_->main->on_recent_group_selected([this](int index) { + main->on_recent_group_selected([this](int index) { if (index == 1) ui_->recent_group_filter = ""; else if (index >= 2 && index - 2 < int(ui_->recent_filter_groups.size())) ui_->recent_group_filter = ui_->recent_filter_groups[index - 2]; else ui_->recent_group_filter.reset(); SyncMainWindow(); }); - ui_->main->on_delete_announcement([this](slint::SharedString id, + main->on_delete_announcement([this](slint::SharedString id, slint::SharedString revision) { const auto parse = [](const slint::SharedString& value) -> std::optional { @@ -1399,16 +1487,16 @@ void GuiApplication::BindMainCallbacks() { announcements_.Dismiss(*announcement_id, *announcement_revision); SyncAnnouncements(); }); - ui_->main->on_refresh_announcements([this] { announcements_.Refresh(); }); - ui_->main->on_open_announcement_link([this](slint::SharedString link) { + main->on_refresh_announcements([this] { announcements_.Refresh(); }); + main->on_open_announcement_link([this](slint::SharedString link) { const std::string url(link); if (announcement_text::IsWebUrl(url)) OpenUrl(url); }); - ui_->main->on_load_more_announcements([this] { + main->on_load_more_announcements([this] { announcements_.LoadMore(); SyncAnnouncements(); }); - ui_->main->on_open_announcement([this](slint::SharedString id, + main->on_open_announcement([this](slint::SharedString id, slint::SharedString revision) { const auto snapshot = announcements_.Get(); for (const auto& item : snapshot.items) { @@ -1423,30 +1511,32 @@ void GuiApplication::BindMainCallbacks() { break; } }); - auto& main = ui_->main; - main->window().on_close_requested([this] { + main->window().on_close_requested([this, main] { // Keep the application and all active sessions alive. The tray helper // ensures that supported platforms still provide a way to restore the // main window; Slint hides it regardless of tray availability. - MinimizeMainWindowToTray(); + if (main->get_gallery_mode()) HideGalleryWindow(); + else MinimizeMainWindowToTray(); return slint::CloseRequestResponse::HideWindow; }); - main->on_main_title_drag([this](int phase, float x, float y) { + main->on_main_title_drag([this, main](int phase, float x, float y) { if (ui_) { - DragWindow(ui_->main, phase, x, y, ui_->main_drag); + DragWindow(ui_->Window(main->get_gallery_mode()), phase, x, y, + main->get_gallery_mode() ? ui_->gallery_drag : ui_->main_drag); } }); - main->on_minimize_main_window([this] { + main->on_minimize_main_window([this, main] { if (ui_) { - ui_->main->window().set_minimized(true); + main->window().set_minimized(true); } }); - main->on_close_main_window([this] { + main->on_close_main_window([this, main] { if (!ui_) { return; } - MinimizeMainWindowToTray(); - ui_->main->hide(); + if (main->get_gallery_mode()) HideGalleryWindow(); + else MinimizeMainWindowToTray(); + main->hide(); }); main->on_copy_local_id([this] { if (!SDL_SetClipboardText(client_id_)) { @@ -1461,9 +1551,9 @@ void GuiApplication::BindMainCallbacks() { LOG_WARN("Signal reconnect could not be started"); } }); - main->on_toggle_password_visibility([this] { + main->on_toggle_password_visibility([this, main] { show_password_ = !show_password_; - ui_->main->set_password_visible(show_password_); + main->set_password_visible(show_password_); }); main->on_reset_password([this](slint::SharedString value) { return RequestPasswordChange(std::string(value)); @@ -1519,28 +1609,32 @@ void GuiApplication::BindMainCallbacks() { reload_recent_connections_ = true; } }); - main->on_browse_save_path([this] { + main->on_browse_save_path([this, main] { const char* folder = tinyfd_selectFolderDialog( localization::file_transfer_save_path[localization_language_index_] .c_str(), file_transfer_save_path_buf_[0] ? file_transfer_save_path_buf_ : nullptr); if (folder) { - ui_->main->set_file_save_path(folder); + main->set_file_save_path(folder); } }); - main->on_save_settings([this] { return SaveSettingsFromUi(); }); - main->on_cancel_settings([this] { ResetSettingsUi(); }); - main->on_save_self_hosted_settings([this] { - // Stage the endpoint until the surrounding settings dialog is confirmed. - return ValidateSelfHostedSettingsFromUi(); + main->on_save_settings([this, main] { + return SaveSettingsFromUi(main->get_gallery_mode()); }); - main->on_cancel_self_hosted_settings([this] { - ui_->main->set_self_host_settings_error(""); + main->on_cancel_settings([this, main] { + ResetSettingsUi(main->get_gallery_mode()); + }); + main->on_save_self_hosted_settings([this, main] { + // Stage the endpoint until the surrounding settings dialog is confirmed. + return ValidateSelfHostedSettingsFromUi(main->get_gallery_mode()); + }); + main->on_cancel_self_hosted_settings([this, main] { + main->set_self_host_settings_error(""); const std::string host = config_center_->GetSignalServerHost(); - ui_->main->set_server_host(UiText(host)); + main->set_server_host(UiText(host)); const int signal_port = config_center_->GetSignalServerPort(); - ui_->main->set_server_port(signal_port > 0 + main->set_server_port(signal_port > 0 ? UiText(std::to_string(signal_port)) : slint::SharedString{}); }); @@ -1603,21 +1697,22 @@ void GuiApplication::BindMainCallbacks() { RefreshMacPermissionStatus(true); #endif }); - main->on_install_portable_service([this] { + main->on_install_portable_service([this, main] { #if _WIN32 && CROSSDESK_PORTABLE portable_service_do_not_remind_ = false; - ui_->main->set_portable_service_do_not_remind(false); + main->set_portable_service_do_not_remind(false); + if (main->get_gallery_mode()) ui_->main->show(); StartPortableWindowsServiceInstall(); #endif }); - main->on_cancel_portable_service([this] { + main->on_cancel_portable_service([this, main] { #if _WIN32 && CROSSDESK_PORTABLE if (portable_service_install_state_.load(std::memory_order_acquire) == PortableServiceInstallState::installing) { return; } portable_service_do_not_remind_ = - ui_->main->get_portable_service_do_not_remind(); + main->get_portable_service_do_not_remind(); if (portable_service_do_not_remind_) { portable_service_prompt_suppressed_ = true; config_center_->SetPortableServicePromptSuppressed(true); @@ -2483,6 +2578,8 @@ void GuiApplication::UpdateLocalization() { } ui_localization::ApplyMainWindowStrings(ui_->main, language); ui_localization::ApplyStreamWindowStrings(ui_->main, language); + ui_localization::ApplyMainWindowStrings(ui_->wall, language); + ui_localization::ApplyStreamWindowStrings(ui_->wall, language); if (ui_->stream) { ui_localization::ApplyStreamWindowStrings(*ui_->stream, language); } @@ -2503,54 +2600,56 @@ void GuiApplication::SyncAnnouncements() { if (ui_->announcement_version == announcements_.Version()) return; const auto state = announcements_.Get(); ui_->announcement_version = state.version; - ui_->main->set_announcement_unread(state.unread); - ui_->main->set_announcement_total(state.total); - ui_->main->set_announcement_loading(state.loading); - ui_->main->set_announcement_failed(state.failed); - ui_->main->set_announcement_read_save_failed(state.read_save_failed); - ui_->main->set_announcement_delete_failed(state.delete_failed); - ui_->main->set_announcement_loaded(state.loaded); - std::vector rows; - bool selected_found = false; - for (const auto& item : state.items) { - ui::AnnouncementItem row; - const auto id = std::to_string(item["id"].get()); - const auto revision = std::to_string(item["revision"].get()); - row.id = UiText(id); - row.revision = UiText(revision); - row.title = UiText(item["title"].get()); - const auto timestamp = - static_cast(item["updated_at"].get()); - std::tm local{}; -#ifdef _WIN32 - localtime_s(&local, ×tamp); -#else - localtime_r(×tamp, &local); -#endif - char date[32]{}; - std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M", &local); - row.date = UiText(date); - row.read = item["read"].get(); - if (id == ui_->announcement_selected_id && - revision == ui_->announcement_selected_revision) { - selected_found = true; - ui_->main->set_announcement_selected_id(row.id); - ui_->main->set_announcement_selected_title(row.title); - ui_->main->set_announcement_selected_body( - announcement_text::Format(item["body"].get())); - ui_->main->set_announcement_selected_date(row.date); - ui_->main->set_announcement_selected_read(row.read); + for (const auto& main : {ui_->main, ui_->wall}) { + main->set_announcement_unread(state.unread); + main->set_announcement_total(state.total); + main->set_announcement_loading(state.loading); + main->set_announcement_failed(state.failed); + main->set_announcement_read_save_failed(state.read_save_failed); + main->set_announcement_delete_failed(state.delete_failed); + main->set_announcement_loaded(state.loaded); + std::vector rows; + bool selected_found = false; + for (const auto& item : state.items) { + ui::AnnouncementItem row; + const auto id = std::to_string(item["id"].get()); + const auto revision = std::to_string(item["revision"].get()); + row.id = UiText(id); + row.revision = UiText(revision); + row.title = UiText(item["title"].get()); + const auto timestamp = + static_cast(item["updated_at"].get()); + std::tm local{}; + #ifdef _WIN32 + localtime_s(&local, ×tamp); + #else + localtime_r(×tamp, &local); + #endif + char date[32]{}; + std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M", &local); + row.date = UiText(date); + row.read = item["read"].get(); + if (id == ui_->announcement_selected_id && + revision == ui_->announcement_selected_revision) { + selected_found = true; + main->set_announcement_selected_id(row.id); + main->set_announcement_selected_title(row.title); + main->set_announcement_selected_body( + announcement_text::Format(item["body"].get())); + main->set_announcement_selected_date(row.date); + main->set_announcement_selected_read(row.read); + } + rows.push_back(std::move(row)); } - rows.push_back(std::move(row)); + if (!selected_found) { + ui_->announcement_selected_id.clear(); + main->set_announcement_selected_id(""); + main->set_announcement_selected_body(slint::StyledText{}); + } + main->set_announcements( + std::make_shared>( + std::move(rows))); } - if (!selected_found) { - ui_->announcement_selected_id.clear(); - ui_->main->set_announcement_selected_id(""); - ui_->main->set_announcement_selected_body(slint::StyledText{}); - } - ui_->main->set_announcements( - std::make_shared>( - std::move(rows))); } void GuiApplication::SyncMainWindow() { @@ -2563,13 +2662,18 @@ void GuiApplication::SyncMainWindow() { #endif #if defined(__APPLE__) if (ui_->main_native_titlebar_attempts > 0) { - if (ConfigureMainWindowChrome(ui_->main->window().appkit_view(), - ui_->main->get_gallery_mode())) { + if (ConfigureMainWindowChrome(ui_->main->window().appkit_view())) { ui_->main_native_titlebar_attempts = 0; } else { --ui_->main_native_titlebar_attempts; } } + if (ui_->gallery_native_titlebar_attempts > 0) { + if (ConfigureMainWindowChrome(ui_->wall->window().appkit_view(), true)) + ui_->gallery_native_titlebar_attempts = 0; + else + --ui_->gallery_native_titlebar_attempts; + } #endif if (re_enter_remote_id_) { ui_->main->invoke_reset_remote_id(); @@ -2583,34 +2687,36 @@ void GuiApplication::SyncMainWindow() { ui_->main->set_password_change_pending(password_change_pending_); } const bool signal_online = signal_connected_.load(); - ui_->main->set_signal_connected(signal_online); - ui_->main->set_signal_reconnecting( - signal_connection_indicator_.Update(signal_online)); - ui_->main->set_signal_tls_error(signal_status_ == - SignalStatus::SignalTlsCertError); - ui_->main->set_update_available(update_available_); - switch (update_checker_->status()) { - case UpdateChecker::Status::Idle: - ui_->main->set_update_check_status(ui::UpdateCheckStatus::Idle); - break; - case UpdateChecker::Status::Checking: - ui_->main->set_update_check_status(ui::UpdateCheckStatus::Checking); - break; - case UpdateChecker::Status::UpToDate: - ui_->main->set_update_check_status(ui::UpdateCheckStatus::UpToDate); - break; - case UpdateChecker::Status::Available: - ui_->main->set_update_check_status(ui::UpdateCheckStatus::Available); - break; - case UpdateChecker::Status::Failed: - ui_->main->set_update_check_status(ui::UpdateCheckStatus::Failed); - break; + const bool signal_reconnecting = signal_connection_indicator_.Update(signal_online); + for (const auto& main : {ui_->main, ui_->wall}) { + main->set_signal_connected(signal_online); + main->set_signal_reconnecting(signal_reconnecting); + main->set_signal_tls_error(signal_status_ == + SignalStatus::SignalTlsCertError); + main->set_update_available(update_available_); + switch (update_checker_->status()) { + case UpdateChecker::Status::Idle: + main->set_update_check_status(ui::UpdateCheckStatus::Idle); + break; + case UpdateChecker::Status::Checking: + main->set_update_check_status(ui::UpdateCheckStatus::Checking); + break; + case UpdateChecker::Status::UpToDate: + main->set_update_check_status(ui::UpdateCheckStatus::UpToDate); + break; + case UpdateChecker::Status::Available: + main->set_update_check_status(ui::UpdateCheckStatus::Available); + break; + case UpdateChecker::Status::Failed: + main->set_update_check_status(ui::UpdateCheckStatus::Failed); + break; + } + main->set_current_version(CROSSDESK_VERSION); + main->set_latest_version(UiText(latest_version_)); + main->set_release_name(UiText(release_name_)); + main->set_release_date(UiText(release_date_)); + main->set_settings_session_active(HasActiveSession()); } - ui_->main->set_current_version(CROSSDESK_VERSION); - ui_->main->set_latest_version(UiText(latest_version_)); - ui_->main->set_release_name(UiText(release_name_)); - ui_->main->set_release_date(UiText(release_date_)); - ui_->main->set_settings_session_active(HasActiveSession()); if (show_offline_warning_window_) { ui_->main->set_offline_warning(UiText(offline_warning_text_)); show_offline_warning_window_ = false; @@ -2729,15 +2835,23 @@ void GuiApplication::SyncConnectionDialog() { if (!ui_ || !show_connection_status_window_) { if (ui_) { ui_->main->set_connection_dialog_open(false); + ui_->wall->set_connection_dialog_open(false); ui_->connection_password_initialized = false; } return; } + const bool gallery_dialog = !ui_->gallery_auth_id.empty(); + if (ui_->connection_dialog_gallery != gallery_dialog) + ui_->connection_password_initialized = false; + ui_->connection_dialog_gallery = gallery_dialog; + auto& dialog = ui_->Window(gallery_dialog); + ui_->Window(!gallery_dialog)->set_connection_dialog_open(false); auto props = FindRemoteSession( ui_->gallery_auth_id.empty() ? focused_remote_id_ : ui_->gallery_auth_id); if (!props) { ui_->main->set_connection_dialog_open(false); + ui_->wall->set_connection_dialog_open(false); return; } @@ -2750,6 +2864,7 @@ void GuiApplication::SyncConnectionDialog() { ui_->gallery_auth_id.clear(); show_connection_status_window_ = false; ui_->main->set_connection_dialog_open(false); + ui_->wall->set_connection_dialog_open(false); return; } const auto auth = props->connection_auth_.Get(); @@ -2781,15 +2896,15 @@ void GuiApplication::SyncConnectionDialog() { text += "\n" + std::string(retry_text); } - ui_->main->set_connection_dialog_open(true); - ui_->main->set_connection_status_text(UiText(text)); - ui_->main->set_connection_pending(pending); - ui_->main->set_connection_password_required(password_required); - ui_->main->set_connection_validating(auth.validating); + dialog->set_connection_dialog_open(true); + dialog->set_connection_status_text(UiText(text)); + dialog->set_connection_pending(pending); + dialog->set_connection_password_required(password_required); + dialog->set_connection_validating(auth.validating); if (password_required && !auth.validating && !ui_->connection_password_initialized) { - ui_->main->set_connection_password(props->remote_password_); - ui_->main->set_connection_remember_password(props->remember_password_); + dialog->set_connection_password(props->remote_password_); + dialog->set_connection_remember_password(props->remember_password_); ui_->connection_password_initialized = true; } else if (!password_required) { ui_->connection_password_initialized = false; @@ -2810,6 +2925,10 @@ void GuiApplication::SyncPlatformDialogs() { ui_->main->set_portable_service_suppressed_notice_open( show_portable_service_prompt_suppressed_window_); ui_->main->set_portable_service_installed(portable_service_installed_); + ui_->wall->set_portable_service_settings_visible(true); + ui_->wall->set_portable_service_installed(portable_service_installed_); + ui_->wall->set_portable_service_installing( + state == PortableServiceInstallState::installing); ui_->main->set_portable_service_installing( state == PortableServiceInstallState::installing); ui_->main->set_portable_service_succeeded( @@ -2883,6 +3002,7 @@ void GuiApplication::SyncXWaylandWindowActivation() { if (!ui_->gallery_control_id.empty()) ReleaseControl(); ui_->main->set_window_active(false); + ui_->wall->set_window_active(false); if (ui_->stream) { (*ui_->stream)->set_window_active(false); } @@ -2892,8 +3012,10 @@ void GuiApplication::SyncXWaylandWindowActivation() { ui_->main->set_window_active( X11WindowMatches(display, active_window, ui_->main->window())); + ui_->wall->set_window_active( + X11WindowMatches(display, active_window, ui_->wall->window())); if (!ui_->gallery_control_id.empty()) { - if (!X11WindowMatches(display, active_window, ui_->main->window())) + if (!X11WindowMatches(display, active_window, ui_->wall->window())) ReleaseControl(); return; } @@ -2927,9 +3049,9 @@ void GuiApplication::SetStreamKeyboardFocus(bool focused) { void GuiApplication::SyncStreamKeyboardFocus() { if (ui_ && !ui_->gallery_control_id.empty()) { #if defined(__APPLE__) - if (!IsMainWindowActive()) ReleaseControl(); + if (!IsGalleryWindowActive()) ReleaseControl(); #elif defined(_WIN32) - if (GetForegroundWindow() != ui_->main->window().win32_hwnd()) + if (GetForegroundWindow() != ui_->wall->window().win32_hwnd()) ReleaseControl(); #endif return; @@ -3701,8 +3823,8 @@ void GuiApplication::SyncServerWindow() { PositionServerWindows(); } -bool GuiApplication::ValidateSelfHostedSettingsFromUi() { - auto& main = ui_->main; +bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) { + auto& main = ui_->Window(gallery_window); const auto address = minirtc::ParseSignalServerAddress( std::string(main->get_server_host())); if (!address) { @@ -3722,13 +3844,13 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi() { return true; } -bool GuiApplication::SaveSettingsFromUi() { +bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { const auto previous_server = GetSignalServerSettings(*config_center_); - auto& main = ui_->main; + auto& main = ui_->Window(gallery_window); const bool has_endpoint = main->get_self_hosted_enabled() || !std::string(main->get_server_host()).empty() || !std::string(main->get_server_port()).empty(); - if (has_endpoint && !ValidateSelfHostedSettingsFromUi()) { + if (has_endpoint && !ValidateSelfHostedSettingsFromUi(gallery_window)) { main->set_settings_tab(0); main->set_self_host_settings_open(true); return false; @@ -3838,6 +3960,7 @@ bool GuiApplication::SaveSettingsFromUi() { } ApplySettingsFromConfig(previous_server != GetSignalServerSettings(*config_center_)); + ResetSettingsUi(gallery_window); return true; } @@ -3872,7 +3995,8 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) { #endif if (ui_) { ui_->localized_language = -1; - ResetSettingsUi(); + if (!ui_->main->get_settings_open()) ResetSettingsUi(); + if (!ui_->wall->get_settings_open()) ResetSettingsUi(true); } if (reconnect && !HasActiveSession()) { // Tick recreates the peer after asynchronous cleanup, using the committed @@ -4088,10 +4212,10 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { point = MapVideoPoint(x, y, ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_width() - : ui_->main->get_gallery_video_width(), + : ui_->wall->get_gallery_video_width(), ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_height() - : ui_->main->get_gallery_video_height(), + : ui_->wall->get_gallery_video_height(), props->video_width_, props->video_height_); } if (kind == 0) { @@ -4161,10 +4285,10 @@ void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x, point = MapVideoPoint(x, y, ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_width() - : ui_->main->get_gallery_video_width(), + : ui_->wall->get_gallery_video_width(), ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_height() - : ui_->main->get_gallery_video_height(), + : ui_->wall->get_gallery_video_height(), props->video_width_, props->video_height_); } if (!point) { @@ -4237,8 +4361,8 @@ bool GuiApplication::OpenUrl(const std::string& url) { } void GuiApplication::SyncAudioPlayback() { - const bool wall_visible = ui_ && ui_->main->get_gallery_mode() && - ui_->main->window().is_visible() && !ui_->main->window().is_minimized(); + const bool wall_visible = ui_ && ui_->wall->window().is_visible() && + !ui_->wall->window().is_minimized(); const bool stream_active = ui_ && ui_->stream && focus_on_stream_window_ && (*ui_->stream)->window().is_visible() && !(*ui_->stream)->window().is_minimized(); @@ -4277,7 +4401,7 @@ void GuiApplication::ReleaseControl() { } if (!ui_->gallery_control_id.empty()) { ui_->gallery_control_id.clear(); - ui_->main->set_gallery_controlling(false); + ui_->wall->set_gallery_controlling(false); } ui_->pressed_mouse_buttons.clear(); SyncAudioPlayback(); @@ -4294,84 +4418,108 @@ void GuiApplication::ReleaseControl() { start_keyboard_capturer_ = false; } -void GuiApplication::ChangeMainMode(bool wall) { - if (!ui_ || ui_->main->get_gallery_mode() == wall) return; - ui_->gallery_transition.Cancel(); - ui_->main->set_gallery_transitioning(false); - ui_->main->set_gallery_content_visible(true); - ReleaseControl(); - if (wall) { +void GuiApplication::ShowGalleryWindow() { + if (!ui_) return; + auto& window = ui_->wall->window(); + if (!window.is_visible()) { + ReleaseControl(); std::vector> sessions; { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [_, props] : remote_sessions_) if (props && !props->closing_) sessions.push_back(props); } - // Apply the wall's default to existing connections as well as new ones. - // Do this once on entry, so an explicit unmute can still take effect. for (const auto& props : sessions) MuteRemoteAudio(props); + if (!ui_->gallery_shown) { + ui_->gallery_window.BeginRestore(); + RestoreWindowGeometry(true, true); + ui_->gallery_shown = true; + } + need_to_send_recent_connections_ = true; + ui_->wall->show(); } - auto& window = ui_->main->window(); + window.set_minimized(false); #if defined(__APPLE__) - const bool fullscreen = IsMainWindowFullscreen(); - const bool maximized = IsMainWindowMaximized(); + ConfigureMainWindowChrome(window.appkit_view(), true); + MacActivateWindow(window.appkit_view()); + ui_->gallery_native_titlebar_attempts = 30; +#elif defined(_WIN32) + ConfigureWindowsWindowIcons(window.win32_hwnd()); + SetForegroundWindow(window.win32_hwnd()); #else - const bool fullscreen = window.is_fullscreen(); - const bool maximized = window.is_maximized(); + if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr); + X11ActivateGalleryWindow(ui_->x11_focus_display); #endif - ui_->gallery_window.BeginModeChange(wall, window.position(), window.size(), - window.scale_factor(), fullscreen, maximized, - ui_->main->get_wayland_titlebar() ? 518.0f - : ui_->main->get_custom_titlebar() ? 517.0f : 486.0f); - LOG_INFO("DeskHub mode requested: {}; client={}x{} scale={}", - wall ? "wall" : "connection", window.size().width, - window.size().height, window.scale_factor()); - if (!wall) { -#if defined(__APPLE__) - SetMainWindowFullscreen(false); -#else - window.set_fullscreen(false); - window.set_maximized(false); -#endif - } - ui_->gallery.selected.clear(); - ui_->gallery_auth_id.clear(); - ui_->gallery_requested_auth.clear(); - show_connection_status_window_ = false; - ui_->main->invoke_reset_gallery_transient(); - ui_->main->set_gallery_fullscreen(false); - ui_->main->set_gallery_mode(wall); - if (wall) need_to_send_recent_connections_ = true; - // Geometry is recorded again only after the native window acknowledges it. - ui_->main_restore_ticks = 0; - ui_->main_native_titlebar_attempts = 30; ui_->gallery_synced = {}; - show_password_ = false; + SyncGallery(); +} + +void GuiApplication::HideGalleryWindow() { + if (!ui_) return; + ReleaseControl(); + ui_->wall->invoke_reset_gallery_transient(); + ui_->wall->set_alias_open(false); + ui_->wall->set_delete_open(false); + ui_->wall->set_device_group_open(false); + ui_->wall->set_self_host_settings_open(false); + ui_->wall->set_settings_open(false); + ui_->wall->set_about_open(false); + ui_->wall->set_messages_open(false); + ResetSettingsUi(true); + ui_->gallery_requested_auth.clear(); + if (!ui_->gallery_auth_id.empty()) { + ui_->gallery_auth_id.clear(); + show_connection_status_window_ = false; + ui_->wall->set_connection_dialog_open(false); + } + SyncWindowGeometry(true); + ui_->gallery_transition.Cancel(); + ui_->wall->set_gallery_transitioning(false); + ui_->wall->set_gallery_content_visible(true); + ui_->window_saved = {}; + SaveGalleryWindowState(); +#if defined(__APPLE__) + // Restore application presentation options while the wall is hidden. Its + // remembered fullscreen preference is applied again on the next open. + if (IsGalleryWindowFullscreen()) { + SetGalleryWindowFullscreen(false); + ui_->gallery_window.BeginRestore(); + } +#endif + ui_->wall->hide(); + ui_->gallery_synced = {}; + SyncGallery(); } void GuiApplication::ToggleGalleryFullscreen() { - if (!ui_ || !ui_->main->get_gallery_mode() || + if (!ui_ || !ui_->wall->window().is_visible() || ui_->gallery_transition.Active()) return; #if defined(__APPLE__) - const bool fullscreen = IsMainWindowFullscreen(); + const bool fullscreen = IsGalleryWindowFullscreen(); #else - const bool fullscreen = ui_->main->window().is_fullscreen(); + const bool fullscreen = ui_->wall->window().is_fullscreen(); #endif const bool target = !(ui_->gallery_window.Restoring() ? ui_->gallery_window.wall_fullscreen : fullscreen); ui_->gallery_transition.Begin(target, std::chrono::steady_clock::now()); ReleaseControl(); - ui_->main->set_gallery_transitioning(true); - ui_->main->set_gallery_content_visible(false); + ui_->wall->set_gallery_transitioning(true); + ui_->wall->set_gallery_content_visible(false); ui_->gallery_synced = {}; } void GuiApplication::SaveGalleryWindowState() { - if (ui_->gallery_window.Restoring() || ui_->gallery_transition.Active()) return; + if (ui_->wall->get_gallery_resizing() || ui_->connection_window.Restoring() || + (ui_->wall->window().is_visible() && ui_->gallery_window.Restoring()) || + ui_->gallery_transition.Active()) return; const auto now = std::chrono::steady_clock::now(); if (now - ui_->window_saved > 1s || exit_) { - const float scale = std::max(ui_->main->window().scale_factor(), 1.0f); + const float scale = ui_->loaded_window_scale[1] > 0 + ? ui_->loaded_window_scale[1] + : std::max(ui_->wall->window().scale_factor(), 1.0f); + const float connection_scale = std::max(ui_->main->window().scale_factor(), 1.0f); nlohmann::json state{{"version", 2}, + {"tile_width", ui_->wall->get_gallery_tile_width()}, {"fullscreen", ui_->gallery_window.wall_fullscreen}, {"maximized", ui_->gallery_window.wall_maximized}}; if (ui_->gallery_window.wall_size) { @@ -4382,11 +4530,11 @@ void GuiApplication::SaveGalleryWindowState() { state["wall_x"] = ui_->gallery_window.wall_position->x / scale; state["wall_y"] = ui_->gallery_window.wall_position->y / scale; } - if (ui_->gallery_window.connection_position) { + if (ui_->connection_window.connection_position) { state["connection_x"] = - ui_->gallery_window.connection_position->x / scale; + ui_->connection_window.connection_position->x / connection_scale; state["connection_y"] = - ui_->gallery_window.connection_position->y / scale; + ui_->connection_window.connection_position->y / connection_scale; } const auto text = state.dump(); if (text != ui_->saved_window_signature && @@ -4401,7 +4549,7 @@ bool GuiApplication::AddGalleryDevice(const std::string& input, const std::string& group, bool remember_password) { const auto error = [this](const char* zh, const char* en, const char* ru) { - ui_->main->set_gallery_notice(UiText( + ui_->wall->set_gallery_notice(UiText( localization_language_index_ == 0 ? zh : localization_language_index_ == 2 ? ru : en)); return false; @@ -4453,12 +4601,12 @@ bool GuiApplication::AddGalleryDevice(const std::string& input, ui_->batch_operations.push_back(std::move(operation)); ui_->gallery_requested_auth = id; ui_->gallery_focus.clear(); - ui_->main->set_gallery_group_index(0); + ui_->wall->set_gallery_group_index(0); const auto ids = ui_->gallery.Ids(); ui_->gallery.page = (std::find(ids.begin(), ids.end(), id) - ids.begin()) / ui_->gallery.capacity; ui_->gallery_synced = {}; - ui_->main->set_gallery_notice(""); + ui_->wall->set_gallery_notice(""); return true; } @@ -4473,7 +4621,7 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) { auto updated = ui_->gallery; updated.AddMany(ids, group); if (!updated.Save(ui_->gallery_path)) { - ui_->main->set_gallery_notice(UiText( + ui_->wall->set_gallery_notice(UiText( localization_language_index_ == 0 ? "保存失败,请重试" : localization_language_index_ == 2 ? "Не удалось сохранить" : "Could not save; try again")); @@ -4491,27 +4639,30 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) { } ui_->gallery_focus.clear(); ui_->gallery.selected.clear(); - ui_->main->set_gallery_batch(false); + ui_->wall->set_gallery_batch(false); const int group_index = group.empty() ? 0 : int(std::find(ui_->gallery.groups.begin(), ui_->gallery.groups.end(), group) - ui_->gallery.groups.begin()) + 1; - ui_->main->set_gallery_group_index(group_index); + ui_->wall->set_gallery_group_index(group_index); const auto visible = ui_->gallery.Ids(group); ui_->gallery.page = (std::find(visible.begin(), visible.end(), ids.front()) - visible.begin()) / ui_->gallery.capacity; ui_->gallery_synced = {}; - ui_->main->set_gallery_notice(""); + ui_->wall->set_gallery_notice(""); return true; } void GuiApplication::DeviceGroupAction(const std::string& action, - const std::string& value) { - ui_->main->set_device_group_error(""); + const std::string& value, + bool gallery_window) { + auto& editor = ui_->group_editors[gallery_window]; + auto& main = ui_->Window(gallery_window); + main->set_device_group_error(""); if (action == "close") { - ui_->group_edit_id.clear(); - ui_->group_options->set_vector({}); + editor.id.clear(); + editor.options->set_vector({}); } else if (action == "open") { - ui_->group_edit_id = value; + editor.id = value; std::vector options; options.push_back({"", true}); for (const auto& name : ui_->gallery.groups) { @@ -4521,35 +4672,38 @@ void GuiApplication::DeviceGroupAction(const std::string& action, options.push_back({UiText(name), selected}); if (selected) options.front().selected = false; } - ui_->group_options->set_vector(options); - ui_->main->set_device_group_options(ui_->group_options); - } else if (action == "toggle" && !ui_->group_edit_id.empty()) { + editor.options->set_vector(options); + main->set_device_group_options(editor.options); + } else if (action == "toggle" && !editor.id.empty()) { bool grouped = false; - for (size_t i = 1; i < ui_->group_options->row_count(); ++i) { - auto option = *ui_->group_options->row_data(i); + for (size_t i = 1; i < editor.options->row_count(); ++i) { + auto option = *editor.options->row_data(i); if (value.empty()) option.selected = false; else if (std::string(option.name) == value) option.selected = !option.selected; grouped |= option.selected; - ui_->group_options->set_row_data(i, option); + editor.options->set_row_data(i, option); } - ui_->group_options->set_row_data(0, {"", !grouped}); - if (value.empty()) ui_->main->set_device_group_new_name(""); + editor.options->set_row_data(0, {"", !grouped}); + if (value.empty()) main->set_device_group_new_name(""); } } -bool GuiApplication::SaveDeviceGroups(const std::string& new_group) { - const auto& id = ui_->group_edit_id; +bool GuiApplication::SaveDeviceGroups(const std::string& new_group, + bool gallery_window) { + auto& editor = ui_->group_editors[gallery_window]; + auto& main = ui_->Window(gallery_window); + const auto& id = editor.id; if (!gallery::Workspace::ValidId(id)) return false; std::vector names; - for (size_t i = 0; i < ui_->group_options->row_count(); ++i) { - const auto option = *ui_->group_options->row_data(i); + for (size_t i = 0; i < editor.options->row_count(); ++i) { + const auto option = *editor.options->row_data(i); if (option.selected && !option.name.empty()) names.emplace_back(option.name); } if (const auto name = Trim(new_group); !name.empty()) names.push_back(name); auto updated = ui_->gallery; if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) { - ui_->main->set_device_group_error(UiText( + main->set_device_group_error(UiText( localization_language_index_ == 0 ? "保存失败,请重试" : localization_language_index_ == 2 ? "Не удалось сохранить" : "Could not save; try again")); @@ -4557,31 +4711,32 @@ bool GuiApplication::SaveDeviceGroups(const std::string& new_group) { } // Removing an empty group shifts indices. Retain the filter by name instead // of accidentally switching to a different group at the old index. - const int index = ui_->main->get_gallery_group_index(); + const int index = ui_->wall->get_gallery_group_index(); const auto current = index > 0 && index <= int(ui_->gallery.groups.size()) ? ui_->gallery.groups[index - 1] : std::string{}; ui_->gallery = std::move(updated); const auto found = std::find(ui_->gallery.groups.begin(), ui_->gallery.groups.end(), current); - ui_->main->set_gallery_group_index( + ui_->wall->set_gallery_group_index( found == ui_->gallery.groups.end() ? 0 : int(found - ui_->gallery.groups.begin()) + 1); // Group edits never enroll or unhide a device, or take ownership of an // existing session. Only the explicit wall-add actions do that. ui_->gallery_synced = {}; - ui_->main->set_device_group_error(""); + main->set_device_group_error(""); SyncGallery(); return true; } void GuiApplication::GalleryAction(const std::string& action, - const std::string& value) { + const std::string& value, + bool gallery_window) { using OperationState = SlintUi::BatchOperation::State; auto& wall = ui_->gallery; ui_->gallery_synced = {}; if (action == "settings-open") { ReleaseControl(); - ui_->main->set_gallery_settings_error(""); + ResetSettingsUi(gallery_window); return; } if (action == "network-open") { @@ -4597,9 +4752,9 @@ void GuiApplication::GalleryAction(const std::string& action, if (action == "recent-open") { ReleaseControl(); need_to_send_recent_connections_ = true; - ui_->main->set_gallery_notice(""); - const int group_index = ui_->main->get_gallery_group_index(); - ui_->main->set_gallery_import_group(UiText( + ui_->wall->set_gallery_notice(""); + const int group_index = ui_->wall->get_gallery_group_index(); + ui_->wall->set_gallery_import_group(UiText( group_index > 0 && group_index <= int(wall.groups.size()) ? wall.groups[group_index - 1] : "")); std::vector devices; @@ -4615,18 +4770,18 @@ void GuiApplication::GalleryAction(const std::string& action, devices.push_back(std::move(device)); } ui_->gallery_recent_model->set_vector(std::move(devices)); - ui_->main->set_gallery_recent_devices(ui_->gallery_recent_model); - ui_->main->set_gallery_recent_selected(0); + ui_->wall->set_gallery_recent_devices(ui_->gallery_recent_model); + ui_->wall->set_gallery_recent_selected(0); return; } if (action == "recent-close") { ui_->gallery_recent_model->set_vector({}); - ui_->main->set_gallery_recent_selected(0); - ui_->main->set_gallery_notice(""); + ui_->wall->set_gallery_recent_selected(0); + ui_->wall->set_gallery_notice(""); return; } if (action == "recent-select" || action == "recent-all") { - const bool select_all = ui_->main->get_gallery_recent_selected() < + const bool select_all = ui_->wall->get_gallery_recent_selected() < int(ui_->gallery_recent_model->row_count()); int count = 0; for (size_t i = 0; i < ui_->gallery_recent_model->row_count(); ++i) { @@ -4637,11 +4792,11 @@ void GuiApplication::GalleryAction(const std::string& action, } if (device.selected) ++count; } - ui_->main->set_gallery_recent_selected(count); + ui_->wall->set_gallery_recent_selected(count); return; } if (action == "add-open") { - ui_->main->set_gallery_notice(""); + ui_->wall->set_gallery_notice(""); return; } else if (action == "group") { ReleaseControl(); @@ -4680,14 +4835,14 @@ void GuiApplication::GalleryAction(const std::string& action, props->enable_mouse_control_ = true; props->clipboard_allowed_ = true; if (ui_->stream) (*ui_->stream)->hide(); - // The main-window FocusScope owns keyboard input. Native capture stays off - // so local toolbar/menu events can never leak into this remote session. + // The gallery window FocusScope owns keyboard input. Native capture stays + // off so local toolbar/menu events never leak into this remote session. start_keyboard_capturer_ = false; - ui_->main->set_gallery_controlling(true); + ui_->wall->set_gallery_controlling(true); } else if (action == "remove") { ReleaseControl(); auto updated = wall; - const int group_index = ui_->main->get_gallery_group_index(); + const int group_index = ui_->wall->get_gallery_group_index(); const std::string group = group_index > 0 && group_index <= int(wall.groups.size()) ? wall.groups[group_index - 1] @@ -4697,7 +4852,7 @@ void GuiApplication::GalleryAction(const std::string& action, wall = std::move(updated); const auto retained = std::find(wall.groups.begin(), wall.groups.end(), group); - ui_->main->set_gallery_group_index( + ui_->wall->set_gallery_group_index( retained == wall.groups.end() ? 0 : int(retained - wall.groups.begin()) + 1); // Only removing from All devices hides an active session. Removing a @@ -4775,30 +4930,32 @@ void GuiApplication::GalleryAction(const std::string& action, SyncGallery(); } -void GuiApplication::RestoreMainWindowGeometry(bool before_show) { - if (!ui_ || !ui_->gallery_window.RestoringNormal()) return; - auto& window = ui_->main->window(); - auto& placement = ui_->gallery_window; +void GuiApplication::RestoreWindowGeometry(bool before_show, + bool gallery_window) { + if (!ui_) return; + auto& placement = gallery_window ? ui_->gallery_window : ui_->connection_window; + if (!placement.RestoringNormal()) return; + auto& component = ui_->Window(gallery_window); + auto& window = component->window(); const auto current_size = window.size(); - const bool wall_mode = ui_->main->get_gallery_mode(); - const float compact_height = ui_->main->get_wayland_titlebar() ? 518.0f - : ui_->main->get_custom_titlebar() ? 517.0f : 486.0f; - if (!before_show && ui_->loaded_window_scale > 0) { - const float ratio = window.scale_factor() / ui_->loaded_window_scale; - if (ui_->gallery_window.wall_position) { - ui_->gallery_window.wall_position->x *= ratio; - ui_->gallery_window.wall_position->y *= ratio; + const bool wall_mode = gallery_window; + 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]; + if (placement.wall_position) { + placement.wall_position->x *= ratio; + placement.wall_position->y *= ratio; } - if (ui_->gallery_window.connection_position) { - ui_->gallery_window.connection_position->x *= ratio; - ui_->gallery_window.connection_position->y *= ratio; + if (placement.connection_position) { + placement.connection_position->x *= ratio; + placement.connection_position->y *= ratio; } - ui_->loaded_window_scale = 0; + ui_->loaded_window_scale[gallery_window] = 0; } auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); - const auto& position = ui_->main->get_gallery_mode() - ? ui_->gallery_window.wall_position - : ui_->gallery_window.connection_position; + const auto& position = gallery_window + ? placement.wall_position + : placement.connection_position; auto restore_position = position; if (position) { auto safe_position = *position; @@ -4862,18 +5019,18 @@ void GuiApplication::RestoreMainWindowGeometry(bool before_show) { if (!normal_restored) window.set_size(target_size); if (restore_position) window.set_position(*restore_position); if (normal_restored && wall_mode) { - if (ui_->gallery_window.wall_maximized && - !ui_->gallery_window.wall_fullscreen) { + if (placement.wall_maximized && + !placement.wall_fullscreen) { #if defined(__APPLE__) - MaximizeMainWindowWithoutAnimation(); + MaximizeGalleryWindowWithoutAnimation(); #else window.set_maximized(true); #endif } - if (ui_->gallery_window.wall_fullscreen) { + if (placement.wall_fullscreen) { #if defined(__APPLE__) ConfigureMainWindowChrome(window.appkit_view(), true); - SetMainWindowFullscreen(true); + SetGalleryWindowFullscreen(true); #else window.set_fullscreen(true); #endif @@ -4881,66 +5038,81 @@ void GuiApplication::RestoreMainWindowGeometry(bool before_show) { } } -void GuiApplication::SyncGallery() { - using OperationState = SlintUi::BatchOperation::State; - if (!ui_) return; - SyncAudioPlayback(); - auto& window = ui_->main->window(); +void GuiApplication::SyncWindowGeometry(bool gallery_window) { + auto& component = ui_->Window(gallery_window); + auto& window = component->window(); + if (!window.is_visible() || window.is_minimized()) return; const auto current_size = window.size(); - if (ui_->observed_main_size && - (current_size.width != ui_->observed_main_size->width || - current_size.height != ui_->observed_main_size->height)) { - if (!ui_->gallery_control_id.empty()) ReleaseControl(); - // Native resize can redraw before the usual 200ms preview refresh. - ui_->gallery_synced = {}; - } - ui_->observed_main_size = current_size; - const bool visible = ui_->main->get_gallery_mode() && window.is_visible() && - !window.is_minimized(); - auto& placement = ui_->gallery_window; - const bool wall_mode = ui_->main->get_gallery_mode(); - auto& transition = ui_->gallery_transition; - if (!visible) transition.Cancel(); - const float compact_height = ui_->main->get_wayland_titlebar() ? 518.0f - : ui_->main->get_custom_titlebar() ? 517.0f : 486.0f; + 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_height = component->get_minimum_window_height(); #if defined(__APPLE__) - const bool fullscreen = IsMainWindowFullscreen(); - const bool maximized = IsMainWindowMaximized(); + const bool fullscreen = gallery_window && IsGalleryWindowFullscreen(); + const bool maximized = gallery_window && IsGalleryWindowMaximized(); #else const bool fullscreen = window.is_fullscreen(); const bool maximized = window.is_maximized(); #endif - if (!transition.Active() && placement.RecoverUndersized(wall_mode, window.position(), current_size, + if (!transitioning && placement.RecoverUndersized(wall_mode, window.position(), current_size, window.scale_factor(), window.is_visible(), window.is_minimized(), fullscreen, maximized, compact_height)) { - ui_->main_restore_ticks = 0; + ui_->restore_ticks[gallery_window] = 0; const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); 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_->main_restore_ticks == 120) { + if (placement.Restoring() && ++ui_->restore_ticks[gallery_window] == 120) { const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); 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); } if (placement.RestoringNormal()) { - RestoreMainWindowGeometry(); + RestoreWindowGeometry(false, gallery_window); } else if (placement.Restoring()) { if (placement.FinishRestore(wall_mode, current_size, fullscreen, maximized)) LOG_INFO("DeskHub window restored: {}; client={}x{} scale={} fullscreen={} maximized={}", wall_mode ? "wall" : "connection", current_size.width, current_size.height, window.scale_factor(), fullscreen, maximized); - } else if (window.is_visible() && !window.is_minimized() && !transition.Active()) { + } else if (window.is_visible() && !window.is_minimized() && !transitioning) { placement.Observe(wall_mode, window.position(), current_size, window.scale_factor(), fullscreen, maximized, compact_height); } +} + +void GuiApplication::SyncGallery() { + using OperationState = SlintUi::BatchOperation::State; + if (!ui_) return; + SyncAudioPlayback(); + SyncWindowGeometry(false); + SyncWindowGeometry(true); + auto& window = ui_->wall->window(); + const auto current_size = window.size(); + if (ui_->observed_gallery_size && + (current_size.width != ui_->observed_gallery_size->width || + current_size.height != ui_->observed_gallery_size->height)) { + if (!ui_->gallery_control_id.empty()) ReleaseControl(); + // Native resize can redraw before the usual 200ms preview refresh. + ui_->gallery_synced = {}; + } + ui_->observed_gallery_size = current_size; + const bool wall_mode = window.is_visible(); + const bool visible = wall_mode && !window.is_minimized(); + auto& placement = ui_->gallery_window; + auto& transition = ui_->gallery_transition; + if (!visible) transition.Cancel(); +#if defined(__APPLE__) + const bool fullscreen = IsGalleryWindowFullscreen(); +#else + const bool fullscreen = window.is_fullscreen(); +#endif if (wall_mode && !placement.Restoring()) { - if (fullscreen != ui_->main->get_gallery_fullscreen()) ReleaseControl(); - ui_->main->set_gallery_fullscreen(fullscreen); + if (fullscreen != ui_->wall->get_gallery_fullscreen()) ReleaseControl(); + ui_->wall->set_gallery_fullscreen(fullscreen); } if (!ui_->gallery_control_id.empty()) { auto controlled = FindRemoteSession(ui_->gallery_control_id); @@ -4954,10 +5126,10 @@ void GuiApplication::SyncGallery() { if (action == gallery::FullscreenTransition::Action::Apply) { // Update custom chrome before native resizing; keep content hidden until // both the compositor geometry and the gallery model have settled. - ui_->main->set_gallery_fullscreen(transition.Target()); + ui_->wall->set_gallery_fullscreen(transition.Target()); #if defined(__APPLE__) - if (!SetMainWindowFullscreen(transition.Target())) { - ui_->main->set_gallery_fullscreen(fullscreen); + if (!SetGalleryWindowFullscreen(transition.Target())) { + ui_->wall->set_gallery_fullscreen(fullscreen); transition.Reveal(now); } else #else @@ -4968,8 +5140,8 @@ void GuiApplication::SyncGallery() { } ui_->gallery_synced = {}; } - ui_->main->set_gallery_transitioning(transition.Active()); - ui_->main->set_gallery_content_visible(transition.ContentVisible()); + ui_->wall->set_gallery_transitioning(transition.Active()); + ui_->wall->set_gallery_content_visible(transition.ContentVisible()); if (!transition.Active() && now - ui_->gallery_synced < (visible ? 16ms : 200ms)) return; @@ -4981,7 +5153,7 @@ void GuiApplication::SyncGallery() { gallery_presence_ids_ = presence_ids; need_to_send_recent_connections_ = true; } - if (ui_->main->get_gallery_importing()) { + if (ui_->wall->get_gallery_importing()) { // Keep the picker selection and ordering stable when presence changes. for (size_t i = 0; i < ui_->gallery_recent_model->row_count(); ++i) { auto device = *ui_->gallery_recent_model->row_data(i); @@ -5010,7 +5182,7 @@ void GuiApplication::SyncGallery() { } return settings_.RecentConnectionDisplayName(device); }; - if (visible && ui_->main->get_gallery_network_open() && + if (visible && ui_->wall->get_gallery_network_open() && now - ui_->gallery_network_synced >= 1s) { ui_->gallery_network_synced = now; // This list follows live connections, independently of the wall's current @@ -5200,15 +5372,15 @@ void GuiApplication::SyncGallery() { std::vector groups{ UiText(tr("全部设备", "All devices", "Все устройства"))}; for (const auto& group : wall.groups) groups.push_back(UiText(group)); - const auto old_groups = ui_->main->get_gallery_groups(); + const auto old_groups = ui_->wall->get_gallery_groups(); bool changed_groups = !old_groups || old_groups->row_count() != groups.size(); if (!changed_groups) for (size_t i = 0; i < groups.size(); ++i) changed_groups |= old_groups->row_data(i) != groups[i]; if (changed_groups) - ui_->main->set_gallery_groups( + ui_->wall->set_gallery_groups( std::make_shared>(groups)); - const int group_index = std::clamp(ui_->main->get_gallery_group_index(), 0, + const int group_index = std::clamp(ui_->wall->get_gallery_group_index(), 0, int(wall.groups.size())); auto ids = wall.Ids(group_index == 0 ? "" : wall.groups[group_index - 1]); if (group_index == 0) { @@ -5221,14 +5393,13 @@ void GuiApplication::SyncGallery() { std::sort(active.begin(), active.end()); ids.insert(ids.end(), active.begin(), active.end()); } + ids = wall.OrderIds(ids); ui_->gallery_ids = ids; std::erase_if(wall.selected, [&](const auto& id) { return std::find(ids.begin(), ids.end(), id) == ids.end(); }); - const int columns = - std::max(1, int((ui_->main->get_gallery_width() - 46) / 196)); - const int rows = - std::max(1, int((ui_->main->get_gallery_height() - 114) / 144)); + const int columns = std::max(1, ui_->wall->get_gallery_columns()); + const int rows = std::max(1, ui_->wall->get_gallery_rows()); wall.ResizePage(columns * rows, ids.size()); // Resizing/fullscreen changes page capacity. Keep the focused device in // the page model so the rail shows the other devices on the same page. @@ -5241,17 +5412,15 @@ void GuiApplication::SyncGallery() { ui_->gallery_focus.clear(); } } - ui_->main->set_gallery_columns(columns); - ui_->main->set_gallery_rows(rows); - ui_->main->set_gallery_page(wall.page); - ui_->main->set_gallery_pages( + ui_->wall->set_gallery_page(wall.page); + ui_->wall->set_gallery_pages( std::max(1, (int(ids.size()) + wall.capacity - 1) / wall.capacity)); - ui_->main->set_gallery_count(ids.size()); + ui_->wall->set_gallery_count(ids.size()); int connected = 0, selected_on_page = 0; const auto gallery_viewport = visible && !ui_->gallery_focus.empty() ? gallery::PreviewResolution::Viewport( - ui_->main->get_gallery_video_width(), - ui_->main->get_gallery_video_height(), window.scale_factor()) + ui_->wall->get_gallery_video_width(), + ui_->wall->get_gallery_video_height(), window.scale_factor()) : gallery::PreviewResolution{}; std::set preview_ids; std::vector tiles; @@ -5329,14 +5498,14 @@ void GuiApplication::SyncGallery() { else for (size_t i = 0; i < tiles.size(); ++i) ui_->gallery_model->set_row_data(i, tiles[i]); - ui_->main->set_gallery_focused(ui_->gallery_focus.empty() + ui_->wall->set_gallery_focused(ui_->gallery_focus.empty() ? ui::GalleryTile{} : make_tile(ui_->gallery_focus, true)); - ui_->main->set_gallery_connected(connected); - if (!ui_->main->get_gallery_adding() && !ui_->main->get_gallery_importing()) - ui_->main->set_gallery_notice(""); - ui_->main->set_gallery_selected(wall.selected.size()); - ui_->main->set_gallery_hidden_selected(wall.selected.size() - + ui_->wall->set_gallery_connected(connected); + if (!ui_->wall->get_gallery_adding() && !ui_->wall->get_gallery_importing()) + ui_->wall->set_gallery_notice(""); + ui_->wall->set_gallery_selected(wall.selected.size()); + ui_->wall->set_gallery_hidden_selected(wall.selected.size() - selected_on_page); std::vector selected_devices; bool selected_audio_available = false, selected_audio_muted = true; @@ -5354,8 +5523,8 @@ void GuiApplication::SyncGallery() { } selected_devices.push_back(std::move(device)); } - ui_->main->set_gallery_selected_audio_available(selected_audio_available); - ui_->main->set_gallery_selected_audio_muted(selected_audio_available && + ui_->wall->set_gallery_selected_audio_available(selected_audio_available); + ui_->wall->set_gallery_selected_audio_muted(selected_audio_available && selected_audio_muted); if (ui_->gallery_selected_model->row_count() != selected_devices.size()) ui_->gallery_selected_model->set_vector(std::move(selected_devices)); @@ -5489,8 +5658,9 @@ void GuiApplication::Cleanup() { } SaveGalleryWindowState(); #if defined(__APPLE__) - SetMainWindowFullscreen(false); + SetGalleryWindowFullscreen(false); #endif + ui_->wall->hide(); ui_->timer.stop(); ui_->server_file_dialog_timer.stop(); ui_->video_timer.stop(); diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index f6bc9e40..5159ba60 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -50,7 +50,7 @@ private: void InitializeUi(); void InitializeSystemTray(); bool MinimizeMainWindowToTray(); - void BindMainCallbacks(); + void BindMainCallbacks(bool gallery_window = false); void BindStreamCallbacks(); void BindServerCallbacks(); void Tick(); @@ -60,16 +60,21 @@ private: void HandlePasswordChangeResult(); void HandleCredentialRecovery(); void SyncMainWindow(); - void ChangeMainMode(bool gallery); + void ShowGalleryWindow(); + void HideGalleryWindow(); + void SyncWindowGeometry(bool gallery_window); void ToggleGalleryFullscreen(); - void RestoreMainWindowGeometry(bool before_show = false); + void RestoreWindowGeometry(bool before_show = false, + bool gallery_window = false); void SyncGallery(); - void GalleryAction(const std::string& action, const std::string& value); + void GalleryAction(const std::string& action, const std::string& value, + bool gallery_window = true); bool AddGalleryDevice(const std::string& id, const std::string& password, const std::string& group, bool remember_password); bool ImportGalleryRecent(const std::string& group); - void DeviceGroupAction(const std::string& action, const std::string& value); - bool SaveDeviceGroups(const std::string& new_group); + void DeviceGroupAction(const std::string& action, const std::string& value, + bool gallery_window); + bool SaveDeviceGroups(const std::string& new_group, bool gallery_window); void ReleaseControl(); void SyncAudioPlayback(); void SaveGalleryWindowState(); @@ -89,9 +94,9 @@ private: void SyncXWaylandWindowActivation(); #endif void UpdateLocalization(); - void ResetSettingsUi(); - bool ValidateSelfHostedSettingsFromUi(); - bool SaveSettingsFromUi(); + void ResetSettingsUi(bool gallery_window = false); + bool ValidateSelfHostedSettingsFromUi(bool gallery_window = false); + bool SaveSettingsFromUi(bool gallery_window = false); void ApplySettingsFromConfig(bool reconnect); #if _WIN32 && CROSSDESK_PORTABLE void CheckPortableWindowsService(); 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 aa31ec79..fbcbd980 100644 --- a/apps/desktop/src/gui/features/gallery/gallery_window_state.h +++ b/apps/desktop/src/gui/features/gallery/gallery_window_state.h @@ -22,26 +22,6 @@ struct WindowState { std::optional wall_size; std::optional wall_position, connection_position; - void BeginModeChange(bool wall, slint::PhysicalPosition position, - slint::PhysicalSize size, float scale, bool fullscreen, - bool maximized, float compact_height = 486) { - // Never capture a half-applied mode transition as the user's wall geometry. - Observe(!wall, position, size, scale, fullscreen, maximized, - compact_height); - if (wall) { - // Modes share the current desktop location. An old wall position must - // not undo a move made while the connection page was visible. - if (connection_position) - wall_position = connection_position; - } else { - // Fullscreen/maximized coordinates describe the screen, not the ordinary - // window. Return to the last normal anchor before restoring compact size. - if (wall_position) - connection_position = wall_position; - } - BeginRestore(); - } - void Observe(bool wall, slint::PhysicalPosition position, slint::PhysicalSize size, float scale, bool fullscreen, bool maximized, float compact_height = 486) { diff --git a/apps/desktop/src/gui/features/gallery/gallery_workspace.h b/apps/desktop/src/gui/features/gallery/gallery_workspace.h index 81e8e0af..c003eb9b 100644 --- a/apps/desktop/src/gui/features/gallery/gallery_workspace.h +++ b/apps/desktop/src/gui/features/gallery/gallery_workspace.h @@ -82,7 +82,39 @@ struct Workspace { if (wall_ids_.count(m.id) && (group.empty() || m.group == group) && seen.insert(m.id).second) ids.push_back(m.id); - return ids; + return OrderIds(ids); + } + // Order is independent of membership, group assignments and live sessions. + std::vector OrderIds(const std::vector &ids) const { + if (device_order_.empty()) + return ids; + std::set remaining(ids.begin(), ids.end()); + std::vector ordered; + ordered.reserve(ids.size()); + for (const auto &id : device_order_) + if (remaining.erase(id)) ordered.push_back(id); + for (const auto &id : ids) + if (remaining.erase(id)) ordered.push_back(id); + return ordered; + } + bool SwapPositions(const std::string &source, const std::string &target, + const std::vector &visible_ids) { + if (source == target || !ValidId(source) || !ValidId(target) || + std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() || + std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end()) + return false; + auto ordered = device_order_; + std::set known(ordered.begin(), ordered.end()); + // Include hidden groups before applying a swap in a filtered view. + for (const auto &id : Ids()) + if (known.insert(id).second) ordered.push_back(id); + // Active-only devices can be arranged without enrolling them in the wall. + for (const auto &id : visible_ids) + if (ValidId(id) && known.insert(id).second) ordered.push_back(id); + std::iter_swap(std::find(ordered.begin(), ordered.end(), source), + std::find(ordered.begin(), ordered.end(), target)); + device_order_ = std::move(ordered); + return true; } // History filtering uses group assignments, independently of wall enrollment. // nullopt means all devices; an empty name means no named group. @@ -147,6 +179,7 @@ struct Workspace { if (group.empty()) { // Removing from the wall leaves history's group assignments intact. wall_ids_.erase(id); + std::erase(device_order_, id); } else if (MatchesGroup(id, group)) { // Removing the last named group leaves an ungrouped wall member. // Reuse the editor path so enrollment and card order stay unchanged. @@ -198,9 +231,19 @@ struct Workspace { if (ValidId(id) && loaded.HasAssignments(id)) loaded.wall_ids_.insert(id); } + if (doc.contains("device_order") && doc["device_order"].is_array()) { + std::set seen; + for (const auto &value : doc["device_order"]) { + if (!value.is_string()) continue; + const auto id = value.get(); + if (ValidId(id) && seen.insert(id).second) + loaded.device_order_.push_back(id); + } + } members = std::move(loaded.members); groups = std::move(loaded.groups); wall_ids_ = std::move(loaded.wall_ids_); + device_order_ = std::move(loaded.device_order_); stream_settings = loaded.stream_settings; return true; } catch (...) { @@ -212,6 +255,7 @@ struct Workspace { nlohmann::json doc{{"version", 2}, {"members", nlohmann::json::array()}, {"wall_ids", wall_ids_}, + {"device_order", device_order_}, {"stream_settings", stream_settings.ToJson()}}; for (const auto &m : members) doc["members"].push_back({{"id", m.id}, {"group", m.group}}); @@ -223,6 +267,7 @@ struct Workspace { private: std::set wall_ids_; + std::vector device_order_; bool HasAssignments(const std::string &id) const { return std::any_of(members.begin(), members.end(), diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 73c3eae1..68a62ef9 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -403,6 +403,7 @@ export component PrimaryButton inherits Rectangle { export component SecondaryButton inherits Rectangle { in property text; + in property text-size: ImGuiFontStyle.body; in property enabled: true; callback clicked; @@ -419,7 +420,7 @@ export component SecondaryButton inherits Rectangle { Text { text: root.text; color: root.enabled ? #24272d : #949aa5; - font-size: ImGuiFontStyle.body; + font-size: root.text-size; horizontal-alignment: center; vertical-alignment: center; } diff --git a/apps/desktop/src/gui/ui/gallery_network_dialog.slint b/apps/desktop/src/gui/ui/gallery_network_dialog.slint index c30f9294..c77a6088 100644 --- a/apps/desktop/src/gui/ui/gallery_network_dialog.slint +++ b/apps/desktop/src/gui/ui/gallery_network_dialog.slint @@ -27,18 +27,18 @@ component NetworkRow inherits Rectangle { identity := Rectangle { horizontal-stretch: 1; device-name := Text { - y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px; + y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px; width: parent.width; text: root.device.name; font-size: root.heading ? 11px : 12px; font-weight: 600; color: root.heading ? #6b7785 : #293746; vertical-alignment: center; overflow: elide; } name-hover := TouchArea { - y: 11px; height: 20px; width: parent.width; + y: 8px; height: 20px; width: parent.width; enabled: !root.heading; } if !root.heading: HorizontalLayout { - x: 0px; y: 34px; width: parent.width; height: 17px; spacing: 4px; + x: 0px; y: 30px; width: parent.width; height: 17px; spacing: 4px; id-text := Text { text: root.device.id; font-size: 10px; color: #7a8490; vertical-alignment: center; overflow: elide; @@ -77,7 +77,7 @@ component NetworkRow inherits Rectangle { Rectangle { width: max(108px, root.width * 0.19); Text { - y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px; + y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px; width: parent.width; text: root.heading ? GalleryStrings.tr("画面", "Video", "Видео") : root.device.resolution; font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400; @@ -85,7 +85,7 @@ component NetworkRow inherits Rectangle { vertical-alignment: center; overflow: elide; } if !root.heading: Text { - y: 34px; height: 17px; width: parent.width; + y: 30px; height: 17px; width: parent.width; text: root.device.fps; font-size: 11px; color: #7a8490; vertical-alignment: center; overflow: elide; } @@ -99,20 +99,20 @@ component NetworkRow inherits Rectangle { vertical-alignment: center; overflow: elide; } if !root.heading: Text { - x: 0px; y: 11px; height: 20px; width: 14px; + x: 0px; y: 8px; height: 20px; width: 14px; text: "↓"; color: #2676b9; font-size: 12px; vertical-alignment: center; } if !root.heading: Text { - x: 17px; y: 11px; height: 20px; width: parent.width - 17px; + x: 17px; y: 8px; height: 20px; width: parent.width - 17px; text: root.device.inbound; color: #293746; font-size: 12px; vertical-alignment: center; overflow: elide; } if !root.heading: Text { - x: 0px; y: 34px; height: 17px; width: 14px; + x: 0px; y: 30px; height: 17px; width: 14px; text: "↑"; color: #7a8490; font-size: 11px; vertical-alignment: center; } if !root.heading: Text { - x: 17px; y: 34px; height: 17px; width: parent.width - 17px; + x: 17px; y: 30px; height: 17px; width: parent.width - 17px; text: root.device.outbound; color: #7a8490; font-size: 11px; vertical-alignment: center; overflow: elide; } @@ -120,7 +120,7 @@ component NetworkRow inherits Rectangle { Rectangle { width: max(94px, root.width * 0.16); Text { - y: root.heading ? 0px : 11px; height: root.heading ? parent.height : 20px; + y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px; width: parent.width; text: root.heading ? GalleryStrings.tr("网络质量", "Network quality", "Качество сети") : "RTT " + root.device.latency; font-size: 11px; font-weight: root.heading ? 600 : 400; @@ -128,7 +128,7 @@ component NetworkRow inherits Rectangle { vertical-alignment: center; overflow: elide; } if !root.heading: Text { - y: 34px; height: 17px; width: parent.width; + y: 30px; height: 17px; width: parent.width; text: GalleryStrings.tr("丢包 ", "Loss ", "Потери ") + root.device.loss; font-size: 11px; color: #7a8490; vertical-alignment: center; overflow: elide; @@ -162,14 +162,16 @@ component NetworkEmptyState inherits Rectangle { export component GalleryNetworkDialog inherits Rectangle { in property <[GalleryNetworkDevice]> devices; + private property row-height: 56px; + private property list-height: max(2, min(root.devices.length, 6)) * root.row-height; callback close(); background: #00000042; TouchArea { clicked => { root.close(); } } Rectangle { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: min(960px, parent.width - 32px); - height: min(560px, parent.height - 24px); + width: min(760px, parent.width - 32px); + height: min(120px + root.list-height, parent.height - 32px); background: white; border-radius: 8px; border-width: 1px; border-color: #c6cdd5; TouchArea {} @@ -210,10 +212,10 @@ export component GalleryNetworkDialog inherits Rectangle { x: 12px; y: 108px; width: parent.width - 24px; height: parent.height - self.y - 12px; viewport-width: self.width - 14px; - viewport-height: max(self.height, root.devices.length * 62px); + viewport-height: max(self.height, root.devices.length * root.row-height); horizontal-scrollbar-policy: always-off; for device[index] in root.devices: NetworkRow { - y: index * 62px; width: list.viewport-width; height: 62px; + y: index * root.row-height; width: list.viewport-width; height: root.row-height; device: device; background: mod(index, 2) == 0 ? white : #f8fafc; Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; } } diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index 1d158ebf..639266e3 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -3,7 +3,7 @@ import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint"; import { GalleryMenuButton } from "gallery_menu_button.slint"; import { GroupFilter } from "group_filter.slint"; import { ScrollView, LineEdit } from "std-widgets.slint"; -import { CompactButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint"; +import { CompactButton, SecondaryButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint"; export struct GalleryTile { id: string, @@ -29,21 +29,72 @@ export struct GalleryActionDevice { connected: bool, } +component DeviceDetailsTip inherits Rectangle { + in property device-name; + in property device-id; + in property presence; + in property available-height; + height: min(root.available-height, 58px); + background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: 5px; + clip: true; + tip-name := Text { + x: 8px; y: 8px; width: parent.width - 16px; + height: 14px; + text: root.device-name; color: white; font-size: 11px; overflow: elide; vertical-alignment: top; + } + Text { + x: 8px; y: tip-name.y + tip-name.height; width: parent.width - 16px; height: 14px; + text: "ID: " + root.device-id; color: white; font-size: 11px; overflow: elide; + } + Text { + x: 8px; y: tip-name.y + tip-name.height + 14px; width: parent.width - 16px; height: 14px; + text: root.presence; color: white; font-size: 11px; overflow: elide; + } +} + export component GalleryView inherits Rectangle { in property controlling: false; in property simplified: false; - private property grid-gap: root.simplified ? 6px : 12px; - private property rail-card-height: root.simplified ? 92px : 76px; + in property window-active: true; + in-out property tile-width: 216; + out property resizing: false; + private property preview-aspect-ratio: 16 / 9; + private property card-inset: 6px; + private property card-chrome-height: root.simplified ? root.card-inset * 2 : 36px; + private property max-card-width: max(184px, min(960px, grid.width, + (grid.height - root.card-chrome-height) * root.preview-aspect-ratio + root.card-inset * 2)); + private property card-width: clamp(root.tile-width * 1px, 184px, root.max-card-width); + private property card-preview-height: (root.card-width - root.card-inset * 2) / root.preview-aspect-ratio; + private property card-height: root.card-preview-height + root.card-chrome-height; + private property rail-preview-height: 126px / root.preview-aspect-ratio; + private property rail-card-height: root.rail-preview-height + (root.simplified ? 10px : 24px); private property focused-id: root.focused.id; private property device-tooltip-id; private property device-tooltip-text; private property device-tooltip-x; private property device-tooltip-y; private property device-tooltip-visible: false; - changed focused-id => { root.device-tooltip-id = ""; } - changed visible => { root.device-tooltip-id = ""; if !root.visible { add-menu.close-menu(); batch-menu.close-menu(); } } - changed width => { root.device-tooltip-id = ""; } - changed height => { root.device-tooltip-id = ""; } + private property drag-source; + private property dragged-tile; + private property dragging: false; + private property drag-start-x; + private property drag-start-y; + private property drag-x; + private property drag-y; + private property drag-target: root.drop-index(root.drag-x, root.drag-y); + private property drag-page: -1; + private property drag-edge-width: 32px; + private property drag-page-direction: root.page-direction(root.drag-x, root.drag-y); + private property swap-save-failed: false; + changed focused-id => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } + changed visible => { root.device-tooltip-id = ""; if !root.visible { root.resizing = false; root.cancel-device-drag(); add-menu.close-menu(); batch-menu.close-menu(); } } + changed width => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } + changed height => { root.device-tooltip-id = ""; root.resizing = false; root.cancel-device-drag(); } + changed window-active => { if !root.window-active { root.resizing = false; root.cancel-device-drag(); } } + changed page => { if root.page != root.drag-page { root.cancel-device-drag(); } } + changed group-index => { root.cancel-device-drag(); } + changed card-width => { root.cancel-device-drag(); } + changed card-height => { root.cancel-device-drag(); } changed device-tooltip-id => { root.device-tooltip-visible = false; device-tooltip-timer.running = root.device-tooltip-id != ""; @@ -65,8 +116,9 @@ export component GalleryView inherits Rectangle { in property <[GalleryTile]> tiles; in property <[string]> groups; in-out property group-index: 0; - in property columns: 3; - in property rows: 2; + // Pagination and rendering share the same fixed-aspect cell geometry. + out property columns: max(1, floor(grid.width / root.card-width)); + out property rows: max(1, floor(grid.height / root.card-height)); in property page: 0; in property pages: 1; in property count: 0; @@ -91,6 +143,7 @@ export component GalleryView inherits Rectangle { private property action-code; in property <[GalleryActionDevice]> selected-devices; callback action(string, string); + callback swap-devices(string, string) -> bool; callback add-device(string, string, string, bool) -> bool; callback import-recent(string) -> bool; callback edit-alias(string, string); @@ -100,10 +153,89 @@ export component GalleryView inherits Rectangle { changed adding => { if root.adding { root.action("release", ""); } } changed importing => { root.action(root.importing ? "release" : "recent-close", ""); } public function reset-transient() { + root.resizing = false; + root.cancel-device-drag(); root.batch = false; root.adding = false; root.importing = false; root.confirming = false; root.device-tooltip-id = ""; add-menu.close-menu(); batch-menu.close-menu(); } - changed batch => { root.device-tooltip-id = ""; batch-menu.close-menu(); } + changed batch => { root.device-tooltip-id = ""; root.cancel-device-drag(); batch-menu.close-menu(); } + function cancel-device-drag() { + root.dragging = false; root.drag-source = ""; root.drag-page = -1; + } + function cell-index(x: length, y: length) -> int { + if x < 0px || y < 0px || x >= grid.table-width || y >= grid.table-height { return -1; } + let column = floor(x / root.card-width); + let row = floor(y / root.card-height); + let index = row * root.columns + column; + if index >= root.tiles.length { return -1; } + // Separators remain resize targets; drop on the device inside a cell. + let cell-x = x - column * root.card-width; + let cell-y = y - row * root.card-height; + if cell-x < 6px || cell-x >= root.card-width - 6px || cell-y < 6px || cell-y >= root.card-height - 6px { return -1; } + return index; + } + function drop-index(x: length, y: length) -> int { + let index = root.cell-index(x, y); + return !root.dragging || root.drag-page-direction != 0 || index < 0 || root.tiles[index].id == root.drag-source ? -1 : index; + } + function page-direction(x: length, y: length) -> int { + if !root.dragging { return 0; } + let view-x = grid.x + x; + let view-y = grid.y + y; + if view-x < 0px || view-x >= root.width || view-y < 0px || view-y >= root.height { return 0; } + if root.page > 0 && view-x < root.drag-edge-width { return -1; } + if root.page + 1 < root.pages && view-x >= root.width - root.drag-edge-width { return 1; } + // Keep the pager buttons as additional hover targets. + let pager-x = view-x - footer.x - pager.x; + let pager-y = view-y - footer.y - pager.y; + if pager-y < 0px || pager-y >= pager.height { return 0; } + if root.page > 0 && pager-x >= previous-page.x && pager-x < previous-page.x + previous-page.width { return -1; } + if root.page + 1 < root.pages && pager-x >= next-page.x && pager-x < next-page.x + next-page.width { return 1; } + return 0; + } + changed drag-page-direction => { + if root.drag-page-direction != 0 { drag-page-timer.restart(); } + } + drag-page-timer := Timer { + interval: 700ms; + running: root.dragging && root.drag-page-direction != 0 && root.page == root.drag-page; + triggered => { + if !root.dragging || root.drag-page-direction == 0 || root.page != root.drag-page { return; } + root.drag-page = root.page + root.drag-page-direction; + root.action("page", root.drag-page-direction > 0 ? "next" : "previous"); + } + } + function move-device-drag(x: length, y: length) { + if root.drag-source == "" { return; } + root.drag-x = x; root.drag-y = y; + if abs(x - root.drag-start-x) >= 6px || abs(y - root.drag-start-y) >= 6px { + root.dragging = true; root.device-tooltip-id = ""; + } + } + function finish-device-drag() { + let source = root.drag-source; + let target = root.drag-target >= 0 ? root.tiles[root.drag-target].id : ""; + // Clear transient state before the callback can replace the page model. + root.cancel-device-drag(); + if source != "" && target != "" { + root.swap-save-failed = !root.swap-devices(source, target); + if root.swap-save-failed { swap-error-timer.restart(); } + } + } + drag-focus := FocusScope { + x: 0px; y: 0px; width: 0px; height: 0px; + key-pressed(event) => { + if event.text == Key.Escape && root.drag-source != "" { + root.cancel-device-drag(); return accept; + } + reject + } + focus-lost => { root.cancel-device-drag(); } + } + swap-error-timer := Timer { + interval: 4s; running: false; + triggered => { root.swap-save-failed = false; self.running = false; } + } function confirm-action(code: string, name: string) { root.action-code = code; root.action-name = name; root.confirming = true; } @@ -138,12 +270,104 @@ export component GalleryView inherits Rectangle { } } } - grid := Rectangle { + grid := TouchArea { x: root.simplified ? 12px : toolbar.x; y: root.simplified ? 12px : toolbar.y + toolbar.height + 12px; width: parent.width - self.x * 2; height: footer.y - 12px - self.y; visible: root.focused.id == ""; + private property table-width: root.columns * root.card-width; + private property table-height: root.rows * root.card-height; + Rectangle { + visible: root.count > 0; + x: 0px; y: 0px; + width: grid.table-width; height: grid.table-height; + background: white; + } + // One stable pointer owner survives page model replacements and cell resizing. + private property column-line: clamp(round(self.mouse-x / root.card-width), 0, root.columns); + private property row-line: clamp(round(self.mouse-y / root.card-height), 0, root.rows); + private property over-table: root.count > 0 + && self.mouse-x >= 0px && self.mouse-x <= grid.table-width + && self.mouse-y >= 0px && self.mouse-y <= grid.table-height; + // Both sides of a shared separator must resize in the same direction. + private property edge-x: !self.over-table || abs(self.mouse-x - self.column-line * root.card-width) > 6px + ? 0 : self.column-line == 0 ? -1 : 1; + private property edge-y: !self.over-table || abs(self.mouse-y - self.row-line * root.card-height) > 6px + ? 0 : self.row-line == 0 ? -1 : 1; + private property drag-x; + private property drag-y; + private property drag-columns; + private property drag-rows; + private property start-x; + private property start-y; + private property start-width; + private property cursor-x: root.resizing ? self.drag-x : self.edge-x; + private property cursor-y: root.resizing ? self.drag-y : self.edge-y; + private property hover-index: root.cell-index(self.mouse-x, self.mouse-y); + mouse-cursor: root.dragging ? (root.drag-target >= 0 || root.drag-page-direction != 0 ? MouseCursor.grabbing : MouseCursor.not-allowed) + : self.cursor-x != 0 && self.cursor-y != 0 + ? (self.cursor-x == self.cursor-y ? MouseCursor.nwse-resize : MouseCursor.nesw-resize) + : self.cursor-x != 0 ? MouseCursor.ew-resize : self.cursor-y != 0 ? MouseCursor.ns-resize + : self.hover-index >= 0 ? MouseCursor.grab : MouseCursor.default; + function show-hint() { + if self.has-hover && !self.pressed && (self.edge-x != 0 || self.edge-y != 0) { + root.device-tooltip-id = "resize-grid"; + root.device-tooltip-text = GalleryStrings.tr("拖动表格分隔线,同步调整所有设备大小", "Drag a table divider to resize all devices", "Потяните за разделитель таблицы, чтобы изменить размер всех устройств"); + root.device-tooltip-x = grid.x + self.mouse-x; + root.device-tooltip-y = grid.y + self.mouse-y + 16px; + } else if root.device-tooltip-id == "resize-grid" { root.device-tooltip-id = ""; } + } + function resize-cells() { + if !root.resizing { return; } + // Distribute the movement across preceding cells so the chosen + // divider follows the pointer while every cell stays equal. + let dx = (self.mouse-x - self.start-x) * self.drag-x / self.drag-columns; + let dy = (self.mouse-y - self.start-y) * self.drag-y / self.drag-rows; + let delta = self.drag-x == 0 ? dy * root.preview-aspect-ratio + : self.drag-y == 0 ? dx + : (dx + dy / root.preview-aspect-ratio) / (1 + 1 / (root.preview-aspect-ratio * root.preview-aspect-ratio)); + root.tile-width = clamp(self.start-width + delta, 184px, root.max-card-width) / 1px; + } + changed has-hover => { self.show-hint(); } + changed mouse-x => { self.show-hint(); } + changed mouse-y => { self.show-hint(); } + pointer-event(event) => { + if event.button == PointerEventButton.left && event.kind == PointerEventKind.down { + root.cancel-device-drag(); + if self.edge-x != 0 || self.edge-y != 0 { + self.drag-x = self.edge-x; self.drag-y = self.edge-y; + self.drag-columns = max(1, self.column-line); + self.drag-rows = max(1, self.row-line); + self.start-x = self.mouse-x; self.start-y = self.mouse-y; + self.start-width = root.card-width; + root.resizing = true; root.device-tooltip-id = ""; + } else if self.hover-index >= 0 { + drag-focus.focus(); + root.dragged-tile = root.tiles[self.hover-index]; + root.drag-source = root.dragged-tile.id; root.drag-page = root.page; + root.drag-start-x = self.mouse-x; root.drag-start-y = self.mouse-y; + root.drag-x = self.mouse-x; root.drag-y = self.mouse-y; + root.swap-save-failed = false; root.device-tooltip-id = ""; + } + } else if event.kind == PointerEventKind.cancel { + root.resizing = false; root.cancel-device-drag(); + } else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up { + if root.resizing { self.resize-cells(); root.resizing = false; } + else if root.drag-source != "" { + root.move-device-drag(self.mouse-x, self.mouse-y); + if !root.dragging && self.hover-index >= 0 && root.tiles[self.hover-index].id == root.drag-source { + let id = root.drag-source; + root.cancel-device-drag(); + root.action(root.batch && !root.simplified ? "select" : "focus", id); + } else { root.finish-device-drag(); } + } + } + } + moved => { + if root.resizing { self.resize-cells(); } + else if root.drag-source != "" { root.move-device-drag(self.mouse-x, self.mouse-y); } + } for tile[index] in root.tiles: tile-card := Rectangle { private property presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); @@ -151,18 +375,18 @@ export component GalleryView inherits Rectangle { accessible-role: button; accessible-label: self.details; accessible-action-default => { root.action(root.batch && !root.simplified ? "select" : "focus", tile.id); } changed details => { if tile-touch.has-hover { tile-touch.show-details(); } } - x: mod(index, root.columns) * (grid.width + root.grid-gap) / root.columns; - y: floor(index / root.columns) * (grid.height + root.grid-gap) / root.rows; - width: (grid.width - (root.columns - 1) * root.grid-gap) / root.columns; - height: (grid.height - (root.rows - 1) * root.grid-gap) / root.rows; - border-radius: root.simplified ? 0px : 6px; - background: !tile.online ? DeviceCardStyle.offline-background : root.simplified ? #24272d : tile.selected ? #eaf3ff : #eff0f2; - border-width: root.simplified ? 0px : tile.selected ? 2px : 1px; - border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : !tile.online ? DeviceCardStyle.offline-border : #c9cdd2; + visible: index < root.columns * root.rows; + x: mod(index, root.columns) * root.card-width; + y: floor(index / root.columns) * root.card-height; + width: root.card-width; + height: root.card-height; + opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1; + background: !tile.online ? DeviceCardStyle.offline-background + : tile.selected ? #eaf3ff : root.batch && tile-touch.has-hover ? #f3f8fe : white; Rectangle { - x: root.simplified ? 0px : 6px; y: self.x; + x: root.card-inset; y: self.x; width: parent.width - self.x * 2; - height: root.simplified ? parent.height : parent.height - 36px; + height: root.card-preview-height; background: tile.online ? #24272d : DeviceCardStyle.offline-preview; if tile.has-frame && tile.online: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; } if !root.simplified && (!tile.has-frame || !tile.connected || tile.stale): Text { @@ -170,14 +394,19 @@ export component GalleryView inherits Rectangle { horizontal-alignment: center; vertical-alignment: center; wrap: word-wrap; } + if root.device-tooltip-visible && root.device-tooltip-id == tile.id: DeviceDetailsTip { + x: 4px; y: parent.height - self.height - 4px; + width: min(320px, parent.width - 8px); available-height: parent.height - 8px; + device-name: tile.name; device-id: tile.id; presence: tile-card.presence; + } } Text { - visible: !root.simplified; x: 9px; y: parent.height - 28px; + visible: !root.simplified; x: 9px; y: parent.height - 30px; width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px); - height: 28px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center; + height: 24px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center; } if root.batch && !root.simplified: Rectangle { - x: parent.width - 27px; y: parent.height - 23px; + x: parent.width - 27px; y: parent.height - 27px; width: 18px; height: 18px; border-radius: 5px; border-width: 1px; background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white; border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8; @@ -188,22 +417,23 @@ export component GalleryView inherits Rectangle { horizontal-alignment: center; vertical-alignment: center; } } - tile-touch := TouchArea { + tile-touch := Rectangle { + x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 12px; + private property has-hover: grid.has-hover && grid.hover-index == index; + private property pressed: grid.pressed && root.drag-source == tile.id; function show-details() { // Avoid covering the per-device action buttons with a tooltip. - if self.mouse-y >= tile-card.height - 28px && !root.simplified { root.device-tooltip-id = ""; return; } - root.device-tooltip-id = tile.id; root.device-tooltip-text = tile-card.details; - root.device-tooltip-x = grid.x + tile-card.x; - root.device-tooltip-y = grid.y + tile-card.y + tile-card.height + 4px; + if root.resizing || root.drag-source != "" || (grid.mouse-y - tile-card.y >= tile-card.height - 30px && !root.simplified) { root.device-tooltip-id = ""; return; } + root.device-tooltip-id = tile.id; } changed has-hover => { if self.has-hover { self.show-details(); } else if root.device-tooltip-id == tile.id { root.device-tooltip-id = ""; } } - moved => { self.show-details(); } - clicked => { root.device-tooltip-id = ""; root.action(root.batch && !root.simplified ? "select" : "focus", tile.id); } - if tile-touch.has-hover && !root.batch && !root.simplified: DeviceCardActions { - x: parent.width - self.width - 6px; y: parent.height - 26px; + private property over-actions: grid.mouse-y - tile-card.y >= tile-card.height - 30px; + changed over-actions => { if self.has-hover { self.show-details(); } } + if tile-touch.has-hover && !root.dragging && !root.resizing && !root.batch && !root.simplified: DeviceCardActions { + x: parent.width - self.width; y: parent.height - 23px; remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub"); connected: tile.connected; edit-alias => { root.action("release", ""); root.edit-alias(tile.id, tile.name); } @@ -214,6 +444,29 @@ export component GalleryView inherits Rectangle { } } } + // Draw each table line once, continuously across occupied and empty cells. + for column in root.columns + 1: Rectangle { + visible: root.count > 0; + x: min(column * root.card-width, grid.table-width - 1px); + y: 0px; + width: 1px; height: grid.table-height; + background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column) + ? #76aeea : #c9cdd2; + } + for row in root.rows + 1: Rectangle { + visible: root.count > 0; + x: 0px; + y: min(row * root.card-height, grid.table-height - 1px); + width: grid.table-width; height: 1px; + background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row) + ? #76aeea : #c9cdd2; + } + if root.drag-target >= 0: Rectangle { + x: mod(root.drag-target, root.columns) * root.card-width + 1px; + y: floor(root.drag-target / root.columns) * root.card-height + 1px; + width: root.card-width - 2px; height: root.card-height - 2px; + background: #76aeea26; border-width: 2px; border-color: #4a94de; + } if root.count == 0: Text { text: root.simplified ? GalleryStrings.tr("暂无设备,退出精简模式后可添加", "No devices. Exit compact mode to add devices", "Нет устройств. Выйдите из компактного режима, чтобы добавить их") @@ -313,7 +566,7 @@ export component GalleryView inherits Rectangle { // Collapse the focused tile and its gap without changing the page model. width: tile.id == root.focused.id ? 0px : 144px; height: root.rail-card-height; visible: tile.id != root.focused.id; - Rectangle { + rail-card := Rectangle { private property presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); private property details: tile.name + "\nID: " + tile.id + "\n" + self.presence; @@ -324,25 +577,27 @@ export component GalleryView inherits Rectangle { accessible-action-default => { root.action("focus", tile.id); } changed details => { if rail-touch.has-hover { rail-touch.show-details(); } } Rectangle { - x: 5px; y: 5px; width: 126px; height: root.simplified ? 82px : 52px; background: tile.online ? #24272d : DeviceCardStyle.offline-preview; + x: 5px; y: 5px; width: 126px; height: root.rail-preview-height; background: tile.online ? #24272d : DeviceCardStyle.offline-preview; if tile.has-frame && tile.online: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; } if !root.simplified && (!tile.has-frame || tile.stale || !tile.connected): Text { text: tile.status; color: tile.online ? #e2e6ed : DeviceCardStyle.offline-placeholder; font-size: 10px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; } + if root.device-tooltip-visible && root.device-tooltip-id == tile.id: DeviceDetailsTip { + x: 4px; y: parent.height - self.height - 4px; + width: parent.width - 8px; available-height: parent.height - 8px; + device-name: tile.name; device-id: tile.id; presence: rail-card.presence; + } } Text { visible: !root.simplified; - x: 7px; y: 58px; width: 122px; height: 16px; + x: 7px; y: root.rail-preview-height + 6px; width: 122px; height: 16px; text: tile.name; color: tile.online ? #303943 : DeviceCardStyle.offline-text; font-size: 11px; overflow: elide; vertical-alignment: center; } rail-touch := TouchArea { function show-details() { root.device-tooltip-id = tile.id; - root.device-tooltip-text = parent.details; - root.device-tooltip-x = focus-view.x + rail.x + rail.viewport-x + rail-slot.x; - root.device-tooltip-y = focus-view.y + rail.y + rail.height + 4px; } changed has-hover => { if self.has-hover { self.show-details(); } @@ -367,29 +622,19 @@ export component GalleryView inherits Rectangle { status-area := Rectangle { x: 0px; y: 0px; width: parent.width - 124px; height: parent.height; visible: !root.batch || root.simplified; - border-radius: 3px; background: network-touch.has-hover ? #edf0f4 : transparent; - accessible-role: button; - accessible-label: GalleryStrings.tr("网络状态", "Network", "Сеть") + ": " + root.connected-summary; - accessible-action-default => { root.network-open(); } - Text { - x: 0px; width: parent.width - 20px; height: parent.height; - text: root.connected-summary + " · " + GalleryStrings.tr("网络状态", "Network", "Сеть"); + connection-count := Text { + x: 0px; width: min(self.preferred-width, parent.width - network-button.width - 12px); height: parent.height; + text: root.connected-summary; color: #62666d; vertical-alignment: center; font-size: 11px; overflow: elide; } - Text { - x: parent.width - 13px; width: 12px; height: parent.height; - text: FontAwesomeIcons.angle-up; font-family: "Font Awesome 6 Free"; font-weight: 900; - font-size: 9px; color: #858c96; vertical-alignment: center; - } - network-touch := TouchArea { - mouse-cursor: pointer; - changed has-hover => { - if self.has-hover { - root.device-tooltip-id = "network-status"; - root.device-tooltip-text = root.connected-summary + "\n" + GalleryStrings.tr("点击查看网络状态", "Click to view network status", "Нажмите для просмотра состояния сети"); - root.device-tooltip-x = footer.x; root.device-tooltip-y = footer.y; - } else if root.device-tooltip-id == "network-status" { root.device-tooltip-id = ""; } - } + network-button := SecondaryButton { + x: connection-count.x + connection-count.width + 12px; + min-width: 64px; width: 64px; height: parent.height; + border-color: #c8cbd0; border-radius: 3px; + text: GalleryStrings.tr("网络状态", "Network", "Сеть"); + accessible-role: button; + accessible-label: self.text; + accessible-action-default => { self.clicked(); } clicked => { root.device-tooltip-id = ""; root.network-open(); } } } @@ -432,15 +677,57 @@ export component GalleryView inherits Rectangle { } } } - HorizontalLayout { + pager := HorizontalLayout { x: parent.width - self.width; y: 0px; width: 112px; height: 24px; spacing: 6px; - CompactButton { width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } } + previous-page := CompactButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } } Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; } - CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } } + next-page := CompactButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } } } } - // Share one delayed tooltip across cards and keep it inside the view. - if root.device-tooltip-visible && root.device-tooltip-id != "": Rectangle { + for edge in 2: Rectangle { + visible: root.dragging && (edge == 0 ? root.page > 0 : root.page + 1 < root.pages); + private property active: root.drag-page-direction == (edge == 0 ? -1 : 1); + x: edge == 0 ? 0px : parent.width - self.width; y: 0px; + width: root.drag-edge-width; height: parent.height; z: 8; + background: self.active ? #76aeea38 : #76aeea14; + Text { + text: edge == 0 ? "‹" : "›"; font-size: 24px; + color: parent.active ? #357bc0 : #7ba2c9; + horizontal-alignment: center; vertical-alignment: center; + } + } + if root.dragging: Rectangle { + x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px); + y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px); + width: 224px; height: 54px; z: 10; + background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: 4px; + Rectangle { + x: 8px; y: 9px; width: 64px; height: 36px; + background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview; + if root.dragged-tile.has-frame && root.dragged-tile.online: Image { + x: 0px; y: 0px; width: parent.width; height: parent.height; + source: root.dragged-tile.frame; image-fit: contain; + } + } + Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; } + Text { + x: 80px; y: 29px; width: parent.width - 88px; height: 16px; + text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page", "Удерживайте для перелистывания") + : root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена") + : GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство"); + font-size: 10px; color: #527ca8; overflow: elide; + } + } + if root.swap-save-failed: Rectangle { + x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px; + width: min(420px, parent.width - 32px); height: 36px; z: 10; + background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: 4px; + Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center; + text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide; + } + } + // Resize hints follow the pointer; device details stay inside their preview. + if root.device-tooltip-visible && root.device-tooltip-id == "resize-grid": Rectangle { x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px); y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px); width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 200f1a53..7756ff0c 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -265,19 +265,20 @@ component PasswordInput inherits Rectangle { } export component MainWindow inherits Window { - title: "CrossDesk"; + title: root.gallery-mode ? "CrossDesk · " + GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub") : "CrossDesk"; icon: @image-url("../../../resources/linux/crossdesk_256x256.png"); preferred-width: 640px; - preferred-height: 486px + root.titlebar-height; + preferred-height: root.minimum-window-height; min-width: 640px; max-width: root.gallery-mode ? 16384px : 640px; - min-height: 486px + root.titlebar-height; - max-height: root.gallery-mode ? 16384px : 486px + root.titlebar-height; + min-height: root.minimum-window-height; + max-height: root.gallery-mode ? 16384px : root.minimum-window-height; no-frame: root.custom-titlebar; background: root.wayland-titlebar ? transparent : white; default-font-size: ImGuiFontStyle.base; - in-out property gallery-mode: false; + // Fixed role for this window instance; opening DeskHub creates another window. + in property gallery-mode: false; in-out property gallery-simplified: false; changed gallery-simplified => { root.reset-gallery-transient(); @@ -316,8 +317,10 @@ export component MainWindow inherits Window { in property <[GalleryTile]> gallery-tiles; in property <[string]> gallery-groups; in-out property gallery-group-index: 0; - in property gallery-columns: 3; - in property gallery-rows: 2; + out property gallery-columns: gallery.columns; + out property gallery-rows: gallery.rows; + in-out property gallery-tile-width: 216; + out property gallery-resizing: gallery.resizing; in property gallery-page: 0; in property gallery-pages: 1; in property gallery-count: 0; @@ -335,11 +338,10 @@ export component MainWindow inherits Window { in property <[GalleryRecentDevice]> gallery-recent-devices; in property gallery-recent-selected: 0; in property gallery-import-group; - out property gallery-width: root.width / 1px; - out property gallery-height: gallery.height / 1px; - callback change-mode(bool); + callback open-gallery(); callback toggle-gallery-fullscreen(); callback gallery-action(string, string); + callback gallery-swap(string, string) -> bool; callback gallery-add-device(string, string, string, bool) -> bool; callback gallery-import-recent(string) -> bool; in-out property device-group-open: false; @@ -367,7 +369,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; - private property content-header-height: 38px; + private property content-header-height: root.gallery-mode ? 38px : 0px; + out property minimum-window-height: + 448px + root.content-header-height + root.titlebar-height; private property wayland-corner-radius: 12px; in property local-id: ""; in property local-password: ""; @@ -585,7 +589,7 @@ export component MainWindow inherits Window { x: 80px; width: parent.width - 160px; height: parent.height; - text: "CrossDesk"; + text: root.title; color: root.window-active ? #3d3d3d : #747474; font-size: 12px; font-weight: 600; @@ -686,7 +690,7 @@ export component MainWindow inherits Window { x: 30px; width: parent.width - 122px; height: parent.height; - text: "CrossDesk"; + text: root.title; color: #202124; font-size: 12px; vertical-alignment: center; @@ -804,15 +808,19 @@ export component MainWindow inherits Window { z: 7; } - // App actions live in the content header; window controls and dragging - // remain in the native or Windows-specific title bar above it. + // The connection page shares its section heading row with the menu. + // Only DeskHub needs a separate toolbar above its content. menu-button := IconButton { - x: parent.width - mode-switch.x - self.width; - y: mode-switch.y; - width: mode-switch.height; - height: mode-switch.height; + visible: !root.gallery-mode; + x: parent.width - 29px - self.width; + y: 10.5px; + width: 28px; + height: self.width; icon: FontAwesomeIcons.bars; icon-size: 14px; + border-width: 1px; + border-color: #c9cdd2; + border-radius: 4px; hover-background: #e1e5eb; pressed-background: #cbd1d9; tooltip: root.announcement-unread > 0 @@ -822,7 +830,7 @@ export component MainWindow inherits Window { clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; } } // Unread messages take precedence so the menu has only one indicator. - if root.update-available && root.announcement-unread <= 0: Rectangle { + if !root.gallery-mode && root.update-available && root.announcement-unread <= 0: Rectangle { x: menu-button.x + menu-button.width - self.width + 4px; y: menu-button.y - 1px; width: 16px; @@ -863,7 +871,7 @@ export component MainWindow inherits Window { clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; } } } - if root.announcement-unread > 0: Rectangle { + if !root.gallery-mode && root.announcement-unread > 0: Rectangle { x: menu-button.x + menu-button.width - self.width + 4px; y: menu-button.y - 1px; width: max(16px, unread-badge-label.preferred-width + 8px); @@ -891,41 +899,23 @@ export component MainWindow inherits Window { } } - mode-switch := Rectangle { + if root.gallery-mode: Text { x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px; - background: #e2e4e7; - border-width: 1px; border-color: #c8cbd0; border-radius: 3px; - Rectangle { - x: root.gallery-mode ? 82px : 2px; - y: 2px; width: 80px; height: parent.height - 4px; - background: #a8d0fa; - border-width: 1px; border-color: #9bc3ed; border-radius: 3px; - animate x { duration: 160ms; easing: ease-out; } - } - for label[index] in [GalleryStrings.tr("连接", "Connect", "Связь"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle { - x: 2px + index * 80px; y: 2px; width: 80px; height: parent.height - 4px; - Text { - text: label; - color: #24272d; - font-weight: (index == 1) == root.gallery-mode ? 600 : 400; - horizontal-alignment: center; vertical-alignment: center; font-size: 12px; - } - TouchArea { - clicked => { root.change-mode(index == 1); } - } - } + text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub"); + color: #24272d; font-weight: 600; font-size: 12px; + vertical-alignment: center; } if root.gallery-mode: CompactButton { - x: menu-button.x - 24px - 16px - self.width; - y: mode-switch.y; width: 94px; height: mode-switch.height; + x: parent.width - 29px - 24px - 8px - self.width; + y: root.content-header-height - self.height; width: 94px; height: 24px; primary: root.gallery-simplified; text: root.gallery-simplified ? GalleryStrings.tr("退出精简", "Exit compact", "Обычный вид") : GalleryStrings.tr("精简模式", "Compact", "Компактно"); clicked => { root.gallery-simplified = !root.gallery-simplified; } } if root.gallery-mode: IconButton { - x: menu-button.x - self.width - 8px; - y: mode-switch.y; width: 24px; height: mode-switch.height; + x: parent.width - 29px - self.width; + y: root.content-header-height - self.height; width: 24px; height: 24px; icon: root.gallery-fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; tooltip: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); clicked => { root.toggle-gallery-fullscreen(); } @@ -1058,7 +1048,7 @@ export component MainWindow inherits Window { Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.border; } - SectionTitle { x: 356px; y: 9px; width: 256px; height: 31px; text: UiStrings.remote-desktop; } + SectionTitle { x: 356px; y: 9px; width: menu-button.x - self.x - 8px; height: 31px; text: UiStrings.remote-desktop; } Rectangle { x: 356px; y: 42px; @@ -1125,8 +1115,23 @@ export component MainWindow inherits Window { Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; } - SectionTitle { x: 29px; y: 7px; height: 28px; text: UiStrings.recent-connections; } - GroupFilter { + SectionTitle { x: 29px; y: 7px; width: open-gallery-button.x - self.x - 12px; height: 28px; text: UiStrings.recent-connections; } + open-gallery-button := SecondaryButton { + x: recent-group-filter.x - self.width - 8px; + y: recent-group-filter.y; + min-width: 60px; + width: root.compact-language ? 60px : 72px; + height: recent-group-filter.height; + text-size: ImGuiFontStyle.base; + border-color: #c9cdd2; + border-radius: 4px; + text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub"); + accessible-role: button; + accessible-label: self.text; + accessible-action-default => { root.open-gallery(); } + clicked => { root.open-gallery(); } + } + recent-group-filter := GroupFilter { x: parent.width - self.width - 29px; y: 8px; width: 155px; height: 28px; model: root.recent-groups; @@ -1338,9 +1343,11 @@ export component MainWindow inherits Window { } gallery := GalleryView { y: root.content-header-height; width: parent.width; - height: parent.height - self.y - 24px; + height: parent.height - self.y; visible: root.gallery-mode; simplified: root.gallery-simplified; + window-active: root.window-active; + tile-width <=> root.gallery-tile-width; network-open => { root.gallery-network-open = true; } controlling: root.gallery-controlling; pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); } @@ -1348,7 +1355,6 @@ export component MainWindow inherits Window { key-input(text, pressed, ctrl, alt, shift, meta) => { root.gallery-key(text, pressed, ctrl, alt, shift, meta); } tiles: root.gallery-tiles; groups: root.gallery-groups; group-index <=> root.gallery-group-index; - columns: root.gallery-columns; rows: root.gallery-rows; page: root.gallery-page; pages: root.gallery-pages; count: root.gallery-count; connected-count: root.gallery-connected; selected-count: root.gallery-selected; selected-audio-available: root.gallery-selected-audio-available; @@ -1359,6 +1365,7 @@ export component MainWindow inherits Window { importing <=> root.gallery-importing; recent-devices: root.gallery-recent-devices; recent-selected: root.gallery-recent-selected; import-group: root.gallery-import-group; action(code, value) => { root.gallery-action(code, value); } + swap-devices(source, target) => { root.gallery-swap(source, target) } add-device(id, password, group, remember) => { root.gallery-add-device(id, password, group, remember) } import-recent(group) => { root.gallery-import-recent(group) } edit-alias(id, name) => { @@ -1369,8 +1376,8 @@ export component MainWindow inherits Window { root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true; } } - // Shared server status bar stays visible in every connection and DeskHub view. - Rectangle { + // Server status and reconnect actions belong to the connection window. + if !root.gallery-mode: Rectangle { y: parent.height - 24px; height: 24px; background: #fafbfc; @@ -1481,7 +1488,7 @@ export component MainWindow inherits Window { } // Dismiss the title menu when clicking anywhere outside of it. - if root.menu-open: TouchArea { + if !root.gallery-mode && root.menu-open: TouchArea { x: 0px; y: 0px; width: parent.width; @@ -1494,7 +1501,7 @@ export component MainWindow inherits Window { } // Title menu. - if root.menu-open: Rectangle { + if !root.gallery-mode && root.menu-open: Rectangle { x: menu-button.x + menu-button.width - self.width; y: menu-button.y + menu-button.height + 2px; width: max(80px, max(menu-settings-label.preferred-width, @@ -1953,19 +1960,14 @@ export component MainWindow inherits Window { DialogSurface { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: min(400px, parent.width - 40px); height: 446px; + width: min(400px, parent.width - 40px); height: min(446px, parent.height - 16px); background: #f8fafc; border-radius: 14px; border-color: #e1e7ef; drop-shadow-color: #14294424; drop-shadow-blur: 24px; drop-shadow-offset-y: 8px; TouchArea { clicked => { focus-sink.focus(); } } Text { - x: 18px; y: 18px; width: parent.width - 80px; height: 27px; + x: 18px; y: 18px; width: parent.width - 36px; height: 27px; text: UiStrings.settings; font-size: 20px; font-weight: 600; color: #22324a; } - SettingsButton { - x: parent.width - 90px; y: 18px; width: 76px; height: 28px; quiet: true; - text: GalleryStrings.tr("关闭", "Close", "Закрыть"); - clicked => { root.cancel-settings(); root.settings-open = false; root.self-host-settings-open = false; } - } Rectangle { x: 18px; y: 56px; width: parent.width - 36px; height: 40px; background: #eaf0f6; border-radius: 9px; @@ -1995,7 +1997,7 @@ export component MainWindow inherits Window { } } if root.settings-tab == 0: settings-scroll := ScrollView { - x: 18px; y: 108px; width: parent.width - 28px; height: 272px; + x: 18px; y: 108px; width: parent.width - 28px; height: parent.height - 174px; viewport-y <=> root.settings-scroll-y; VerticalLayout { width: settings-scroll.width - 12px; @@ -2049,15 +2051,15 @@ export component MainWindow inherits Window { } } if root.settings-tab == 1: GallerySettings { - x: 18px; y: 108px; width: parent.width - 36px; height: 272px; + x: 18px; y: 108px; width: parent.width - 36px; height: parent.height - 174px; resolution-index <=> root.gallery-resolution-draft; frame-rate-index <=> root.gallery-frame-rate-draft; bandwidth-index <=> root.gallery-bandwidth-draft; error: root.gallery-settings-error; } - Rectangle { x: 18px; y: 382px; width: parent.width - 36px; height: 1px; background: #e6ebf2; } + Rectangle { x: 18px; y: parent.height - 64px; width: parent.width - 36px; height: 1px; background: #e6ebf2; } HorizontalLayout { - x: 18px; y: 396px; width: parent.width - 36px; height: 32px; spacing: 10px; + x: 18px; y: parent.height - 50px; width: parent.width - 36px; height: 32px; spacing: 10px; if root.settings-tab == 1: SettingsButton { width: 110px; quiet: true; text: GalleryStrings.tr("恢复默认", "Reset defaults", "По умолчанию"); diff --git a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm index 743de91d..b782e791 100644 --- a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm +++ b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm @@ -19,12 +19,11 @@ namespace crossdesk { namespace { -__weak NSWindow *main_window = nil; -bool main_fullscreen = false; -bool main_gallery = false; -NSRect main_restore_frame = NSZeroRect; -NSWindowStyleMask main_restore_style = NSWindowStyleMaskTitled; -NSApplicationPresentationOptions main_restore_presentation; +__weak NSWindow *gallery_window = nil; +bool gallery_fullscreen = false; +NSRect gallery_restore_frame = NSZeroRect; +NSWindowStyleMask gallery_restore_style = NSWindowStyleMaskTitled; +NSApplicationPresentationOptions gallery_restore_presentation; __weak NSWindow *stream_window = nil; __weak NSView *stream_slint_view = nil; CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil; @@ -118,9 +117,8 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) { NSButton *zoom_button = [window standardWindowButton:NSWindowZoomButton]; if (zoom_button == nil) return false; - main_window = window; - main_gallery = gallery; - if (main_fullscreen) return true; + if (gallery) gallery_window = window; + if (gallery && gallery_fullscreen) return true; if (gallery) window.styleMask |= NSWindowStyleMaskResizable; else @@ -149,63 +147,63 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) { } } -bool SetMainWindowFullscreen(bool fullscreen) { +bool SetGalleryWindowFullscreen(bool fullscreen) { @autoreleasepool { - if (!main_window || (fullscreen && !main_gallery)) return false; - if (main_fullscreen == fullscreen) return true; + if (!gallery_window) return false; + if (gallery_fullscreen == fullscreen) return true; if (fullscreen) { - NSScreen *screen = main_window.screen ?: NSScreen.mainScreen; + NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen; if (!screen) return false; - main_restore_frame = main_window.frame; - main_restore_style = main_window.styleMask; - main_restore_presentation = NSApp.presentationOptions; - main_fullscreen = true; - main_window.styleMask |= NSWindowStyleMaskFullSizeContentView; - main_window.titleVisibility = NSWindowTitleHidden; - main_window.movable = NO; - SetTitlebarButtonsHidden(main_window, YES); + gallery_restore_frame = gallery_window.frame; + gallery_restore_style = gallery_window.styleMask; + gallery_restore_presentation = NSApp.presentationOptions; + gallery_fullscreen = true; + gallery_window.styleMask |= NSWindowStyleMaskFullSizeContentView; + gallery_window.titleVisibility = NSWindowTitleHidden; + gallery_window.movable = NO; + SetTitlebarButtonsHidden(gallery_window, YES); NSApp.presentationOptions |= NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar; // Let the next event-loop frame paint the updated Slint layout. A // synchronous draw here exposes stale grid geometry during the resize. - [main_window setFrame:screen.frame display:NO animate:NO]; + [gallery_window setFrame:screen.frame display:NO animate:NO]; } else { - main_fullscreen = false; - NSApp.presentationOptions = main_restore_presentation; - main_window.styleMask = main_restore_style; - main_window.titleVisibility = NSWindowTitleVisible; - main_window.movable = YES; - [main_window standardWindowButton:NSWindowCloseButton].hidden = NO; - [main_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO; - // The main window uses the in-app fullscreen action in both modes. - HideMainWindowZoomButton(main_window); - [main_window setFrame:main_restore_frame display:NO animate:NO]; + gallery_fullscreen = false; + NSApp.presentationOptions = gallery_restore_presentation; + gallery_window.styleMask = gallery_restore_style; + gallery_window.titleVisibility = NSWindowTitleVisible; + gallery_window.movable = YES; + [gallery_window standardWindowButton:NSWindowCloseButton].hidden = NO; + [gallery_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO; + // DeskHub uses its in-app fullscreen action. + HideMainWindowZoomButton(gallery_window); + [gallery_window setFrame:gallery_restore_frame display:NO animate:NO]; } - main_window.contentView.needsDisplay = YES; + gallery_window.contentView.needsDisplay = YES; return true; } } -bool IsMainWindowFullscreen() { return main_fullscreen; } -bool IsMainWindowActive() { return main_window && [NSApp isActive] && [main_window isKeyWindow]; } +bool IsGalleryWindowFullscreen() { return gallery_fullscreen; } +bool IsGalleryWindowActive() { return gallery_window && [NSApp isActive] && [gallery_window isKeyWindow]; } -bool MaximizeMainWindowWithoutAnimation() { +bool MaximizeGalleryWindowWithoutAnimation() { @autoreleasepool { - if (!main_window || !main_gallery || main_fullscreen) return false; - NSScreen *screen = main_window.screen ?: NSScreen.mainScreen; + if (!gallery_window || gallery_fullscreen) return false; + NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen; if (!screen) return false; - [main_window setFrame:screen.visibleFrame display:YES animate:NO]; + [gallery_window setFrame:screen.visibleFrame display:YES animate:NO]; return true; } } -bool IsMainWindowMaximized() { +bool IsGalleryWindowMaximized() { @autoreleasepool { - if (!main_window || !main_gallery || main_fullscreen) return false; - NSScreen *screen = main_window.screen; + if (!gallery_window || gallery_fullscreen) return false; + NSScreen *screen = gallery_window.screen; if (!screen) return false; // isZoomed can report true for a constrained compact window. Only treat // the ordinary window as maximized when it fills the usable screen. - const NSRect frame = main_window.frame; + const NSRect frame = gallery_window.frame; const NSRect available = screen.visibleFrame; return std::abs(frame.origin.x - available.origin.x) < 1 && std::abs(frame.origin.y - available.origin.y) < 1 && diff --git a/apps/desktop/src/platform/window_drag.h b/apps/desktop/src/platform/window_drag.h index 61820e5d..9238f06b 100644 --- a/apps/desktop/src/platform/window_drag.h +++ b/apps/desktop/src/platform/window_drag.h @@ -8,20 +8,20 @@ namespace crossdesk { // left-mouse-down event. Returns false when no suitable native event exists. bool StartNativeWindowDrag(); -// Sets a white native titlebar and hides its zoom button in both main-window -// modes. DeskHub remains resizable. |slint_view| is its AppKit NSView*. +// Configures the connection or DeskHub window with a white native titlebar. +// Only DeskHub is resizable. |slint_view| is its AppKit NSView*. // Returns false while the native window or its titlebar controls are not ready. bool ConfigureMainWindowChrome(void* slint_view, bool gallery = false); // Winit's initial macOS position is a content origin, while subsequent moves // use the outer frame. Return the logical titlebar inset for initial placement. float MainWindowInitialPositionYOffset(); -bool SetMainWindowFullscreen(bool fullscreen); -bool IsMainWindowFullscreen(); -bool IsMainWindowActive(); +bool SetGalleryWindowFullscreen(bool fullscreen); +bool IsGalleryWindowFullscreen(); +bool IsGalleryWindowActive(); // AppKit's animated zoom spins a nested run loop and cannot be called from a // Slint timer. Restore maximization by setting the visible screen frame directly. -bool MaximizeMainWindowWithoutAnimation(); -bool IsMainWindowMaximized(); +bool MaximizeGalleryWindowWithoutAnimation(); +bool IsGalleryWindowMaximized(); // Configures live resize and replaces the stream window's native Space // fullscreen action with an immediate, single-window fullscreen transition. diff --git a/docs/deskhub.md b/docs/deskhub.md index e8260d31..44d8b7f7 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -1,6 +1,8 @@ # 桌面墙(DeskHub) -主窗口标题栏下方可以切换“连接”和“桌面墙”,同一行右侧保留纯文字“精简模式”按钮、全屏图标和菜单。设备管理栏集中显示分组、设备数量、“选择”和“添加”入口。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。 +主窗口“近期连接”标题行右侧、分组筛选左边的“桌面墙”按钮打开独立的桌面墙窗口。菜单与上方桌面标题共用一行,连接窗口保持紧凑尺寸。重复点击按钮会唤起已有桌面墙窗口,最小化时恢复显示,不创建重复窗口。每次启动软件只显示连接窗口;桌面墙首次打开时恢复保存的位置、尺寸及全屏状态;没有已保存尺寸时默认采用 960 × 720 逻辑像素(含标题栏,当前 macOS 的内容区为 960 × 688),两个窗口分别记忆位置。关闭桌面墙只隐藏该窗口并释放远程操控,保留设备连接;再次点击“桌面墙”即可打开。已断开的显示器所对应的位置会修正到可用屏幕内。 + +桌面墙窗口顶部显示“桌面墙”标题,右侧保留纯文字“精简模式”按钮和全屏图标,不显示菜单及消息、更新角标。设备管理栏集中显示分组、设备数量、“选择”和“添加”入口。桌面墙可以单独调整尺寸及全屏,不改变连接窗口的尺寸和位置。全局设置从主窗口菜单进入;设备操作和桌面墙连接的密码弹窗显示在桌面墙窗口内。 全屏按钮通过短暂淡出、等待窗口尺寸稳定、淡入完成切换,常规总时长约 0.2 秒;过渡期间屏蔽设备操作与重复全屏点击。窗口尺寸变化会立即更新网格,避免在新窗口中先显示旧的行列布局。过渡复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会恢复内容与交互。 @@ -12,19 +14,20 @@ - “添加 → 从近期连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。 - 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。 - “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。 -- 卡片名称栏只显示设备名,长名称省略,不再显示状态圆点。未在线的设备(离线或在线状态未知)整张卡片使用灰色,包括预览背景、边框和名称;在线但尚未连接的设备保持正常样式。选中边框、复选框和操作入口仍可用。悬停画面可查看完整名称、ID 和在线状态,精简模式也支持。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示未知,已有活动连接的设备仍显示在线。 +- 卡片名称栏只显示设备名,长名称省略,不再显示状态圆点。未在线的设备(离线或在线状态未知)整张卡片使用灰色,包括预览背景、边框和名称;在线但尚未连接的设备保持正常样式。选中边框、复选框和操作入口仍可用。悬停画面可查看设备名称、ID 和在线状态,精简模式也支持。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示未知,已有活动连接的设备仍显示在线。 - 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。 - 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“添加”菜单导入或按设备 ID 添加,才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。 - 近期连接卡片使用与桌面墙相同的离线灰色,不再在缩略图上叠加在线/离线标签。不在线时减淡历史缩略图并将名称栏置灰;没有缩略图时,平台占位也使用相同灰色。在线或已连接的设备正常显示,悬停仍可查看在线状态和操作设备。 - 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。 - “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。 -- 网格随窗口大小增加行列,紧凑窗口为 3 × 2;翻页不关闭连接。分组内顺序不会按在线状态自动重排。 -- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部固定宽度的文字按钮高亮显示“退出精简”,不带图标;全屏及菜单入口仍可见。最底部保留与连接页一致的独立服务器状态栏,其上方保留连接计数、网络详情入口和分页。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。 -- 菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,在连接页面以及桌面墙普通、精简、展开和全屏视图中均可打开。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。 -- 窗口最底部常驻与连接页一致的服务器状态栏,普通、精简、展开、批量选择和全屏视图均显示连接指示灯、服务器连接状态及断开时的重连入口。桌面墙操作栏位于其上方,显示已连接设备数量和“网络状态”;点击可打开当前已建立连接的设备列表。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 +- 网格中每台设备的预览区域统一固定为 16:9,普通模式的设备名称栏位于画面下方;其他比例的远端桌面留边完整显示。设备信息提示显示在该设备预览区域内部左下角,宽高受画面范围限制;过长的名称省略显示,保留 ID 和在线状态,不遮挡相邻设备或底部操作栏,精简模式及放大视图的缩略设备同样适用。设备直接放在连续表格的单元格中,单元格之间没有卡片间距,共用贯通整行、整列的单条分隔线;空单元格保留表格线,不再为每台设备绘制独立边框和右下角拖拽标记。拖动任意横向、竖向分隔线或交点,可同步放大、缩小全部单元格,预览区域始终保持 16:9;分隔线两侧的拖动方向一致。普通和精简模式共用单元格宽度,表格按实际尺寸自动换行、调整分页容量;窗口较小时限制格子大小,避免超出可用区域,窗口恢复后继续使用原尺寸。宽度至少为 184 逻辑像素、至多为 960 逻辑像素,并受当前可用区域限制;松开鼠标后保存尺寸,重启后恢复。拖动分隔线不会触发展开、选择或设备操作。翻页不关闭连接,分组内顺序不会按在线状态自动重排。 +- 按住单元格内的设备画面或名称拖动,可与另一台设备交换位置;目标单元格高亮,鼠标旁显示设备缩略图和松开提示。跨页时保持按住鼠标,将设备拖到窗口内容区左、右边缘内侧 32 逻辑像素范围,或上一页、下一页按钮,悬停 700 毫秒后翻页;边缘显示方向提示并在触发区域高亮,继续停留可连续翻页,到首尾页自动停止。翻页期间保留拖动源,末页设备不足一整页时也可继续拖动,移到目标设备后松开完成交换;离开翻页区域会重新计算悬停时间,在可翻页的边缘区域或按钮上松开只取消拖动,不会误与边缘下方的设备换位;窗口外不触发翻页。普通、精简及批量选择模式均支持,短距离点击仍执行原来的查看或选择操作。拖到自身、空单元格、分隔线或表格外不会换位;Esc、窗口失焦、切换分组或调整窗口大小会取消拖动。交换后的顺序保存到本地,并应用于所有分组视图,不修改分组、批量选择、连接或静音状态;保存失败时保持原顺序并提示重试。仅活动会话也可换位,排序不会自动将它加入保存的桌面墙成员。 +- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部固定宽度的文字按钮高亮显示“退出精简”,不带图标;全屏入口仍可见。底部保留连接计数、网络详情入口和分页,不显示服务器状态栏。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。 +- 主窗口菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,桌面墙处于普通、精简、展开或全屏视图时,也可通过主窗口菜单打开设置。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。 +- 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、往返延迟(RTT)、控制端接收与发送速率、视频丢包率、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 - 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 -- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看完整名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通模式缩略图栏高 90px,在保留画面大小的同时将空间留给主预览。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 +- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 - 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。 - 点击“选择”进入批量模式,“完成”退出;批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏中的已选数量和“全选/取消全选”使用固定宽度,切换选中数量或按钮文字时,后续按钮和分页保持原位。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接、断开、“更多”及分页控件在同一底栏显示;静音和文件分发收进“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。 @@ -36,7 +39,7 @@ 15 帧订阅使用编码前发送节流和稳定的逐显示流时间预算,并采用保持分辨率策略,避免低帧率触发交互视频的画质降级。30/60 帧选项使用编码器自身的交互帧率控制。仅向声明 `supports_deskhub_fps15` 的对端发送 `fps=15`;缺少该能力的 v1/v2 对端使用原有 `fps=60, frame_rate=30` 格式回退到 30 FPS,未声明预览能力的更旧版本保持原行为。协议仍接受旧控制端的 1/8 帧请求,但设置界面不再提供这两个档位。 -预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;外层圆角卡片与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。 +预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;表格单元格与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。 连接旧版被控端时,若仅声明 `supports_deskhub_preview`,控制端发送原有 v1 订阅;若未声明预览能力,则不发送该订阅,沿用其视频策略。旧控制端连接新版被控端时继续使用原分辨率;v1 订阅也会清除此前的尺寸限制。只有同时声明 `supports_deskhub_preview` 与 `supports_deskhub_resolution` 的被控端才会收到 v2 尺寸请求,旧版本不承诺发送端节省资源。 @@ -66,9 +69,9 @@ MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, he 连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。 -批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。 +批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态(含共享格子宽度)保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。 -`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,并通过可选的 `stream_settings` 保存分辨率预设及帧率。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。 +`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,通过可选的 `device_order` 保存手动排列顺序,并通过可选的 `stream_settings` 保存分辨率预设及帧率。没有手动顺序的旧配置继续使用原有排列。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。 批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。