mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 00:33:22 +08:00
[feat] add DeskHub device wall and shared group actions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,18 @@ private:
|
||||
void HandlePasswordChangeResult();
|
||||
void HandleCredentialRecovery();
|
||||
void SyncMainWindow();
|
||||
void ChangeMainMode(bool gallery);
|
||||
void ToggleGalleryFullscreen();
|
||||
void RestoreMainWindowGeometry(bool before_show = false);
|
||||
void SyncGallery();
|
||||
void GalleryAction(const std::string& action, const std::string& value);
|
||||
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 ReleaseControl();
|
||||
void SaveGalleryWindowState();
|
||||
void SyncAnnouncements();
|
||||
void SyncConnectionDialog();
|
||||
void SyncPlatformDialogs();
|
||||
|
||||
@@ -46,7 +46,8 @@ void ClipboardController::Shutdown() {
|
||||
pending_remote_text_.reset();
|
||||
}
|
||||
|
||||
void ClipboardController::QueueRemoteText(const char *data, size_t size) {
|
||||
void ClipboardController::QueueRemoteText(const char* data, size_t size,
|
||||
const std::string& remote_id) {
|
||||
if (!events_enabled_.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
@@ -62,6 +63,11 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto session =
|
||||
remote_id.empty() ? nullptr : owner_.FindRemoteSession(remote_id);
|
||||
if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load()))
|
||||
return;
|
||||
const auto generation = session ? session->input_generation_.load() : 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_mutex_);
|
||||
if (!events_enabled_.load(std::memory_order_relaxed)) {
|
||||
@@ -70,6 +76,9 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
|
||||
// Only the newest clipboard value matters. Replacing it also bounds the
|
||||
// amount of memory a busy or malicious peer can queue.
|
||||
pending_remote_text_ = std::string(data, size);
|
||||
pending_remote_id_ = remote_id;
|
||||
pending_session_ = session;
|
||||
pending_generation_ = generation;
|
||||
}
|
||||
|
||||
SDL_Event event{};
|
||||
@@ -87,9 +96,22 @@ void ClipboardController::ApplyPendingRemoteText() {
|
||||
}
|
||||
|
||||
std::optional<std::string> pending_text;
|
||||
std::string source_id;
|
||||
std::weak_ptr<gui_detail::RemoteSession> source_session;
|
||||
uint64_t generation = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_mutex_);
|
||||
pending_text.swap(pending_remote_text_);
|
||||
source_id.swap(pending_remote_id_);
|
||||
source_session.swap(pending_session_);
|
||||
generation = pending_generation_;
|
||||
}
|
||||
if (!source_id.empty()) {
|
||||
auto props = owner_.FindRemoteSession(source_id);
|
||||
if (!props || props != source_session.lock() ||
|
||||
!props->clipboard_allowed_.load() ||
|
||||
props->input_generation_.load() != generation)
|
||||
return;
|
||||
}
|
||||
if (!pending_text || *pending_text == last_text_) {
|
||||
return;
|
||||
@@ -145,7 +167,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
|
||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||
for (const auto &[remote_id, props] : owner_.remote_sessions_) {
|
||||
if (!props || !props->peer_ || !props->connection_established_ ||
|
||||
!props->enable_mouse_control_) {
|
||||
!props->clipboard_allowed_.load()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
struct RemoteSession;
|
||||
}
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class GuiRuntime;
|
||||
@@ -29,7 +34,8 @@ public:
|
||||
|
||||
void Initialize();
|
||||
void Shutdown();
|
||||
void QueueRemoteText(const char *data, size_t size);
|
||||
void QueueRemoteText(const char* data, size_t size,
|
||||
const std::string& remote_id = {});
|
||||
void ApplyPendingRemoteText();
|
||||
void HandleLocalUpdate();
|
||||
|
||||
@@ -41,6 +47,9 @@ private:
|
||||
std::atomic<bool> events_enabled_{false};
|
||||
std::mutex pending_mutex_;
|
||||
std::optional<std::string> pending_remote_text_;
|
||||
std::string pending_remote_id_;
|
||||
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
|
||||
uint64_t pending_generation_ = 0;
|
||||
std::string last_text_;
|
||||
};
|
||||
|
||||
|
||||
@@ -585,6 +585,10 @@ int SessionDeviceManager::DestroyAudioOutput() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::ClearAudio() {
|
||||
if (output_stream_) SDL_ClearAudioStream(output_stream_);
|
||||
}
|
||||
|
||||
void SessionDeviceManager::PushAudio(const char *data, size_t size) {
|
||||
if (!output_stream_) {
|
||||
return;
|
||||
|
||||
@@ -67,6 +67,7 @@ public:
|
||||
|
||||
int InitializeAudioOutput();
|
||||
int DestroyAudioOutput();
|
||||
void ClearAudio();
|
||||
void PushAudio(const char *data, size_t size);
|
||||
|
||||
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_FULLSCREEN_TRANSITION_H_
|
||||
#define _GALLERY_FULLSCREEN_TRANSITION_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
|
||||
// Driven by the existing UI tick; never pumps events or creates nested timers.
|
||||
class FullscreenTransition {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
enum class Action { Idle, Apply, Reveal, Finished };
|
||||
|
||||
bool Begin(bool fullscreen, Clock::time_point now) {
|
||||
if (Active())
|
||||
return false;
|
||||
target_ = fullscreen;
|
||||
phase_ = Phase::FadingOut;
|
||||
phase_at_ = now;
|
||||
width_ = height_ = 0;
|
||||
scale_ = 0;
|
||||
geometry_changed_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
Action Advance(Clock::time_point now, uint32_t width, uint32_t height,
|
||||
float scale, bool fullscreen, bool restoring) {
|
||||
using namespace std::chrono_literals;
|
||||
if (phase_ == Phase::FadingOut) {
|
||||
if (restoring && now - phase_at_ >= 1500ms) {
|
||||
Reveal(now);
|
||||
return Action::Reveal;
|
||||
}
|
||||
// Slint fades out for 60ms; allow another frame before native resizing.
|
||||
if (now - phase_at_ < 80ms || restoring)
|
||||
return Action::Idle;
|
||||
phase_ = Phase::Settling;
|
||||
phase_at_ = stable_at_ = now;
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
scale_ = scale;
|
||||
return Action::Apply;
|
||||
}
|
||||
if (phase_ == Phase::Settling) {
|
||||
const bool changed =
|
||||
width != width_ || height != height_ || scale != scale_;
|
||||
if (changed && width > 0 && height > 0)
|
||||
geometry_changed_ = true;
|
||||
if (changed || fullscreen != target_ || restoring) {
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
scale_ = scale;
|
||||
stable_at_ = now;
|
||||
}
|
||||
// Some backends report the requested flag before delivering new bounds.
|
||||
// Also allow fullscreen modes whose client dimensions genuinely match.
|
||||
if ((width > 0 && height > 0 && fullscreen == target_ && !restoring &&
|
||||
(geometry_changed_ || now - phase_at_ >= 300ms) &&
|
||||
now - stable_at_ >= 32ms) ||
|
||||
now - phase_at_ >= 1500ms) {
|
||||
Reveal(now);
|
||||
return Action::Reveal;
|
||||
}
|
||||
}
|
||||
if (phase_ == Phase::FadingIn && now - phase_at_ >= 80ms) {
|
||||
Cancel();
|
||||
return Action::Finished;
|
||||
}
|
||||
return Action::Idle;
|
||||
}
|
||||
|
||||
void Reveal(Clock::time_point now) {
|
||||
phase_ = Phase::FadingIn;
|
||||
phase_at_ = now;
|
||||
}
|
||||
void Cancel() { phase_ = Phase::Idle; }
|
||||
bool Active() const { return phase_ != Phase::Idle; }
|
||||
bool Target() const { return target_; }
|
||||
bool ContentVisible() const {
|
||||
return phase_ == Phase::Idle || phase_ == Phase::FadingIn;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Phase { Idle, FadingOut, Settling, FadingIn };
|
||||
Phase phase_ = Phase::Idle;
|
||||
bool target_ = false;
|
||||
bool geometry_changed_ = false;
|
||||
uint32_t width_ = 0, height_ = 0;
|
||||
float scale_ = 0;
|
||||
Clock::time_point phase_at_{}, stable_at_{};
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "gallery_storage.h"
|
||||
|
||||
#include <fstream>
|
||||
#ifdef _WIN32
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
namespace crossdesk::gallery {
|
||||
bool WriteAtomically(const std::filesystem::path &path,
|
||||
const std::string &text) {
|
||||
auto temporary = path;
|
||||
temporary += ".tmp";
|
||||
try {
|
||||
{
|
||||
std::ofstream output;
|
||||
output.exceptions(std::ios::failbit | std::ios::badbit);
|
||||
output.open(temporary, std::ios::binary | std::ios::trunc);
|
||||
output.write(text.data(), text.size());
|
||||
output.close();
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
return true;
|
||||
#else
|
||||
std::filesystem::rename(temporary, path);
|
||||
return true;
|
||||
#endif
|
||||
} catch (...) {
|
||||
}
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(temporary, ignored);
|
||||
return false;
|
||||
}
|
||||
} // namespace crossdesk::gallery
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_STORAGE_H_
|
||||
#define _GALLERY_STORAGE_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
bool WriteAtomically(const std::filesystem::path &path,
|
||||
const std::string &text);
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_WINDOW_STATE_H_
|
||||
#define _GALLERY_WINDOW_STATE_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
|
||||
struct WindowState {
|
||||
bool wall_fullscreen = false;
|
||||
bool wall_maximized = false;
|
||||
// Store client dimensions in logical pixels, independent of the display DPI.
|
||||
std::optional<slint::LogicalSize> wall_size;
|
||||
std::optional<slint::PhysicalPosition> wall_position, connection_position;
|
||||
|
||||
void BeginModeChange(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool fullscreen,
|
||||
bool maximized, float compact_height = 486) {
|
||||
// Never capture a half-applied mode transition as the user's wall geometry.
|
||||
Observe(!wall, position, size, scale, fullscreen, maximized,
|
||||
compact_height);
|
||||
if (wall) {
|
||||
// Modes share the current desktop location. An old wall position must
|
||||
// not undo a move made while the connection page was visible.
|
||||
if (connection_position)
|
||||
wall_position = connection_position;
|
||||
} else {
|
||||
// Fullscreen/maximized coordinates describe the screen, not the ordinary
|
||||
// window. Return to the last normal anchor before restoring compact size.
|
||||
if (wall_position)
|
||||
connection_position = wall_position;
|
||||
}
|
||||
BeginRestore();
|
||||
}
|
||||
|
||||
void Observe(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool fullscreen,
|
||||
bool maximized, float compact_height = 486) {
|
||||
if (Restoring())
|
||||
return;
|
||||
if (!wall) {
|
||||
connection_position = position;
|
||||
return;
|
||||
}
|
||||
if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height))
|
||||
return;
|
||||
scale = std::max(scale, 1.0f);
|
||||
wall_fullscreen = fullscreen;
|
||||
wall_maximized = !fullscreen && maximized;
|
||||
if (!fullscreen && !maximized) {
|
||||
wall_size = slint::LogicalSize(
|
||||
slint::Size<float>{size.width / scale, size.height / scale});
|
||||
wall_position = position;
|
||||
}
|
||||
}
|
||||
|
||||
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool visible,
|
||||
bool minimized, bool fullscreen, bool maximized,
|
||||
float compact_height = 486) {
|
||||
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
|
||||
// Leave those alone, and let an in-flight restoration retain its target.
|
||||
if (Restoring() || !visible || minimized || fullscreen || maximized ||
|
||||
size.width == 0 || size.height == 0 ||
|
||||
!IsUndersized(size, scale, compact_height))
|
||||
return false;
|
||||
if (wall) {
|
||||
wall_position = position;
|
||||
wall_fullscreen = false;
|
||||
wall_maximized = false;
|
||||
} else {
|
||||
connection_position = position;
|
||||
}
|
||||
// Keep the last valid logical size; only the current anchor is captured.
|
||||
BeginRestore();
|
||||
return true;
|
||||
}
|
||||
|
||||
slint::PhysicalSize RestoreSize(bool wall, float scale,
|
||||
float compact_height = 486) const {
|
||||
const float width =
|
||||
wall && wall_size ? std::max(640.0f, wall_size->width) : 640;
|
||||
const float height = wall && wall_size
|
||||
? std::max(compact_height, wall_size->height)
|
||||
: compact_height;
|
||||
scale = std::max(scale, 1.0f);
|
||||
return slint::PhysicalSize(
|
||||
slint::Size<uint32_t>{uint32_t(std::lround(width * scale)),
|
||||
uint32_t(std::lround(height * scale))});
|
||||
}
|
||||
|
||||
void BeginRestore() {
|
||||
phase_ = Phase::Normal;
|
||||
target_.reset();
|
||||
last_size_.reset();
|
||||
matches_ = 0;
|
||||
}
|
||||
|
||||
void PrepareForShow(slint::Window &window, bool wall,
|
||||
slint::PhysicalSize size,
|
||||
std::optional<slint::PhysicalPosition> position,
|
||||
float initial_y_offset = 0) {
|
||||
const float scale = std::max(window.scale_factor(), 1.0f);
|
||||
// The actual display scale is resolved when the native window is created.
|
||||
// Give the backend logical geometry so its first visible frame is correct.
|
||||
window.set_size(slint::LogicalSize(
|
||||
slint::Size<float>{size.width / scale, size.height / scale}));
|
||||
if (position) {
|
||||
(wall ? wall_position : connection_position) = position;
|
||||
window.set_position(slint::LogicalPosition(slint::Point<float>{
|
||||
position->x / scale, position->y / scale + initial_y_offset}));
|
||||
}
|
||||
}
|
||||
bool Restoring() const { return phase_ != Phase::Idle; }
|
||||
bool RestoringNormal() const { return phase_ == Phase::Normal; }
|
||||
|
||||
// A set_size request can complete asynchronously, after layout constraints
|
||||
// and the backing scale have changed. Acknowledge observations on later
|
||||
// ticks.
|
||||
bool NormalSizeRestored(slint::PhysicalSize actual,
|
||||
slint::PhysicalSize target) {
|
||||
if (!RestoringNormal())
|
||||
return false;
|
||||
if (target_ && SameSize(*target_, target) && SameSize(actual, target))
|
||||
++matches_;
|
||||
else
|
||||
matches_ = 0;
|
||||
target_ = target;
|
||||
if (matches_ < 2)
|
||||
return false;
|
||||
phase_ = Phase::Presentation;
|
||||
matches_ = 0;
|
||||
last_size_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FinishRestore(bool wall, slint::PhysicalSize actual, bool fullscreen,
|
||||
bool maximized) {
|
||||
if (phase_ != Phase::Presentation)
|
||||
return false;
|
||||
const bool want_fullscreen = wall && wall_fullscreen;
|
||||
const bool want_maximized = wall && !want_fullscreen && wall_maximized;
|
||||
const bool normal = !want_fullscreen && !want_maximized;
|
||||
if (normal && !fullscreen && !maximized && target_ &&
|
||||
!SameSize(actual, *target_)) {
|
||||
// A late constraint/DPI update can invalidate an earlier acknowledgement.
|
||||
// Reissue the normal size instead of getting stuck or saving that size.
|
||||
BeginRestore();
|
||||
return false;
|
||||
}
|
||||
const bool ready = fullscreen == want_fullscreen &&
|
||||
(want_fullscreen || maximized == want_maximized) &&
|
||||
(!normal || (target_ && SameSize(actual, *target_)));
|
||||
if (ready && last_size_ && SameSize(actual, *last_size_))
|
||||
++matches_;
|
||||
else
|
||||
matches_ = 0;
|
||||
last_size_ = actual;
|
||||
if (matches_ < 2)
|
||||
return false;
|
||||
phase_ = Phase::Idle;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Phase { Idle, Normal, Presentation };
|
||||
Phase phase_ = Phase::Idle;
|
||||
std::optional<slint::PhysicalSize> target_, last_size_;
|
||||
int matches_ = 0;
|
||||
static bool IsUndersized(slint::PhysicalSize size, float scale,
|
||||
float compact_height) {
|
||||
scale = std::max(scale, 1.0f);
|
||||
return double(size.width) + 1 < 640 * scale ||
|
||||
double(size.height) + 1 < compact_height * scale;
|
||||
}
|
||||
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
|
||||
return std::abs(double(a.width) - b.width) <= 1 &&
|
||||
std::abs(double(a.height) - b.height) <= 1;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_WORKSPACE_H_
|
||||
#define _GALLERY_WORKSPACE_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "gallery_storage.h"
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
struct Member {
|
||||
std::string id;
|
||||
std::string group;
|
||||
};
|
||||
|
||||
// Layout contains no credentials and never reconnects devices when loaded.
|
||||
struct Workspace {
|
||||
// Group assignments are shared by history and the wall. Wall enrollment is
|
||||
// independent, so assigning a group alone must never add a device to it.
|
||||
std::vector<Member> members;
|
||||
std::vector<std::string> groups;
|
||||
std::set<std::string> selected;
|
||||
int page = 0;
|
||||
int capacity = 6;
|
||||
|
||||
static std::string NormalizeId(const std::string &input) {
|
||||
std::string result;
|
||||
for (size_t i = 0; i < input.size();) {
|
||||
const auto ch = static_cast<unsigned char>(input[i]);
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (i + 2 < input.size() && ch == 0xEF &&
|
||||
static_cast<unsigned char>(input[i + 1]) == 0xBC &&
|
||||
static_cast<unsigned char>(input[i + 2]) >= 0x90 &&
|
||||
static_cast<unsigned char>(input[i + 2]) <= 0x99) {
|
||||
result += char('0' + static_cast<unsigned char>(input[i + 2]) - 0x90);
|
||||
i += 3;
|
||||
} else if (input.compare(i, 3, "\xe3\x80\x80") == 0)
|
||||
i += 3;
|
||||
else
|
||||
result += input[i++];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static bool ValidId(const std::string &id) {
|
||||
return id.size() == 9 &&
|
||||
std::all_of(id.begin(), id.end(),
|
||||
[](unsigned char c) { return c >= '0' && c <= '9'; });
|
||||
}
|
||||
bool Add(const std::string &id, const std::string &group = {}) {
|
||||
if (!ValidId(id))
|
||||
return false;
|
||||
const bool joined = wall_ids_.insert(id).second;
|
||||
// A device grouped earlier in history joins at the end when explicitly
|
||||
// added, while retaining its existing group assignments.
|
||||
if (joined)
|
||||
std::stable_partition(members.begin(), members.end(),
|
||||
[&](const auto &m) { return m.id != id; });
|
||||
if (group.empty() && HasAssignments(id))
|
||||
return joined;
|
||||
return AddAssignment(id, group) || joined;
|
||||
}
|
||||
std::vector<std::string> Ids(const std::string &group = {}) const {
|
||||
std::vector<std::string> ids;
|
||||
std::set<std::string> seen;
|
||||
for (const auto &m : members)
|
||||
if (wall_ids_.count(m.id) && (group.empty() || m.group == group) &&
|
||||
seen.insert(m.id).second)
|
||||
ids.push_back(m.id);
|
||||
return ids;
|
||||
}
|
||||
// History filtering uses group assignments, independently of wall enrollment.
|
||||
// nullopt means all devices; an empty name means no named group.
|
||||
bool MatchesGroup(const std::string &id,
|
||||
const std::optional<std::string> &group) const {
|
||||
if (!group)
|
||||
return true;
|
||||
if (group->empty())
|
||||
return std::none_of(members.begin(), members.end(), [&](const auto &m) {
|
||||
return m.id == id && !m.group.empty();
|
||||
});
|
||||
return std::any_of(members.begin(), members.end(), [&](const auto &m) {
|
||||
return m.id == id && m.group == *group;
|
||||
});
|
||||
}
|
||||
size_t AddMany(const std::vector<std::string> &ids,
|
||||
const std::string &group = {}) {
|
||||
size_t added = 0;
|
||||
for (const auto &id : ids) {
|
||||
// Without a target group, an existing membership already puts the device
|
||||
// on the wall. An explicit group may reuse that same device/session.
|
||||
if (group.empty() && wall_ids_.count(id))
|
||||
continue;
|
||||
if (Add(id, group))
|
||||
++added;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
// Both editors replace the same group assignments without changing wall
|
||||
// enrollment. Keep the position of devices already on the wall untouched.
|
||||
bool SetGroups(const std::string &id, const std::vector<std::string> &names) {
|
||||
if (!ValidId(id))
|
||||
return false;
|
||||
std::vector<Member> replacements;
|
||||
std::set<std::string> seen;
|
||||
for (const auto &name : names)
|
||||
if (!name.empty() && seen.insert(name).second)
|
||||
replacements.push_back({id, name});
|
||||
if (replacements.empty())
|
||||
replacements.push_back({id, ""});
|
||||
std::vector<Member> updated;
|
||||
bool inserted = false;
|
||||
for (const auto &member : members) {
|
||||
if (member.id != id) {
|
||||
updated.push_back(member);
|
||||
} else if (!inserted) {
|
||||
updated.insert(updated.end(), replacements.begin(), replacements.end());
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
if (!inserted)
|
||||
updated.insert(updated.end(), replacements.begin(), replacements.end());
|
||||
members = std::move(updated);
|
||||
for (const auto &member : replacements)
|
||||
if (!member.group.empty() &&
|
||||
std::find(groups.begin(), groups.end(), member.group) == groups.end())
|
||||
groups.push_back(member.group);
|
||||
PruneGroups();
|
||||
return true;
|
||||
}
|
||||
void Remove(const std::string &id, const std::string &group = {}) {
|
||||
if (group.empty()) {
|
||||
// Removing from the wall leaves history's group assignments intact.
|
||||
wall_ids_.erase(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.
|
||||
std::vector<std::string> remaining;
|
||||
for (const auto &member : members)
|
||||
if (member.id == id && member.group != group)
|
||||
remaining.push_back(member.group);
|
||||
SetGroups(id, remaining);
|
||||
}
|
||||
selected.erase(id);
|
||||
}
|
||||
void ResizePage(int new_capacity, int count) {
|
||||
const int first = page * capacity;
|
||||
capacity = std::max(1, new_capacity);
|
||||
page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity));
|
||||
}
|
||||
// The caller supplies every ID in the current filter, across all pages.
|
||||
void ToggleSelectAll(const std::vector<std::string> &ids) {
|
||||
std::set<std::string> targets(ids.begin(), ids.end());
|
||||
if (selected == targets)
|
||||
selected.clear();
|
||||
else
|
||||
selected = std::move(targets);
|
||||
}
|
||||
bool Load(const std::filesystem::path &path) {
|
||||
try {
|
||||
if (!std::filesystem::exists(path))
|
||||
return true;
|
||||
std::ifstream file(path);
|
||||
const auto doc = nlohmann::json::parse(file);
|
||||
const int version = doc.at("version").get<int>();
|
||||
if ((version != 1 && version != 2) || !doc.at("members").is_array())
|
||||
return false;
|
||||
if (version == 2 && !doc.at("wall_ids").is_array())
|
||||
return false;
|
||||
Workspace loaded;
|
||||
for (const auto &member : doc.at("members")) {
|
||||
const auto id = member.at("id").get<std::string>();
|
||||
loaded.AddAssignment(id, member.value("group", ""));
|
||||
// Every saved member in the old format was already on the wall.
|
||||
if (version == 1 && ValidId(id))
|
||||
loaded.wall_ids_.insert(id);
|
||||
}
|
||||
if (version == 2)
|
||||
for (const auto &value : doc.at("wall_ids")) {
|
||||
const auto id = value.get<std::string>();
|
||||
if (ValidId(id) && loaded.HasAssignments(id))
|
||||
loaded.wall_ids_.insert(id);
|
||||
}
|
||||
members = std::move(loaded.members);
|
||||
groups = std::move(loaded.groups);
|
||||
wall_ids_ = std::move(loaded.wall_ids_);
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool Save(const std::filesystem::path &path) const {
|
||||
try {
|
||||
nlohmann::json doc{{"version", 2},
|
||||
{"members", nlohmann::json::array()},
|
||||
{"wall_ids", wall_ids_}};
|
||||
for (const auto &m : members)
|
||||
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
|
||||
return WriteAtomically(path, doc.dump(2));
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::set<std::string> wall_ids_;
|
||||
|
||||
bool HasAssignments(const std::string &id) const {
|
||||
return std::any_of(members.begin(), members.end(),
|
||||
[&](const auto &m) { return m.id == id; });
|
||||
}
|
||||
bool AddAssignment(const std::string &id, const std::string &group) {
|
||||
if (!ValidId(id))
|
||||
return false;
|
||||
if (std::any_of(members.begin(), members.end(), [&](const auto &m) {
|
||||
return m.id == id && m.group == group;
|
||||
}))
|
||||
return false;
|
||||
if (!group.empty() &&
|
||||
std::find(groups.begin(), groups.end(), group) == groups.end())
|
||||
groups.push_back(group);
|
||||
members.push_back({id, group});
|
||||
return true;
|
||||
}
|
||||
void PruneGroups() {
|
||||
groups.erase(std::remove_if(groups.begin(), groups.end(),
|
||||
[&](const auto &name) {
|
||||
return std::none_of(members.begin(),
|
||||
members.end(),
|
||||
[&](const auto &m) {
|
||||
return m.group == name;
|
||||
});
|
||||
}),
|
||||
groups.end());
|
||||
}
|
||||
};
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -139,11 +139,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
std::string remote_user_id(user_id, user_id_size);
|
||||
auto props =
|
||||
runtime->FindRemoteSession(remote_user_id);
|
||||
if (props && !props->enable_mouse_control_) {
|
||||
if (props && !props->clipboard_allowed_.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime->clipboard_.QueueRemoteText(data, size);
|
||||
runtime->clipboard_.QueueRemoteText(
|
||||
data, size, props ? remote_user_id : std::string{});
|
||||
}
|
||||
return;
|
||||
} else if (source_id == runtime->file_feedback_label_) {
|
||||
|
||||
@@ -329,6 +329,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
props->net_traffic_stats_.Reset();
|
||||
props->enable_mouse_control_ = false;
|
||||
props->preview_subscription_supported_ = false;
|
||||
props->audio_allowed_ = false;
|
||||
props->clipboard_allowed_ = false;
|
||||
runtime->ResetRemoteServiceStatus(*props);
|
||||
{
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#include <libyuv.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -5,6 +8,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "nv12_scaler.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
@@ -46,6 +50,68 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
|
||||
if (props->connection_established_) {
|
||||
const auto timing_now = VideoLatencyFrame::Clock::now();
|
||||
// Convert only visible thumbnails, off the UI thread. A session retains
|
||||
// one latest RGBA image, not an unbounded queue of frames.
|
||||
const int preview_width = props->gallery_preview_width_.load();
|
||||
// Multiple video streams may decode concurrently for the same session.
|
||||
// Serialize access to the shared conversion buffers. Visible previews
|
||||
// consume incoming frames at the negotiated interactive cadence.
|
||||
std::unique_lock conversion_lock(props->gallery_conversion_mutex_,
|
||||
std::defer_lock);
|
||||
if (preview_width > 0) conversion_lock.lock();
|
||||
if (conversion_lock.owns_lock()) {
|
||||
const auto* native = GetNativeVideoFrame(video_frame);
|
||||
const int w = native ? native->width : video_frame->width;
|
||||
const int h = native ? native->height : video_frame->height;
|
||||
if (w > 0 && h > 0 && w <= 16384 && h <= 16384 && !(w % 2) && !(h % 2)) {
|
||||
const size_t bytes = static_cast<size_t>(w) * h * 3 / 2;
|
||||
const uint8_t* data =
|
||||
reinterpret_cast<const uint8_t*>(video_frame->data);
|
||||
if (native) {
|
||||
props->gallery_nv12_.resize(bytes);
|
||||
data = native->copy_to_nv12(native->owner,
|
||||
props->gallery_nv12_.data(), bytes) == 0
|
||||
? props->gallery_nv12_.data()
|
||||
: nullptr;
|
||||
} else if (video_frame->size < bytes)
|
||||
data = nullptr;
|
||||
if (data) {
|
||||
const double scale = std::min(
|
||||
{1.0, double(preview_width) / w, double(preview_width) / h});
|
||||
const int dw = std::max(2, int(w * scale) & ~1);
|
||||
const int dh = std::max(2, int(h * scale) & ~1);
|
||||
auto& scaled = props->gallery_scaled_;
|
||||
scaled.resize(static_cast<size_t>(dw) * dh * 3 / 2);
|
||||
if (ScaleNv12ViaI420(data, w, data + w * h, w, w, h, scaled.data(),
|
||||
dw, scaled.data() + dw * dh, dw, dw, dh,
|
||||
libyuv::kFilterBilinear,
|
||||
&props->gallery_scratch_) == 0) {
|
||||
auto rgba = std::make_shared<std::vector<unsigned char>>(
|
||||
static_cast<size_t>(dw) * dh * 4);
|
||||
if (libyuv::NV12ToABGR(scaled.data(), dw, scaled.data() + dw * dh,
|
||||
dw, rgba->data(), dw * 4, dw, dh) == 0) {
|
||||
std::lock_guard lock(props->gallery_frame_mutex_);
|
||||
props->gallery_rgba_ = std::move(rgba);
|
||||
props->gallery_width_ = dw;
|
||||
props->gallery_height_ = dh;
|
||||
props->gallery_frame_at_ = timing_now;
|
||||
++props->gallery_sequence_;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (conversion_lock.owns_lock()) conversion_lock.unlock();
|
||||
// A wall-only session does not need a second full-resolution CPU buffer.
|
||||
if (props->wall_managed_ && !props->tab_opened_) {
|
||||
{
|
||||
std::lock_guard lock(props->video_frame_mutex_);
|
||||
props->video_width_ = video_frame->width;
|
||||
props->video_height_ = video_frame->height;
|
||||
}
|
||||
props->streaming_ = true;
|
||||
return;
|
||||
}
|
||||
const auto timing = props->video_latency_->Frame(
|
||||
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_),
|
||||
timing_now);
|
||||
@@ -205,6 +271,10 @@ void PeerEventHandler::OnReceiveAudioBuffer(
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
|
||||
const auto session =
|
||||
runtime->FindRemoteSession(std::string(user_id, user_id_size));
|
||||
if (!session || !session->audio_allowed_.load()) return;
|
||||
runtime->audio_buffer_fresh_ = true;
|
||||
|
||||
runtime->devices_.PushAudio(data, size);
|
||||
|
||||
@@ -79,6 +79,18 @@ struct RemoteSession {
|
||||
uint32_t preview_request_ = 0;
|
||||
PreviewSubscription preview_requested_;
|
||||
std::chrono::steady_clock::time_point preview_request_at_{};
|
||||
std::atomic<bool> clipboard_allowed_{false};
|
||||
std::atomic<uint64_t> input_generation_{0};
|
||||
std::atomic<bool> audio_allowed_{false};
|
||||
std::atomic<int> gallery_preview_width_{0};
|
||||
std::mutex gallery_frame_mutex_;
|
||||
std::shared_ptr<std::vector<unsigned char>> gallery_rgba_;
|
||||
int gallery_width_ = 0, gallery_height_ = 0;
|
||||
uint64_t gallery_sequence_ = 0;
|
||||
std::chrono::steady_clock::time_point gallery_frame_at_{};
|
||||
// Decoder-thread scratch memory, never shared with the UI.
|
||||
std::mutex gallery_conversion_mutex_;
|
||||
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
||||
Params params_;
|
||||
PeerPtr* peer_ = nullptr;
|
||||
std::shared_ptr<PeerEventHandler> peer_events_;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#ifndef _RUNTIME_STATE_H_
|
||||
#define _RUNTIME_STATE_H_
|
||||
|
||||
#include <stream_names.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
@@ -19,10 +21,8 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <stream_names.h>
|
||||
|
||||
#include "config_center.h"
|
||||
#include "common/task_queue_lock_free.h"
|
||||
#include "config_center.h"
|
||||
#include "path_manager.h"
|
||||
#include "runtime/device_presence_cache.h"
|
||||
#include "runtime/preview_subscriptions.h"
|
||||
@@ -38,6 +38,7 @@ namespace crossdesk::gui_detail {
|
||||
|
||||
struct InfrastructureState {
|
||||
PreviewSubscriptions preview_subscriptions_;
|
||||
std::mutex audio_delivery_mutex_;
|
||||
std::unique_ptr<ConfigCenter> config_center_;
|
||||
ConfigCenter::LANGUAGE localization_language_ =
|
||||
ConfigCenter::LANGUAGE::CHINESE;
|
||||
|
||||
@@ -49,6 +49,7 @@ export global FontAwesomeIcons {
|
||||
out property <string> signal: "\u{f012}";
|
||||
out property <string> shield-halved: "\u{f3ed}";
|
||||
out property <string> square-full: "\u{f45c}";
|
||||
out property <string> tags: "\u{f02c}";
|
||||
out property <string> trash-can: "\u{f2ed}";
|
||||
out property <string> user-secret: "\u{f21b}";
|
||||
out property <string> volume-high: "\u{f028}";
|
||||
@@ -59,6 +60,7 @@ export global FontAwesomeIcons {
|
||||
export component IconButton inherits Rectangle {
|
||||
in property <string> icon;
|
||||
in property <string> tooltip;
|
||||
in property <bool> tooltip-above: false;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> danger: false;
|
||||
in property <length> icon-size: 16px;
|
||||
@@ -103,7 +105,7 @@ export component IconButton inherits Rectangle {
|
||||
|
||||
if touch.has-hover && root.tooltip != "": Rectangle {
|
||||
x: min(root.width - self.width, 0px);
|
||||
y: root.height + 3px;
|
||||
y: root.tooltip-above ? -self.height - 3px : root.height + 3px;
|
||||
width: tip.preferred-width + 12px;
|
||||
height: 24px;
|
||||
background: #22252be8;
|
||||
|
||||
@@ -4,3 +4,7 @@ export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
||||
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
|
||||
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
||||
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
|
||||
|
||||
export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
|
||||
export { GalleryStrings } from "device_strings.slint";
|
||||
export { DeviceGroupOption } from "device_group_dialog.slint";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
|
||||
export global DeviceCardStyle {
|
||||
out property <length> toolbar-width: 104px;
|
||||
}
|
||||
|
||||
component CardAction inherits IconButton {
|
||||
width: 26px; height: 23px;
|
||||
icon-size: 12px; icon-color: white;
|
||||
hover-background: #ffffff2e;
|
||||
pressed-background: #598cf273;
|
||||
tooltip-above: true;
|
||||
}
|
||||
|
||||
// Shared by recent connections and DeskHub; callers supply only behavior.
|
||||
export component DeviceCardActions inherits Rectangle {
|
||||
in property <string> remove-label;
|
||||
in property <bool> connected: false;
|
||||
callback edit-alias();
|
||||
callback edit-groups();
|
||||
callback remove-device();
|
||||
callback connect-device();
|
||||
callback disconnect-device();
|
||||
width: DeviceCardStyle.toolbar-width; height: 23px;
|
||||
background: #4a4e55dc; border-radius: 5px;
|
||||
HorizontalLayout {
|
||||
spacing: 0px;
|
||||
CardAction {
|
||||
icon: FontAwesomeIcons.pen;
|
||||
tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать");
|
||||
clicked => { root.edit-alias(); }
|
||||
}
|
||||
CardAction {
|
||||
icon: FontAwesomeIcons.tags;
|
||||
tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
|
||||
clicked => { root.edit-groups(); }
|
||||
}
|
||||
CardAction {
|
||||
icon: FontAwesomeIcons.trash-can;
|
||||
tooltip: root.remove-label;
|
||||
clicked => { root.remove-device(); }
|
||||
}
|
||||
CardAction {
|
||||
icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
|
||||
icon-size: 13px;
|
||||
tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить");
|
||||
clicked => {
|
||||
if root.connected { root.disconnect-device(); }
|
||||
else { root.connect-device(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
import { CompactButton, ImGuiCheckBox } from "common.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
|
||||
export struct DeviceGroupOption {
|
||||
name: string,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
export component DeviceGroupDialog inherits Rectangle {
|
||||
in property <string> device-name;
|
||||
in property <string> device-id;
|
||||
in property <[DeviceGroupOption]> options;
|
||||
in property <string> error;
|
||||
in-out property <string> new-group;
|
||||
callback toggle-group(string);
|
||||
callback save(string);
|
||||
callback cancel();
|
||||
background: #00000040;
|
||||
TouchArea {}
|
||||
dialog := Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(360px, parent.width - 40px);
|
||||
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
|
||||
background: white; border-radius: 8px;
|
||||
border-width: 1px; border-color: #b2b2b2;
|
||||
TouchArea {}
|
||||
Text {
|
||||
x: 18px; y: 16px; height: 24px;
|
||||
text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
|
||||
color: #24272d; font-size: 17px; font-weight: 600;
|
||||
}
|
||||
Text {
|
||||
x: 18px; y: 47px; width: parent.width - 36px; height: 18px;
|
||||
text: root.device-name; color: #30343b; font-size: 12px; overflow: elide;
|
||||
}
|
||||
Text {
|
||||
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
|
||||
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
|
||||
}
|
||||
group-list := Rectangle {
|
||||
x: 18px; y: 95px; width: parent.width - 36px;
|
||||
height: min(5, max(1, root.options.length)) * 32px + 2px;
|
||||
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px;
|
||||
ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width;
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
for option[index] in root.options: Rectangle {
|
||||
height: 32px;
|
||||
background: row-touch.has-hover ? #eef4fc : transparent;
|
||||
ImGuiCheckBox {
|
||||
x: 9px; y: 6px; width: 20px; height: 20px;
|
||||
checked: option.selected && (option.name != "" || root.new-group == "");
|
||||
}
|
||||
Text {
|
||||
x: 39px; width: parent.width - 48px; height: parent.height;
|
||||
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
|
||||
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LineEdit {
|
||||
x: 18px; y: group-list.y + group-list.height + 12px;
|
||||
width: parent.width - 36px; height: 30px;
|
||||
text <=> root.new-group;
|
||||
placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)");
|
||||
}
|
||||
if root.error != "": Text {
|
||||
x: 18px; y: group-list.y + group-list.height + 48px;
|
||||
width: parent.width - 36px; height: 32px;
|
||||
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
HorizontalLayout {
|
||||
x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px;
|
||||
spacing: 10px;
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } }
|
||||
CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export global GalleryStrings {
|
||||
in-out property <int> language: 0;
|
||||
public pure function tr(zh: string, en: string, ru: string) -> string {
|
||||
return self.language == 0 ? zh : self.language == 2 ? ru : en;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
|
||||
import { GroupFilter } from "group_filter.slint";
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
import { CompactButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
|
||||
|
||||
export struct GalleryTile {
|
||||
id: string,
|
||||
name: string,
|
||||
status: string,
|
||||
connected: bool,
|
||||
online: bool,
|
||||
presence-known: bool,
|
||||
stale: bool,
|
||||
selected: bool,
|
||||
has-frame: bool,
|
||||
frame: image,
|
||||
}
|
||||
export struct GalleryRecentDevice {
|
||||
id: string,
|
||||
name: string,
|
||||
selected: bool,
|
||||
online: bool,
|
||||
}
|
||||
export struct GalleryActionDevice {
|
||||
id: string,
|
||||
name: string,
|
||||
connected: bool,
|
||||
}
|
||||
export component GalleryView inherits Rectangle {
|
||||
in property <bool> controlling: false;
|
||||
// Pointer mapping must use the exact image viewport, including its inset.
|
||||
out property <float> video-width: video.width / 1px;
|
||||
out property <float> video-height: video.height / 1px;
|
||||
private property <bool> vertical-rail: focus-view.width > 840px;
|
||||
private property <int> rail-current-index: 0;
|
||||
changed rail-current-index => { root.reveal-current(); }
|
||||
changed width => { root.reveal-current(); }
|
||||
changed height => { root.reveal-current(); }
|
||||
function reveal-current() {
|
||||
if root.vertical-rail {
|
||||
rail.viewport-x = 0px;
|
||||
let top = root.rail-current-index * 100px;
|
||||
rail.viewport-y = clamp(rail.viewport-y, -top, rail.height - top - 92px);
|
||||
rail.viewport-y = clamp(rail.viewport-y, min(0px, rail.height - rail.viewport-height), 0px);
|
||||
} else {
|
||||
rail.viewport-y = 0px;
|
||||
let left = root.rail-current-index * 144px;
|
||||
rail.viewport-x = clamp(rail.viewport-x, -left, rail.width - left - 136px);
|
||||
rail.viewport-x = clamp(rail.viewport-x, min(0px, rail.width - rail.viewport-width), 0px);
|
||||
}
|
||||
}
|
||||
callback pointer-input(PointerEventButton, PointerEventKind, length, length);
|
||||
callback scroll-input(length, length, length, length);
|
||||
callback key-input(string, bool, bool, bool, bool, bool);
|
||||
in property <[GalleryTile]> tiles;
|
||||
in property <[string]> groups;
|
||||
in-out property <int> group-index: 0;
|
||||
in property <int> columns: 3;
|
||||
in property <int> rows: 2;
|
||||
in property <int> page: 0;
|
||||
in property <int> pages: 1;
|
||||
in property <int> count: 0;
|
||||
in property <int> connected-count: 0;
|
||||
in property <int> selected-count: 0;
|
||||
in property <int> hidden-selected: 0;
|
||||
in property <string> notice;
|
||||
in property <GalleryTile> focused;
|
||||
in-out property <bool> batch: false;
|
||||
in-out property <bool> adding: false;
|
||||
in-out property <bool> importing: false;
|
||||
in property <[GalleryRecentDevice]> recent-devices;
|
||||
in property <int> recent-selected: 0;
|
||||
in property <string> import-group;
|
||||
private property <bool> confirming: false;
|
||||
private property <string> action-name;
|
||||
private property <string> action-code;
|
||||
in property <[GalleryActionDevice]> selected-devices;
|
||||
callback action(string, string);
|
||||
callback add-device(string, string, string, bool) -> bool;
|
||||
callback import-recent(string) -> bool;
|
||||
callback edit-alias(string, string);
|
||||
callback edit-groups(string, string);
|
||||
callback remove-device(string);
|
||||
background: white;
|
||||
changed adding => { if root.adding { root.action("release", ""); } }
|
||||
changed importing => { root.action(root.importing ? "release" : "recent-close", ""); }
|
||||
public function reset-transient() { root.batch = false; root.adding = false; root.importing = false; root.confirming = false; }
|
||||
function confirm-action(code: string, name: string) {
|
||||
root.action-code = code; root.action-name = name; root.confirming = true;
|
||||
}
|
||||
toolbar := HorizontalLayout {
|
||||
x: 29px; y: 12px; width: parent.width - 58px; height: 24px;
|
||||
visible: root.focused.id == "";
|
||||
spacing: 8px;
|
||||
GroupFilter { width: 155px; height: 24px; system-count: 1; model: root.groups; current-index <=> root.group-index;
|
||||
selected(index) => { root.action("group", index); }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
CompactButton { width: 82px; primary: root.batch; text: root.batch ? GalleryStrings.tr("完成", "Done", "Готово") : GalleryStrings.tr("批量选择", "Select", "Выбрать"); clicked => { root.batch = !root.batch; root.action("selection", ""); } }
|
||||
CompactButton { width: 116px; text: GalleryStrings.tr("从最近连接添加", "Add from recent", "Из недавних"); clicked => { root.action("recent-open", ""); root.importing = true; } }
|
||||
CompactButton { width: 90px; primary: true; text: GalleryStrings.tr("添加设备", "Add device", "Добавить"); clicked => { root.action("add-open", ""); root.adding = true; } }
|
||||
}
|
||||
grid := Rectangle {
|
||||
x: toolbar.x; y: toolbar.y + toolbar.height + 12px; width: toolbar.width;
|
||||
height: footer.y - 12px - self.y;
|
||||
visible: root.focused.id == "";
|
||||
for tile[index] in root.tiles: Rectangle {
|
||||
x: mod(index, root.columns) * (grid.width + 12px) / root.columns;
|
||||
y: floor(index / root.columns) * (grid.height + 12px) / root.rows;
|
||||
width: (grid.width - (root.columns - 1) * 12px) / root.columns;
|
||||
height: (grid.height - (root.rows - 1) * 12px) / root.rows;
|
||||
border-radius: 6px; background: tile.selected ? #eaf3ff : #eff0f2;
|
||||
border-width: tile.selected ? 2px : 1px;
|
||||
border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : #c9cdd2;
|
||||
Rectangle {
|
||||
x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 36px;
|
||||
background: #24272d;
|
||||
if tile.has-frame: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; }
|
||||
if !tile.has-frame || !tile.connected || tile.stale: Text {
|
||||
text: tile.status; color: #ffffff; font-size: 12px;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
Text { x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
|
||||
presence-badge := Rectangle {
|
||||
visible: root.batch || !tile-touch.has-hover;
|
||||
x: parent.width - (root.batch ? 35px : 9px) - self.width; y: parent.height - 23px;
|
||||
width: presence-label.preferred-width + 12px; height: 18px;
|
||||
border-radius: 9px; background: tile.online ? #eaf6ee : #e2e5e9;
|
||||
presence-label := Text {
|
||||
x: 6px; height: parent.height;
|
||||
text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
|
||||
color: tile.online ? #17854c : #62666d; font-size: 10px; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
if root.batch: Rectangle {
|
||||
x: parent.width - 27px; y: parent.height - 23px;
|
||||
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;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
if tile.selected: Text {
|
||||
text: FontAwesomeIcons.check; color: white;
|
||||
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
tile-touch := TouchArea {
|
||||
clicked => { root.action(root.batch ? "select" : "focus", tile.id); }
|
||||
if tile-touch.has-hover && !root.batch: DeviceCardActions {
|
||||
x: parent.width - self.width - 6px; y: parent.height - 26px;
|
||||
remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub");
|
||||
connected: tile.connected;
|
||||
edit-alias => { root.action("release", ""); root.edit-alias(tile.id, tile.name); }
|
||||
edit-groups => { root.edit-groups(tile.id, tile.name); }
|
||||
remove-device => { root.action("release", ""); root.remove-device(tile.id); }
|
||||
connect-device => { root.action("connect", tile.id); }
|
||||
disconnect-device => { root.action("disconnect", tile.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.count == 0: Text {
|
||||
text: GalleryStrings.tr("添加设备,建立你的桌面墙", "Add devices to build your DeskHub", "Добавьте устройства в DeskHub");
|
||||
color: #808080; horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
focus-view := Rectangle {
|
||||
visible: root.focused.id != "";
|
||||
x: 29px; y: 7px; width: parent.width - 58px;
|
||||
height: parent.height - 51px;
|
||||
HorizontalLayout {
|
||||
x: 0px; y: 0px; width: parent.width; height: 28px; spacing: 8px;
|
||||
CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
|
||||
Rectangle {
|
||||
horizontal-stretch: 1; min-width: 0px;
|
||||
Text { x: 0px; width: parent.width; height: parent.height; text: root.focused.name; color: #303943; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; }
|
||||
}
|
||||
Rectangle {
|
||||
width: min(focus-state.preferred-width + 16px, 110px); height: 22px;
|
||||
border-radius: 5px;
|
||||
background: root.controlling ? #e7f1fd : #f0f2f5;
|
||||
focus-state := Text {
|
||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||
text: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
|
||||
: root.focused.connected && root.focused.has-frame && !root.focused.stale
|
||||
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
|
||||
font-size: 10px; color: root.controlling ? #3e77b3 : #6b7480; vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
|
||||
}
|
||||
Rectangle {
|
||||
x: 0px; y: 36px; width: root.vertical-rail ? parent.width - 158px : parent.width;
|
||||
height: root.vertical-rail ? parent.height - 36px : parent.height - 152px;
|
||||
background: #eff1f4; border-radius: 6px;
|
||||
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : #d5dbe3;
|
||||
video := Rectangle {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
background: #24272d;
|
||||
if root.focused.has-frame: Image {
|
||||
width: parent.width; height: parent.height; source: root.focused.frame; image-fit: contain;
|
||||
opacity: root.focused.stale || !root.focused.connected ? 0.4 : 1;
|
||||
}
|
||||
video-focus := FocusScope {
|
||||
property <bool> active-control: root.controlling;
|
||||
changed active-control => { if self.active-control { self.focus(); } }
|
||||
width: parent.width; height: parent.height;
|
||||
focus-on-click: true;
|
||||
focus-lost => { if root.controlling { root.action("release", ""); } }
|
||||
key-pressed(event) => {
|
||||
if root.controlling {
|
||||
if event.text == Key.Escape && event.modifiers.shift && (event.modifiers.control || event.modifiers.meta) {
|
||||
root.action("release", "");
|
||||
} else { root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); }
|
||||
}
|
||||
accept
|
||||
}
|
||||
key-released(event) => {
|
||||
if root.controlling { root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); }
|
||||
accept
|
||||
}
|
||||
TouchArea {
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down { video-focus.focus(); }
|
||||
if root.controlling { root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); }
|
||||
}
|
||||
scroll-event(event) => {
|
||||
if root.controlling { root.scroll-input(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); }
|
||||
accept
|
||||
}
|
||||
}
|
||||
}
|
||||
if !root.focused.has-frame || root.focused.stale || !root.focused.connected: Text { text: root.focused.status; color: white; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
}
|
||||
rail := ScrollView {
|
||||
x: root.vertical-rail ? parent.width - 148px : 0px;
|
||||
y: root.vertical-rail ? 36px : parent.height - 106px;
|
||||
width: root.vertical-rail ? 148px : parent.width;
|
||||
height: root.vertical-rail ? parent.height - 36px : 106px;
|
||||
// Reserve 14px below or 12px beside cards for the scrollbar.
|
||||
viewport-width: root.vertical-rail ? self.width : max(self.width, root.tiles.length * 144px - 8px);
|
||||
viewport-height: root.vertical-rail ? max(self.height, root.tiles.length * 100px - 8px) : self.height;
|
||||
horizontal-scrollbar-policy: root.vertical-rail ? always-off : as-needed;
|
||||
vertical-scrollbar-policy: root.vertical-rail ? as-needed : always-off;
|
||||
for tile[index] in root.tiles: Rectangle {
|
||||
private property <bool> current: tile.id == root.focused.id;
|
||||
init => { if self.current { root.rail-current-index = index; root.reveal-current(); } }
|
||||
changed current => { if self.current { root.rail-current-index = index; root.reveal-current(); } }
|
||||
x: root.vertical-rail ? 0px : index * 144px;
|
||||
y: root.vertical-rail ? index * 100px : 0px;
|
||||
width: 136px; height: 92px; border-radius: 5px;
|
||||
background: tile.id == root.focused.id ? #edf5ff : #f5f6f8;
|
||||
border-width: tile.id == root.focused.id ? 2px : 1px;
|
||||
border-color: tile.id == root.focused.id ? #76aeea : #d5dbe3;
|
||||
Rectangle {
|
||||
x: 5px; y: 5px; width: 126px; height: 52px; background: #24272d;
|
||||
if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; }
|
||||
if !tile.has-frame || tile.stale || !tile.connected: Text {
|
||||
text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
Text { x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; }
|
||||
Text {
|
||||
x: 7px; y: 78px; width: 86px; height: 12px; font-size: 9px;
|
||||
text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
|
||||
color: tile.online ? #17854c : #7a828d; overflow: elide;
|
||||
}
|
||||
if tile.id == root.focused.id: Text {
|
||||
x: 99px; y: 78px; width: 30px; height: 12px;
|
||||
text: GalleryStrings.tr("当前", "Current", "Выбрано"); color: #4d83bd; font-size: 9px; horizontal-alignment: right; overflow: elide;
|
||||
}
|
||||
TouchArea { clicked => { root.action("focus", tile.id); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reuse the footer for selection actions so entering batch mode never
|
||||
// changes card geometry or moves a device beneath the pointer.
|
||||
footer := HorizontalLayout {
|
||||
x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: 12px;
|
||||
if root.batch: CompactButton {
|
||||
width: 90px; enabled: root.count > 0;
|
||||
text: root.count > 0 && root.selected-count == root.count
|
||||
? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор")
|
||||
: GalleryStrings.tr("全选", "Select all", "Выбрать все");
|
||||
clicked => { root.action("all-select", ""); }
|
||||
}
|
||||
Rectangle {
|
||||
horizontal-stretch: 1; min-width: 0px;
|
||||
Text {
|
||||
y: 0px; width: parent.width; height: root.batch && root.hidden-selected > 0 ? 13px : parent.height;
|
||||
text: root.batch ? GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count : root.connected-count + " / " + root.count + GalleryStrings.tr(" 台已连接", " connected", " подключено");
|
||||
color: #62666d; vertical-alignment: center; font-size: 12px; overflow: elide;
|
||||
}
|
||||
if root.batch && root.hidden-selected > 0: Text {
|
||||
y: 14px; width: parent.width; height: 10px;
|
||||
text: GalleryStrings.tr("跨页 ", "Other pages ", "Другие страницы ") + root.hidden-selected;
|
||||
color: #80858d; font-size: 10px; overflow: elide;
|
||||
}
|
||||
}
|
||||
if root.batch: HorizontalLayout {
|
||||
width: 214px; spacing: 8px;
|
||||
CompactButton { width: 58px; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } }
|
||||
CompactButton { width: 62px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
|
||||
CompactButton { width: 78px; enabled: root.selected-count > 0; text: GalleryStrings.tr("分发文件", "Send file", "Файл"); clicked => { root.confirm-action("file", self.text); } }
|
||||
}
|
||||
HorizontalLayout {
|
||||
width: 112px; height: 24px; spacing: 6px;
|
||||
CompactButton { width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
|
||||
Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; }
|
||||
CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
|
||||
}
|
||||
}
|
||||
if root.adding: Rectangle {
|
||||
background: #00000042;
|
||||
TouchArea {}
|
||||
add-dialog := Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
|
||||
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
|
||||
function submit() {
|
||||
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
|
||||
password.text = "";
|
||||
root.adding = false;
|
||||
}
|
||||
}
|
||||
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; }
|
||||
Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; }
|
||||
device-id := LineEdit {
|
||||
x: 96px; y: 56px; width: 286px; height: 30px;
|
||||
placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр");
|
||||
input-type: InputType.number;
|
||||
accepted => { password.focus(); }
|
||||
}
|
||||
Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; }
|
||||
password := LineEdit {
|
||||
x: 96px; y: 100px; width: 286px; height: 30px;
|
||||
placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства");
|
||||
input-type: InputType.password;
|
||||
accepted => { group.focus(); }
|
||||
}
|
||||
Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; }
|
||||
group := LineEdit {
|
||||
x: 96px; y: 144px; width: 286px; height: 30px;
|
||||
placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)");
|
||||
accepted => { add-dialog.submit(); }
|
||||
}
|
||||
remember := ImGuiCheckBox { x: 96px; y: 188px; }
|
||||
Text { x: 123px; y: 188px; height: 21px; text: GalleryStrings.tr("记住密码", "Remember password", "Запомнить пароль"); font-size: 12px; vertical-alignment: center;
|
||||
TouchArea { clicked => { remember.checked = !remember.checked; } }
|
||||
}
|
||||
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
|
||||
CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
|
||||
CompactButton {
|
||||
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
|
||||
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
|
||||
enabled: device-id.text != "" && password.text != "";
|
||||
clicked => { add-dialog.submit(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.importing: Rectangle {
|
||||
background: #00000042;
|
||||
TouchArea {}
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
|
||||
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
|
||||
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
|
||||
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
|
||||
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-devices.length > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-devices.length; }
|
||||
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); font-size: 12px; vertical-alignment: center; }
|
||||
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-devices.length > 0; clicked => { root.action("recent-all", ""); } }
|
||||
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.recent-selected + " / " + root.recent-devices.length; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
|
||||
Rectangle {
|
||||
x: 18px; y: 102px; width: 404px; height: 160px;
|
||||
border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true;
|
||||
recent-list := ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width - 12px;
|
||||
viewport-height: max(self.height, root.recent-devices.length * 44px);
|
||||
for device[index] in root.recent-devices: Rectangle {
|
||||
y: index * 44px; width: recent-list.viewport-width; height: 44px;
|
||||
background: device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white;
|
||||
ImGuiCheckBox { x: 8px; y: 11px; checked: device.selected; }
|
||||
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; font-size: 12px; overflow: elide; vertical-alignment: center; }
|
||||
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: #808080; }
|
||||
Rectangle { x: parent.width - 74px; y: 19px; width: 6px; height: 6px; border-radius: 3px; background: device.online ? #21b567 : #92969e; }
|
||||
Text {
|
||||
x: parent.width - 61px; y: 12px; width: 57px; height: 20px;
|
||||
text: device.online ? GalleryStrings.tr("在线", "Online", "В сети") : GalleryStrings.tr("离线", "Offline", "Не в сети");
|
||||
color: device.online ? #17854c : #808080; font-size: 11px; vertical-alignment: center;
|
||||
}
|
||||
row-touch := TouchArea { clicked => { root.action("recent-select", device.id); } }
|
||||
}
|
||||
}
|
||||
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
|
||||
import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
|
||||
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
|
||||
CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
|
||||
CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
|
||||
}
|
||||
}
|
||||
if root.confirming: Rectangle {
|
||||
background: #00000042; TouchArea {}
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
|
||||
width: 360px; height: confirmation-list.height + 118px;
|
||||
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
|
||||
Text {
|
||||
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
|
||||
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
|
||||
color: #24272d; font-size: 16px; font-weight: 600; vertical-alignment: center;
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
|
||||
background: #eff3f8; border-radius: 11px;
|
||||
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
confirmation-list := Rectangle {
|
||||
x: 20px; y: 58px; width: parent.width - 40px;
|
||||
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
|
||||
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true;
|
||||
confirmation-scroll := ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width - 12px;
|
||||
viewport-height: max(self.height, root.selected-devices.length * 52px);
|
||||
for device[index] in root.selected-devices: Rectangle {
|
||||
y: index * 52px; width: confirmation-scroll.viewport-width; height: 52px;
|
||||
Text { x: 12px; y: 8px; width: parent.width - 32px - connection-badge.width; height: 18px; text: device.name; color: #303740; font-size: 12px; font-weight: 600; overflow: elide; vertical-alignment: center; }
|
||||
Text { x: 12px; y: 29px; width: parent.width - 32px - connection-badge.width; height: 16px; text: "ID " + device.id; color: #8a929e; font-size: 11px; vertical-alignment: center; }
|
||||
connection-badge := Rectangle {
|
||||
x: parent.width - self.width - 12px; y: 16px;
|
||||
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
|
||||
background: device.connected ? #eaf6ee : #f0f2f5;
|
||||
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
|
||||
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
|
||||
}
|
||||
if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; }
|
||||
}
|
||||
}
|
||||
}
|
||||
CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
|
||||
CompactButton {
|
||||
x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true;
|
||||
text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить");
|
||||
clicked => { root.confirming = false; root.action(root.action-code, ""); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons } from "common.slint";
|
||||
|
||||
// Shared by history and DeskHub; system choices precede the named groups.
|
||||
export component GroupFilter inherits Rectangle {
|
||||
in property <[string]> model;
|
||||
in property <int> system-count: 2;
|
||||
in property <bool> enabled: true;
|
||||
in-out property <int> current-index: 0;
|
||||
callback selected(int);
|
||||
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
|
||||
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
|
||||
private property <length> visible-height: min(root.list-height, 162px);
|
||||
private property <length> scroll-y: 0px;
|
||||
private property <bool> popup-animation-enabled: false;
|
||||
private property <float> popup-progress: 0;
|
||||
|
||||
animate popup-progress {
|
||||
duration: 140ms; easing: ease-out;
|
||||
enabled: root.popup-animation-enabled;
|
||||
}
|
||||
|
||||
width: 155px; height: 28px;
|
||||
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
|
||||
border-width: 1px;
|
||||
border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2;
|
||||
border-radius: 4px;
|
||||
animate background, border-color { duration: 100ms; easing: ease-out; }
|
||||
forward-focus: trigger-focus;
|
||||
accessible-role: combobox;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-expandable: true;
|
||||
accessible-expanded: popup.is-open;
|
||||
accessible-value: root.model[root.current-index];
|
||||
|
||||
function reveal-selection() {
|
||||
let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px);
|
||||
if top < -root.scroll-y { root.scroll-y = -top; }
|
||||
if top + 26px > -root.scroll-y + root.visible-height {
|
||||
root.scroll-y = root.visible-height - top - 26px;
|
||||
}
|
||||
root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px);
|
||||
}
|
||||
function choose(index: int) {
|
||||
if !root.enabled || root.model.length == 0 { return; }
|
||||
root.current-index = clamp(index, 0, root.model.length - 1);
|
||||
root.reveal-selection();
|
||||
root.selected(root.current-index);
|
||||
}
|
||||
function open-menu() {
|
||||
if root.enabled && root.model.length > 0 {
|
||||
popup-open-timer.running = false;
|
||||
root.popup-animation-enabled = false;
|
||||
root.popup-progress = 0;
|
||||
root.reveal-selection();
|
||||
popup.show();
|
||||
popup-open-timer.running = true;
|
||||
}
|
||||
}
|
||||
// Defer until the popup has its initial frame; animate its contents only,
|
||||
// keeping the native popup bounds and scrolling geometry stable.
|
||||
popup-open-timer := Timer {
|
||||
interval: 16ms;
|
||||
running: false;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
if popup.is-open {
|
||||
root.popup-animation-enabled = true;
|
||||
root.popup-progress = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function handle-key(event: KeyEvent) -> EventResult {
|
||||
if !root.enabled { return reject; }
|
||||
if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; }
|
||||
if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; }
|
||||
if event.text == Key.Return || event.text == " " {
|
||||
if popup.is-open { popup.close(); } else { root.open-menu(); }
|
||||
return accept;
|
||||
}
|
||||
if event.text == Key.Escape && popup.is-open { popup.close(); return accept; }
|
||||
return reject;
|
||||
}
|
||||
Text {
|
||||
x: 10px; width: parent.width - 36px; height: parent.height;
|
||||
text: root.model[root.current-index];
|
||||
color: root.enabled ? #30343b : #92979f;
|
||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text {
|
||||
x: parent.width - 25px; width: 16px; height: parent.height;
|
||||
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900; font-size: 10px; color: #858c96;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
transform-rotation: popup.is-open ? 180deg : 0deg;
|
||||
animate transform-rotation { duration: 140ms; easing: ease-out; }
|
||||
accessible-role: none;
|
||||
}
|
||||
trigger-focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
trigger-touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => { trigger-focus.focus(); root.open-menu(); }
|
||||
}
|
||||
}
|
||||
popup := PopupWindow {
|
||||
x: 0px; y: root.height + 4px;
|
||||
width: root.width; height: root.visible-height + 8px;
|
||||
close-policy: close-on-click-outside;
|
||||
forward-focus: popup-focus;
|
||||
popup-focus := FocusScope {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
Rectangle {
|
||||
y: (1 - root.popup-progress) * -4px;
|
||||
width: parent.width; height: parent.height;
|
||||
opacity: root.popup-progress;
|
||||
background: white; border-width: 1px;
|
||||
border-color: #c9cdd2; border-radius: 5px;
|
||||
ScrollView {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
viewport-width: self.width;
|
||||
viewport-height: root.list-height;
|
||||
viewport-y <=> root.scroll-y;
|
||||
Rectangle {
|
||||
width: parent.width; height: root.list-height;
|
||||
if root.has-divider: Rectangle {
|
||||
x: 6px; y: root.system-count * 26px + 3px;
|
||||
width: parent.width - 12px; height: 1px; background: #e9ebee;
|
||||
}
|
||||
for choice[index] in root.model: Rectangle {
|
||||
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
|
||||
width: parent.width; height: 26px; border-radius: 3px;
|
||||
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
|
||||
animate background { duration: 100ms; easing: ease-out; }
|
||||
accessible-role: list-item;
|
||||
accessible-label: choice;
|
||||
Text {
|
||||
x: 8px; width: parent.width - 34px; height: parent.height;
|
||||
text: choice; color: #30343b; font-size: 12px;
|
||||
overflow: elide; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if index == root.current-index: Text {
|
||||
x: parent.width - 24px; width: 16px; height: parent.height;
|
||||
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900; font-size: 10px; color: #4e87c0;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
option-touch := TouchArea {
|
||||
clicked => { root.choose(index); popup.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
|
||||
import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint";
|
||||
import { GalleryView, GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
|
||||
import { GroupFilter } from "group_filter.slint";
|
||||
import { LineEdit, ScrollView } from "std-widgets.slint";
|
||||
import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint";
|
||||
import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
||||
@@ -6,7 +11,9 @@ export struct RecentConnection {
|
||||
remote-id: string,
|
||||
display-name: string,
|
||||
host-name: string,
|
||||
group-names: string,
|
||||
online: bool,
|
||||
connected: bool,
|
||||
thumbnail: image,
|
||||
has-thumbnail: bool,
|
||||
platform: string,
|
||||
@@ -286,20 +293,86 @@ export component MainWindow inherits Window {
|
||||
title: "CrossDesk";
|
||||
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||
preferred-width: 640px;
|
||||
preferred-height: 450px + root.titlebar-height;
|
||||
preferred-height: 486px + root.titlebar-height;
|
||||
min-width: 640px;
|
||||
max-width: 640px;
|
||||
min-height: 450px + root.titlebar-height;
|
||||
max-height: 450px + root.titlebar-height;
|
||||
max-width: root.gallery-mode ? 16384px : 640px;
|
||||
min-height: 486px + root.titlebar-height;
|
||||
max-height: root.gallery-mode ? 16384px : 486px + root.titlebar-height;
|
||||
no-frame: root.custom-titlebar;
|
||||
background: root.wayland-titlebar ? transparent : white;
|
||||
default-font-size: ImGuiFontStyle.base;
|
||||
|
||||
in-out property <bool> gallery-mode: false;
|
||||
in property <bool> gallery-fullscreen: false;
|
||||
in property <bool> gallery-transitioning: false;
|
||||
in property <bool> gallery-content-visible: true;
|
||||
changed gallery-transitioning => {
|
||||
if root.gallery-transitioning {
|
||||
focus-sink.focus();
|
||||
root.menu-open = false;
|
||||
root.recent-tooltip-visible = false;
|
||||
}
|
||||
}
|
||||
in-out property <string> connection-input-error;
|
||||
in property <bool> gallery-controlling: false;
|
||||
out property <float> gallery-video-width: gallery.video-width;
|
||||
out property <float> gallery-video-height: gallery.video-height;
|
||||
callback gallery-pointer(PointerEventButton, PointerEventKind, length, length);
|
||||
callback gallery-scroll(length, length, length, length);
|
||||
callback gallery-key(string, bool, bool, bool, bool, bool);
|
||||
in property <[GalleryTile]> gallery-tiles;
|
||||
in property <[string]> gallery-groups;
|
||||
in-out property <int> gallery-group-index: 0;
|
||||
in property <int> gallery-columns: 3;
|
||||
in property <int> gallery-rows: 2;
|
||||
in property <int> gallery-page: 0;
|
||||
in property <int> gallery-pages: 1;
|
||||
in property <int> gallery-count: 0;
|
||||
in property <int> gallery-connected: 0;
|
||||
in property <int> gallery-selected: 0;
|
||||
in property <int> gallery-hidden-selected: 0;
|
||||
in property <string> gallery-notice;
|
||||
in property <[GalleryActionDevice]> gallery-selected-devices;
|
||||
in property <GalleryTile> gallery-focused;
|
||||
in-out property <bool> gallery-batch: false;
|
||||
in-out property <bool> gallery-adding: false;
|
||||
in-out property <bool> gallery-importing: false;
|
||||
in property <[GalleryRecentDevice]> gallery-recent-devices;
|
||||
in property <int> gallery-recent-selected: 0;
|
||||
in property <string> gallery-import-group;
|
||||
out property <float> gallery-width: root.width / 1px;
|
||||
out property <float> gallery-height: gallery.height / 1px;
|
||||
callback change-mode(bool);
|
||||
callback toggle-gallery-fullscreen();
|
||||
callback gallery-action(string, string);
|
||||
callback gallery-add-device(string, string, string, bool) -> bool;
|
||||
callback gallery-import-recent(string) -> bool;
|
||||
in-out property <bool> device-group-open: false;
|
||||
private property <string> device-group-id;
|
||||
private property <string> device-group-name;
|
||||
in property <[DeviceGroupOption]> device-group-options;
|
||||
in property <string> device-group-error;
|
||||
in-out property <string> device-group-new-name;
|
||||
callback device-group-action(string, string);
|
||||
callback device-group-save(string) -> bool;
|
||||
function open-device-groups(id: string, name: string) {
|
||||
root.gallery-action("release", "");
|
||||
focus-sink.focus();
|
||||
root.recent-tooltip-visible = false;
|
||||
root.device-group-id = id;
|
||||
root.device-group-name = name;
|
||||
root.device-group-new-name = "";
|
||||
root.device-group-action("open", id);
|
||||
root.device-group-open = true;
|
||||
}
|
||||
|
||||
public function reset-gallery-transient() { gallery.reset-transient(); root.menu-open = false; }
|
||||
in property <bool> custom-titlebar: false;
|
||||
in property <bool> wayland-titlebar: false;
|
||||
in property <bool> window-active: true;
|
||||
private property <length> titlebar-height:
|
||||
root.custom-titlebar ? (root.wayland-titlebar ? 32px : 31px) : 0px;
|
||||
root.custom-titlebar && !root.gallery-fullscreen ? (root.wayland-titlebar ? 32px : 31px) : 0px;
|
||||
private property <length> content-header-height: 38px;
|
||||
private property <length> wayland-corner-radius: 12px;
|
||||
in property <string> local-id: "";
|
||||
in property <string> local-password: "";
|
||||
@@ -310,6 +383,13 @@ export component MainWindow inherits Window {
|
||||
in property <bool> signal-reconnecting: false;
|
||||
in property <bool> signal-tls-error: false;
|
||||
in property <[RecentConnection]> recent-connections;
|
||||
in property <[string]> recent-groups: [GalleryStrings.tr("全部设备", "All devices", "Все устройства"), GalleryStrings.tr("未分组", "Ungrouped", "Без группы")];
|
||||
in-out property <int> recent-group-index: 0;
|
||||
callback recent-group-selected(int);
|
||||
changed recent-group-index => {
|
||||
root.recent-tooltip-visible = false;
|
||||
recent-scroll.viewport-x = 0px;
|
||||
}
|
||||
in property <[AnnouncementItem]> announcements;
|
||||
in property <int> announcement-unread;
|
||||
private property <string> announcement-unread-label:
|
||||
@@ -389,13 +469,14 @@ export component MainWindow inherits Window {
|
||||
// as disabled so the fixed settings layout never turns into a blank row.
|
||||
in property <bool> hardware-codec-available: true;
|
||||
|
||||
callback copy-local-id;
|
||||
callback copy-local-id() -> bool;
|
||||
callback toggle-password-visibility;
|
||||
callback refresh-password;
|
||||
callback reset-password(string) -> bool;
|
||||
callback format-remote-id(string) -> string;
|
||||
callback connect-requested(string);
|
||||
callback recent-connect(string);
|
||||
callback recent-disconnect(string);
|
||||
callback recent-edit-alias(string, string);
|
||||
callback recent-delete(string);
|
||||
callback save-settings() -> bool;
|
||||
@@ -439,29 +520,28 @@ export component MainWindow inherits Window {
|
||||
in-out property <string> alias-input: "";
|
||||
in-out property <bool> delete-open: false;
|
||||
private property <string> delete-remote-id: "";
|
||||
private property <bool> delete-from-gallery: false;
|
||||
private property <bool> copy-toast: false;
|
||||
private property <bool> compact-language: root.language-index == 0;
|
||||
private property <bool> recent-tooltip-visible: false;
|
||||
private property <string> recent-tooltip-name: "";
|
||||
private property <string> recent-tooltip-host: "";
|
||||
private property <string> recent-tooltip-id: "";
|
||||
private property <string> recent-tooltip-groups: "";
|
||||
private property <bool> recent-tooltip-online: false;
|
||||
private property <length> recent-tooltip-pointer-x: 0px;
|
||||
private property <length> recent-tooltip-pointer-y: 0px;
|
||||
private property <length> recent-tooltip-width: 260px;
|
||||
private property <length> recent-tooltip-height:
|
||||
root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name
|
||||
? 80px : 64px;
|
||||
private property <length> recent-tooltip-x: min(
|
||||
root.width - root.recent-tooltip-width - 8px,
|
||||
max(8px,
|
||||
root.recent-tooltip-pointer-x > root.width * 0.7
|
||||
? root.recent-tooltip-pointer-x - root.recent-tooltip-width - 12px
|
||||
: root.recent-tooltip-pointer-x + 12px));
|
||||
private property <length> recent-tooltip-y: min(
|
||||
root.height - root.titlebar-height - root.recent-tooltip-height - 8px,
|
||||
max(8px, root.recent-tooltip-pointer-y - root.recent-tooltip-height - 8px));
|
||||
|
||||
changed menu-open => { if root.menu-open { root.gallery-action("release", ""); } }
|
||||
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
|
||||
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
|
||||
changed settings-open => {
|
||||
if root.settings-open {
|
||||
root.settings-scroll-y = 0px;
|
||||
@@ -490,7 +570,7 @@ export component MainWindow inherits Window {
|
||||
root.wayland-titlebar ? root.wayland-corner-radius : 0px;
|
||||
clip: root.wayland-titlebar;
|
||||
|
||||
if root.custom-titlebar && root.wayland-titlebar: Rectangle {
|
||||
if root.custom-titlebar && root.wayland-titlebar && !root.gallery-fullscreen: Rectangle {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
width: parent.width;
|
||||
@@ -583,7 +663,7 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if root.custom-titlebar && !root.wayland-titlebar: Rectangle {
|
||||
if root.custom-titlebar && !root.wayland-titlebar && !root.gallery-fullscreen: Rectangle {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
width: root.width;
|
||||
@@ -673,6 +753,12 @@ export component MainWindow inherits Window {
|
||||
width: root.width;
|
||||
height: root.height - self.y;
|
||||
clip: true;
|
||||
opacity: root.gallery-content-visible || !root.gallery-mode ? 1 : 0;
|
||||
animate opacity {
|
||||
duration: 60ms;
|
||||
easing: ease-in-out;
|
||||
enabled: root.gallery-mode;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width;
|
||||
@@ -714,10 +800,10 @@ export component MainWindow inherits Window {
|
||||
// App actions live in the content header; window controls and dragging
|
||||
// remain in the native or Windows-specific title bar above it.
|
||||
menu-button := IconButton {
|
||||
x: parent.width - 40px;
|
||||
y: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
x: parent.width - mode-switch.x - self.width;
|
||||
y: mode-switch.y;
|
||||
width: mode-switch.height;
|
||||
height: mode-switch.height;
|
||||
icon: FontAwesomeIcons.bars;
|
||||
icon-size: 14px;
|
||||
hover-background: #e1e5eb;
|
||||
@@ -726,7 +812,7 @@ export component MainWindow inherits Window {
|
||||
? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "")
|
||||
: root.update-available ? UiStrings.update-available : "";
|
||||
z: 8;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
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 {
|
||||
@@ -767,7 +853,7 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
|
||||
}
|
||||
}
|
||||
if root.announcement-unread > 0: Rectangle {
|
||||
@@ -794,10 +880,51 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
|
||||
}
|
||||
}
|
||||
|
||||
mode-switch := Rectangle {
|
||||
x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px;
|
||||
background: #eef1f5;
|
||||
border-width: 0.5px; border-color: #d5dbe3; border-radius: 6px;
|
||||
Rectangle {
|
||||
x: root.gallery-mode ? 82px : 2px;
|
||||
y: 2px; width: 80px; height: parent.height - 4px;
|
||||
background: @linear-gradient(180deg, #c4e0fd, #abd0f8);
|
||||
border-width: 0.5px; border-color: #97bde5; border-radius: 4px;
|
||||
drop-shadow-color: #315b8b20;
|
||||
drop-shadow-blur: 2px; drop-shadow-offset-y: 1px;
|
||||
Rectangle {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
border-width: 0.5px; border-color: #ffffff70; border-radius: 3px;
|
||||
}
|
||||
animate x { duration: 160ms; easing: ease-out; }
|
||||
}
|
||||
for label[index] in [GalleryStrings.tr("连接", "Connect", "Связь"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle {
|
||||
x: 2px + index * 80px; y: 2px; width: 80px; height: parent.height - 4px;
|
||||
Text {
|
||||
text: label;
|
||||
color: (index == 1) == root.gallery-mode ? #234b75 : #697483;
|
||||
font-weight: (index == 1) == root.gallery-mode ? 600 : 400;
|
||||
horizontal-alignment: center; vertical-alignment: center; font-size: 12px;
|
||||
animate color { duration: 120ms; easing: ease-out; }
|
||||
}
|
||||
TouchArea {
|
||||
clicked => { root.change-mode(index == 1); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.gallery-mode: CompactButton {
|
||||
x: menu-button.x - self.width - 8px;
|
||||
y: mode-switch.y;
|
||||
width: 87px;
|
||||
height: mode-switch.height;
|
||||
text: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit full", "Выйти") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
|
||||
clicked => { root.toggle-gallery-fullscreen(); }
|
||||
}
|
||||
Rectangle {
|
||||
y: root.content-header-height; height: parent.height - self.y - 24px; visible: !root.gallery-mode;
|
||||
// Local and remote desktop panels.
|
||||
Rectangle {
|
||||
y: 0px;
|
||||
@@ -856,13 +983,14 @@ export component MainWindow inherits Window {
|
||||
y: 46px;
|
||||
width: 29px;
|
||||
height: 30px;
|
||||
icon: FontAwesomeIcons.copy;
|
||||
icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy;
|
||||
icon-size: 16px;
|
||||
tooltip: UiStrings.copied;
|
||||
clicked => {
|
||||
root.copy-local-id();
|
||||
root.copy-toast = true;
|
||||
copy-timer.restart();
|
||||
if root.copy-local-id() {
|
||||
root.copy-toast = true;
|
||||
copy-timer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { x: 8px; y: 88px; width: 240px; height: 1px; background: ImGuiLineStyle.separator; }
|
||||
@@ -921,7 +1049,7 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.divider; }
|
||||
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; }
|
||||
Rectangle {
|
||||
@@ -959,10 +1087,11 @@ export component MainWindow inherits Window {
|
||||
// Run formatting after the input event has
|
||||
// finished so the caret state isn't reset by
|
||||
// a synchronous two-way binding rewrite.
|
||||
edited => { remote-id-format-timer.restart(); }
|
||||
edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); }
|
||||
accepted => { root.connect-requested(root.remote-id-input); }
|
||||
}
|
||||
}
|
||||
Text { x: 8px; y: 92px; width: 240px; height: 52px; text: root.connection-input-error; font-size: 12px; color: #a33832; wrap: word-wrap; }
|
||||
IconButton {
|
||||
x: 175px;
|
||||
y: 42px;
|
||||
@@ -982,7 +1111,7 @@ export component MainWindow inherits Window {
|
||||
// Recent connections: horizontal cards with thumbnail, state and actions.
|
||||
recent-panel := Rectangle {
|
||||
y: 234px;
|
||||
height: 192px;
|
||||
height: parent.height - self.y;
|
||||
background: white;
|
||||
border-width: 0px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
@@ -990,6 +1119,13 @@ 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 {
|
||||
x: parent.width - self.width - 29px; y: 8px;
|
||||
width: 155px; height: 28px;
|
||||
model: root.recent-groups;
|
||||
current-index <=> root.recent-group-index;
|
||||
selected(index) => { root.recent-group-selected(index); }
|
||||
}
|
||||
|
||||
recent-list := Rectangle {
|
||||
x: 29px;
|
||||
@@ -1002,6 +1138,13 @@ export component MainWindow inherits Window {
|
||||
border-radius: 10px;
|
||||
clip: true;
|
||||
|
||||
if root.recent-connections.length == 0: Text {
|
||||
text: root.recent-group-index == 0
|
||||
? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений")
|
||||
: GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений");
|
||||
color: #808080; font-size: ImGuiFontStyle.base;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
recent-scroll := ScrollView {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
@@ -1018,7 +1161,7 @@ export component MainWindow inherits Window {
|
||||
height: 117px;
|
||||
background: transparent;
|
||||
private property <length> content-width: self.width - 30px;
|
||||
private property <length> toolbar-width: 79px;
|
||||
private property <length> toolbar-width: DeviceCardStyle.toolbar-width;
|
||||
private property <length> toolbar-right-padding: 3px;
|
||||
|
||||
Rectangle {
|
||||
@@ -1113,12 +1256,19 @@ export component MainWindow inherits Window {
|
||||
image-touch := TouchArea {
|
||||
width: parent.width;
|
||||
height: 84px;
|
||||
private property <string> group-names: connection.group-names;
|
||||
changed group-names => {
|
||||
if self.has-hover && root.recent-tooltip-id == connection.remote-id {
|
||||
root.recent-tooltip-groups = self.group-names;
|
||||
}
|
||||
}
|
||||
clicked => { root.recent-connect(connection.remote-id); }
|
||||
changed has-hover => {
|
||||
if self.has-hover {
|
||||
root.recent-tooltip-name = connection.display-name;
|
||||
root.recent-tooltip-host = connection.host-name;
|
||||
root.recent-tooltip-id = connection.remote-id;
|
||||
root.recent-tooltip-groups = self.group-names;
|
||||
root.recent-tooltip-online = connection.online;
|
||||
root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x
|
||||
+ recent-scroll.x + recent-scroll.viewport-x
|
||||
@@ -1142,41 +1292,24 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
}
|
||||
if card-touch.has-hover: Rectangle {
|
||||
if card-touch.has-hover: DeviceCardActions {
|
||||
x: parent.width - self.width - connection-card.toolbar-right-padding;
|
||||
y: 85px;
|
||||
width: connection-card.toolbar-width;
|
||||
height: 23px;
|
||||
background: #4a4e55dc;
|
||||
border-radius: 5px;
|
||||
HorizontalLayout {
|
||||
spacing: 0px;
|
||||
IconButton {
|
||||
width: 26px; height: 23px; icon: FontAwesomeIcons.pen; icon-size: 12px; icon-color: white;
|
||||
hover-background: #ffffff2e;
|
||||
pressed-background: #598cf273;
|
||||
clicked => {
|
||||
root.alias-remote-id = connection.remote-id;
|
||||
root.alias-input = connection.display-name;
|
||||
root.alias-open = true;
|
||||
}
|
||||
}
|
||||
IconButton {
|
||||
width: 26px; height: 23px; icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon-color: white;
|
||||
hover-background: #ffffff2e;
|
||||
pressed-background: #598cf273;
|
||||
clicked => {
|
||||
root.delete-remote-id = connection.remote-id;
|
||||
root.delete-open = true;
|
||||
}
|
||||
}
|
||||
IconButton {
|
||||
width: 27px; height: 23px; icon: FontAwesomeIcons.arrow-right-long; icon-size: 13px; icon-color: white;
|
||||
hover-background: #ffffff2e;
|
||||
pressed-background: #598cf273;
|
||||
clicked => { root.recent-connect(connection.remote-id); }
|
||||
}
|
||||
remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись");
|
||||
connected: connection.connected;
|
||||
edit-alias => {
|
||||
root.alias-remote-id = connection.remote-id;
|
||||
root.alias-input = connection.display-name;
|
||||
root.alias-open = true;
|
||||
}
|
||||
edit-groups => { root.open-device-groups(connection.remote-id, connection.display-name); }
|
||||
remove-device => {
|
||||
root.delete-remote-id = connection.remote-id;
|
||||
root.delete-from-gallery = false;
|
||||
root.delete-open = true;
|
||||
}
|
||||
connect-device => { root.recent-connect(connection.remote-id); }
|
||||
disconnect-device => { root.recent-disconnect(connection.remote-id); }
|
||||
}
|
||||
}
|
||||
if index < root.recent-connections.length - 1: Rectangle {
|
||||
@@ -1192,9 +1325,38 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
gallery := GalleryView {
|
||||
y: root.content-header-height; width: parent.width; height: parent.height - self.y - 24px;
|
||||
visible: root.gallery-mode;
|
||||
controlling: root.gallery-controlling;
|
||||
pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); }
|
||||
scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); }
|
||||
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;
|
||||
hidden-selected: root.gallery-hidden-selected; notice: root.gallery-notice;
|
||||
selected-devices: root.gallery-selected-devices;
|
||||
focused: root.gallery-focused; batch <=> root.gallery-batch; adding <=> root.gallery-adding;
|
||||
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); }
|
||||
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) => {
|
||||
root.alias-remote-id = id; root.alias-input = name; root.alias-open = true;
|
||||
}
|
||||
edit-groups(id, name) => { root.open-device-groups(id, name); }
|
||||
remove-device(id) => {
|
||||
root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true;
|
||||
}
|
||||
}
|
||||
// Status bar.
|
||||
Rectangle {
|
||||
y: 426px;
|
||||
y: parent.height - 24px;
|
||||
height: 24px;
|
||||
background: #fafbfc;
|
||||
Rectangle {
|
||||
@@ -1255,9 +1417,11 @@ export component MainWindow inherits Window {
|
||||
// pointer can otherwise cover a dialog button and hide it from the user.
|
||||
if root.recent-tooltip-visible && !root.connection-dialog-open: Rectangle {
|
||||
x: root.recent-tooltip-x;
|
||||
y: root.recent-tooltip-y;
|
||||
y: min(root.height - root.titlebar-height - self.height - 8px,
|
||||
max(8px, root.recent-tooltip-pointer-y - self.height - 8px));
|
||||
width: root.recent-tooltip-width;
|
||||
height: root.recent-tooltip-height;
|
||||
height: (root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name
|
||||
? 80px : 64px) + tooltip-groups.preferred-height + 2px;
|
||||
background: #22252bf2;
|
||||
border-width: 1px;
|
||||
border-color: #ffffff38;
|
||||
@@ -1285,6 +1449,14 @@ export component MainWindow inherits Window {
|
||||
font-size: ImGuiFontStyle.small;
|
||||
overflow: elide;
|
||||
}
|
||||
tooltip-groups := Text {
|
||||
text: GalleryStrings.tr("分组", "Groups", "Группы") + ": " +
|
||||
(root.recent-tooltip-groups == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : root.recent-tooltip-groups);
|
||||
color: #e1e4e8;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
wrap: word-wrap;
|
||||
vertical-stretch: 0;
|
||||
}
|
||||
Text {
|
||||
text: root.recent-tooltip-online ? UiStrings.online : UiStrings.offline;
|
||||
color: root.recent-tooltip-online ? #62d98b : #d1d5db;
|
||||
@@ -1536,14 +1708,32 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
// One editor and one callback path for recent connections and DeskHub.
|
||||
if root.device-group-open: DeviceGroupDialog {
|
||||
z: 40;
|
||||
device-id: root.device-group-id;
|
||||
device-name: root.device-group-name;
|
||||
options: root.device-group-options;
|
||||
error: root.device-group-error;
|
||||
new-group <=> root.device-group-new-name;
|
||||
toggle-group(name) => { root.device-group-action("toggle", name); }
|
||||
save(name) => {
|
||||
if root.device-group-save(name) {
|
||||
root.device-group-action("close", "");
|
||||
root.device-group-open = false;
|
||||
}
|
||||
}
|
||||
cancel => { root.device-group-action("close", ""); root.device-group-open = false; }
|
||||
}
|
||||
|
||||
// Recent connection alias modal.
|
||||
if root.alias-open: Rectangle {
|
||||
background: #00000042;
|
||||
z: 40;
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
DialogSurface {
|
||||
x: root.compact-language ? 205px : 160px;
|
||||
y: 145px;
|
||||
x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px;
|
||||
y: root.gallery-mode ? (parent.height - self.height) / 2 : 145px;
|
||||
width: root.compact-language ? 230px : 320px;
|
||||
height: 160px;
|
||||
VerticalLayout {
|
||||
@@ -1588,16 +1778,16 @@ export component MainWindow inherits Window {
|
||||
z: 40;
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
DialogSurface {
|
||||
x: root.compact-language ? 205px : 160px;
|
||||
y: 155px;
|
||||
x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px;
|
||||
y: root.gallery-mode ? (parent.height - self.height) / 2 : 155px;
|
||||
width: root.compact-language ? 230px : 320px;
|
||||
height: 140px;
|
||||
VerticalLayout {
|
||||
padding: 20px; spacing: 18px;
|
||||
DialogPromptText { prompt: UiStrings.confirm-delete; }
|
||||
DialogPromptText { prompt: root.delete-from-gallery ? (root.gallery-group-index > 0 ? GalleryStrings.tr("从当前分组移除此设备?", "Remove from this group?", "Убрать из этой группы?") : GalleryStrings.tr("从桌面墙移除此设备?", "Remove from DeskHub?", "Убрать из DeskHub?")) : UiStrings.confirm-delete; }
|
||||
HorizontalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
PrimaryButton { text: UiStrings.ok; clicked => { root.recent-delete(root.delete-remote-id); root.delete-open = false; } }
|
||||
PrimaryButton { text: UiStrings.ok; clicked => { if root.delete-from-gallery { root.gallery-action("remove", root.delete-remote-id); } else { root.recent-delete(root.delete-remote-id); } root.delete-open = false; } }
|
||||
SecondaryButton { text: UiStrings.cancel; clicked => { root.delete-open = false; } }
|
||||
}
|
||||
}
|
||||
@@ -1672,8 +1862,8 @@ export component MainWindow inherits Window {
|
||||
text: UiStrings.ok;
|
||||
clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); }
|
||||
}
|
||||
if !root.connection-validating: ConnectionDialogButton {
|
||||
x: 117px;
|
||||
ConnectionDialogButton {
|
||||
x: root.connection-validating ? (parent.width - self.width) / 2 : 117px;
|
||||
y: 118px;
|
||||
text: UiStrings.cancel;
|
||||
clicked => { root.connection-cancel(); }
|
||||
@@ -2265,6 +2455,13 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore clicks against moving cards and duplicate fullscreen requests.
|
||||
if root.gallery-transitioning: TouchArea {
|
||||
x: 0px; y: root.titlebar-height;
|
||||
width: root.width; height: root.height - self.y;
|
||||
z: 250;
|
||||
}
|
||||
|
||||
if root.custom-titlebar: Rectangle {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
|
||||
@@ -40,6 +40,7 @@ inline int ApplyMainWindowStrings(
|
||||
int language_index) {
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
window->global<ui::GalleryStrings>().set_language(language);
|
||||
auto& strings = window->global<ui::UiStrings>();
|
||||
auto& messages = window->global<ui::MessageStrings>();
|
||||
messages.set_messages(Text(localization::messages[language]));
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@interface CrossDeskStreamFullscreenTarget : NSObject
|
||||
- (void)toggleStreamFullscreen:(id)sender;
|
||||
@end
|
||||
@@ -17,6 +19,12 @@
|
||||
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 *stream_window = nil;
|
||||
__weak NSView *stream_slint_view = nil;
|
||||
CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil;
|
||||
@@ -53,6 +61,14 @@ void SetTitlebarButtonsHidden(NSWindow *window, BOOL hidden) {
|
||||
[window standardWindowButton:NSWindowZoomButton].hidden = hidden;
|
||||
}
|
||||
|
||||
void HideMainWindowZoomButton(NSWindow *window) {
|
||||
NSButton *button = [window standardWindowButton:NSWindowZoomButton];
|
||||
button.target = nil;
|
||||
button.action = nil;
|
||||
button.enabled = NO;
|
||||
button.hidden = YES;
|
||||
}
|
||||
|
||||
void CenterWindowOnScreen(NSWindow *window, NSScreen *screen) {
|
||||
if (window == nil || screen == nil) {
|
||||
return;
|
||||
@@ -80,7 +96,17 @@ bool StartNativeWindowDrag() {
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
float MainWindowInitialPositionYOffset() {
|
||||
@autoreleasepool {
|
||||
const NSRect content = NSMakeRect(0, 0, 640, 486);
|
||||
const NSRect frame = [NSWindow frameRectForContentRect:content
|
||||
styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
|
||||
NSWindowStyleMaskMiniaturizable];
|
||||
return float(NSMaxY(frame) - NSMaxY(content));
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
NSWindow *window = slint_view.window;
|
||||
@@ -91,9 +117,19 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
|
||||
NSButton *zoom_button =
|
||||
[window standardWindowButton:NSWindowZoomButton];
|
||||
if (zoom_button == nil || zoom_button.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (zoom_button == nil) return false;
|
||||
main_window = window;
|
||||
main_gallery = gallery;
|
||||
if (main_fullscreen) return true;
|
||||
if (gallery)
|
||||
window.styleMask |= NSWindowStyleMaskResizable;
|
||||
else
|
||||
window.styleMask &= ~NSWindowStyleMaskResizable;
|
||||
window.collectionBehavior =
|
||||
(window.collectionBehavior & ~(NSWindowCollectionBehaviorFullScreenPrimary |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary |
|
||||
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
|
||||
NSWindowCollectionBehaviorFullScreenNone;
|
||||
|
||||
// Winit creates a transparent AppKit backing even when Slint paints an
|
||||
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
||||
@@ -104,7 +140,7 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.opaque = YES;
|
||||
window.backgroundColor = NSColor.whiteColor;
|
||||
zoom_button.hidden = YES;
|
||||
HideMainWindowZoomButton(window);
|
||||
|
||||
NSView *frame_view = window.contentView.superview;
|
||||
frame_view.needsLayout = YES;
|
||||
@@ -113,6 +149,71 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
}
|
||||
}
|
||||
|
||||
bool SetMainWindowFullscreen(bool fullscreen) {
|
||||
@autoreleasepool {
|
||||
if (!main_window || (fullscreen && !main_gallery)) return false;
|
||||
if (main_fullscreen == fullscreen) return true;
|
||||
if (fullscreen) {
|
||||
NSScreen *screen = main_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);
|
||||
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];
|
||||
} 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];
|
||||
}
|
||||
main_window.contentView.needsDisplay = YES;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
bool IsMainWindowFullscreen() { return main_fullscreen; }
|
||||
bool IsMainWindowActive() { return main_window && [NSApp isActive] && [main_window isKeyWindow]; }
|
||||
|
||||
bool MaximizeMainWindowWithoutAnimation() {
|
||||
@autoreleasepool {
|
||||
if (!main_window || !main_gallery || main_fullscreen) return false;
|
||||
NSScreen *screen = main_window.screen ?: NSScreen.mainScreen;
|
||||
if (!screen) return false;
|
||||
[main_window setFrame:screen.visibleFrame display:YES animate:NO];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsMainWindowMaximized() {
|
||||
@autoreleasepool {
|
||||
if (!main_window || !main_gallery || main_fullscreen) return false;
|
||||
NSScreen *screen = main_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 available = screen.visibleFrame;
|
||||
return std::abs(frame.origin.x - available.origin.x) < 1 &&
|
||||
std::abs(frame.origin.y - available.origin.y) < 1 &&
|
||||
std::abs(frame.size.width - available.size.width) < 1 &&
|
||||
std::abs(frame.size.height - available.size.height) < 1;
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureStreamWindowLiveResize(void *opaque_slint_view) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
|
||||
@@ -8,10 +8,20 @@ namespace crossdesk {
|
||||
// left-mouse-down event. Returns false when no suitable native event exists.
|
||||
bool StartNativeWindowDrag();
|
||||
|
||||
// Sets a fixed white native titlebar and hides the disabled zoom button on
|
||||
// the fixed-size main window. |slint_view| is its AppKit NSView*. Returns false
|
||||
// while the native window or its fixed-size constraints are not ready.
|
||||
bool ConfigureMainWindowChrome(void* slint_view);
|
||||
// Sets a white native titlebar and hides its zoom button in both main-window
|
||||
// modes. DeskHub remains 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();
|
||||
// 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();
|
||||
|
||||
// Configures live resize and replaces the stream window's native Space
|
||||
// fullscreen action with an immediate, single-window fullscreen transition.
|
||||
|
||||
@@ -510,7 +510,7 @@ function setup_targets()
|
||||
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
|
||||
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
|
||||
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
|
||||
"apps/desktop/src/gui/features/settings/*.cpp")
|
||||
"apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/features/gallery/*.cpp")
|
||||
-- Only these translation units consume the application version. Keep
|
||||
-- version changes out of the flags for media and the rest of the GUI.
|
||||
add_files("apps/desktop/src/gui/application/gui_application.cpp",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 桌面墙(DeskHub)
|
||||
|
||||
主窗口标题栏下方可以切换“连接”和“桌面墙”,菜单位于同一行右侧。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。
|
||||
|
||||
全屏按钮通过短暂淡出、等待窗口尺寸稳定、淡入完成切换,常规总时长约 0.2 秒;过渡期间屏蔽设备操作与重复全屏点击。窗口尺寸变化会立即更新网格,避免在新窗口中先显示旧的行列布局。过渡复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会恢复内容与交互。
|
||||
|
||||
可见的普通窗口若低于连接页最小尺寸,桌面墙恢复上次有效大小,连接页恢复固定大小;最小化、隐藏和全屏窗口不会触发这一恢复。窗口恢复以实际尺寸确认完成,期间不记录过渡尺寸;macOS 的最大化恢复使用无动画原生窗口调整,避免系统缩放动画嵌套事件循环。
|
||||
|
||||
## 使用
|
||||
|
||||
- 在桌面墙中点“添加设备”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。
|
||||
- “从最近连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“批量选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。
|
||||
- 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。
|
||||
- “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。
|
||||
- 卡片名称栏以文字标签显示“在线/离线”,与画面中的连接状态分开,不遮挡预览。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示“未知”,已有活动连接的设备仍显示在线。
|
||||
- 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。
|
||||
- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“从最近连接添加”或“添加设备”才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。
|
||||
- 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。
|
||||
- “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。
|
||||
- 网格随窗口大小增加行列,紧凑窗口为 3 × 2;翻页不关闭连接。分组内顺序不会按在线状态自动重排。
|
||||
- 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。
|
||||
- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;紧凑窗口的设备缩略图位于下方,宽窗口移到右侧。缩略图显示设备名、在线状态和当前设备标记,缺少画面或画面停滞时有状态占位;滚动条单独留出空间。调整尺寸后当前设备仍在列表中,选中时自动滚入可见区域。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
|
||||
- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。
|
||||
- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/分发文件及分页控件在同一底栏显示。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
|
||||
|
||||
## 预览与兼容性
|
||||
|
||||
桌面墙的所有已连接设备统一使用各自的交互画质、30/60 帧设置和降级策略。缩略图、放大查看、操控、翻页和隐藏窗口均不改变发送档位。新版两端通过 `supports_deskhub_preview` 同步订阅,视频按编码器协商的帧率发送,不再额外限帧;声音仍需明确开启。
|
||||
|
||||
为兼容旧版控制端,被控端仍接受暂停和 1/8 帧订阅;这些低帧率请求使用保持分辨率策略,避免套用交互视频的帧率与关键帧时延预算而反复缩小画面。当前桌面墙不再发出这些请求。
|
||||
|
||||
预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;外层圆角卡片与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。
|
||||
|
||||
旧版被控端仍可连接,按其支持的交互视频策略发送。多设备同时发送交互视频会增加带宽和编解码负载。
|
||||
|
||||
## 批量操作
|
||||
|
||||
连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。
|
||||
|
||||
批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。
|
||||
|
||||
`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。
|
||||
|
||||
批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。
|
||||
|
||||
## 验证
|
||||
|
||||
```sh
|
||||
xmake build crossdesk
|
||||
xmake run slint_ui_smoke_test
|
||||
xmake run crossdesk_wire_test
|
||||
xmake run keyboard_state_wire_test
|
||||
```
|
||||
Reference in New Issue
Block a user