Files
crossdesk/apps/desktop/src/gui/features/gallery/gallery_workspace.h
T

307 lines
11 KiB
C++

/*
* @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"
#include "gallery_stream_settings.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;
StreamSettings stream_settings;
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 Contains(const std::string &id) const {
return wall_ids_.count(id) != 0;
}
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 OrderIds(ids);
}
// Order is independent of membership, group assignments and live sessions.
std::vector<std::string> OrderIds(const std::vector<std::string> &ids) const {
if (device_order_.empty())
return ids;
std::set<std::string> remaining(ids.begin(), ids.end());
std::vector<std::string> ordered;
ordered.reserve(ids.size());
for (const auto &id : device_order_)
if (remaining.erase(id)) ordered.push_back(id);
for (const auto &id : ids)
if (remaining.erase(id)) ordered.push_back(id);
return ordered;
}
bool SwapPositions(const std::string &source, const std::string &target,
const std::vector<std::string> &visible_ids) {
if (source == target || !ValidId(source) || !ValidId(target) ||
std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() ||
std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end())
return false;
auto ordered = device_order_;
std::set<std::string> known(ordered.begin(), ordered.end());
// Include hidden groups before applying a swap in a filtered view.
for (const auto &id : Ids())
if (known.insert(id).second) ordered.push_back(id);
// Active-only devices can be arranged without enrolling them in the wall.
for (const auto &id : visible_ids)
if (ValidId(id) && known.insert(id).second) ordered.push_back(id);
std::iter_swap(std::find(ordered.begin(), ordered.end(), source),
std::find(ordered.begin(), ordered.end(), target));
device_order_ = std::move(ordered);
return true;
}
// History filtering uses group assignments, independently of wall enrollment.
// nullopt means all devices; an empty name means no named group.
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);
std::erase(device_order_, id);
} else if (MatchesGroup(id, group)) {
// Removing the last named group leaves an ungrouped wall member.
// Reuse the editor path so enrollment and card order stay unchanged.
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;
if (doc.contains("stream_settings"))
loaded.stream_settings = StreamSettings::FromJson(doc["stream_settings"]);
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);
}
if (doc.contains("device_order") && doc["device_order"].is_array()) {
std::set<std::string> seen;
for (const auto &value : doc["device_order"]) {
if (!value.is_string()) continue;
const auto id = value.get<std::string>();
if (ValidId(id) && seen.insert(id).second)
loaded.device_order_.push_back(id);
}
}
members = std::move(loaded.members);
groups = std::move(loaded.groups);
wall_ids_ = std::move(loaded.wall_ids_);
device_order_ = std::move(loaded.device_order_);
stream_settings = loaded.stream_settings;
return true;
} catch (...) {
return false;
}
}
bool Save(const std::filesystem::path &path) const {
try {
nlohmann::json doc{{"version", 2},
{"members", nlohmann::json::array()},
{"wall_ids", wall_ids_},
{"device_order", device_order_},
{"stream_settings", stream_settings.ToJson()}};
for (const auto &m : members)
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
return WriteAtomically(path, doc.dump(2));
} catch (...) {
return false;
}
}
private:
std::set<std::string> wall_ids_;
std::vector<std::string> device_order_;
bool HasAssignments(const std::string &id) const {
return std::any_of(members.begin(), members.end(),
[&](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