diff --git a/Meta/include/meta/ext/color_gradient/color_gradient.hpp b/Meta/include/meta/ext/color_gradient/color_gradient.hpp index f6cb69a..8940dd4 100644 --- a/Meta/include/meta/ext/color_gradient/color_gradient.hpp +++ b/Meta/include/meta/ext/color_gradient/color_gradient.hpp @@ -8,10 +8,12 @@ #include -namespace meta { +namespace meta +{ /// A color stop in a gradient. -struct Stop { +struct Stop +{ /// Position in the range [0, 1]. float position; @@ -22,7 +24,8 @@ struct Stop { }; /// A named color gradient preset. -struct Preset { +struct Preset +{ /// Preset name. std::string name; @@ -36,7 +39,8 @@ struct Preset { /// type: they are host configuration, carried in attribute metadata as a /// GradientPresets entry (keys::ui::presets), so that deserializing a value /// cannot clobber the preset library installed at setup time. -class ColorGradient { +class ColorGradient +{ public: /// Constructs a default black-to-white gradient. ColorGradient() = default; @@ -72,7 +76,8 @@ class ColorGradient { /// Preset library for a gradient attribute, installed by the host into /// attribute metadata under keys::ui::presets. Runtime configuration, not /// document state: never serialized (mirrors meta::DataProvider). -struct GradientPresets { +struct GradientPresets +{ /// Available presets. std::vector presets; }; diff --git a/Meta/include/meta/ext/color_gradient/gradient_library.hpp b/Meta/include/meta/ext/color_gradient/gradient_library.hpp index 8e05c93..99f0202 100644 --- a/Meta/include/meta/ext/color_gradient/gradient_library.hpp +++ b/Meta/include/meta/ext/color_gradient/gradient_library.hpp @@ -14,10 +14,12 @@ #include "meta/core/event.hpp" #include "meta/ext/color_gradient/color_gradient.hpp" -namespace meta { +namespace meta +{ /// Ordering applied to preset grids. Favourites are always pinned first. -enum class GradientSort { +enum class GradientSort +{ Default, ///< host order, then library insertion order Name, ///< case-insensitive name Luminance, ///< dark to light (gradient_luminance) @@ -31,11 +33,12 @@ std::string_view to_string(GradientSort sort); std::optional gradient_sort_from_string(std::string_view text); /// Outcome of GradientLibrary::import_file(). -struct GradientImportReport { +struct GradientImportReport +{ std::size_t added = 0; ///< stored under their own name std::size_t renamed = 0; ///< stored under a suffixed name (name clash) std::size_t skipped = 0; ///< identical to an existing preset - bool ok = false; ///< file could be read and held gradients + bool ok = false; ///< file could be read and held gradients }; /** @@ -61,9 +64,9 @@ nlohmann::json gradient_file_json(const std::vector &presets); * * @return The parsed presets, or std::nullopt when nothing usable was found. */ -std::optional> -parse_gradient_file(const nlohmann::json &json, - std::string_view fallback_name = "Gradient"); +std::optional> parse_gradient_file( + const nlohmann::json &json, + std::string_view fallback_name = "Gradient"); /** * @brief The user's gradient presets, shared by every gradient widget in the @@ -78,7 +81,8 @@ parse_gradient_file(const nlohmann::json &json, * The Qt GradientPicker assigns a default per-user path on first use when * none is set; call set_path() + load() beforehand to choose another. */ -class GradientLibrary { +class GradientLibrary +{ public: /// Fired after every effective mutation and after a successful load. Event<> changed; @@ -113,8 +117,8 @@ class GradientLibrary { // --- user presets const std::vector &presets() const; - bool has(std::string_view name) const; - const Preset *find(std::string_view name) const; + bool has(std::string_view name) const; + const Preset *find(std::string_view name) const; /** * @brief Stores a preset. The name is trimmed ("Gradient" when empty) and @@ -138,19 +142,19 @@ class GradientLibrary { /// `base` if free, otherwise "base (2)", "base (3)", ...; names in /// `reserved` (e.g. host presets) count as taken. - std::string unique_name(std::string_view base, + std::string unique_name(std::string_view base, const std::vector &reserved = {}) const; // --- favourites - bool is_favorite(std::string_view name) const; - void set_favorite(std::string_view name, bool on); + bool is_favorite(std::string_view name) const; + void set_favorite(std::string_view name, bool on); const std::vector &favorites() const; // --- sort preference GradientSort sort() const; - void set_sort(GradientSort sort); + void set_sort(GradientSort sort); // --- serialization @@ -171,16 +175,16 @@ class GradientLibrary { /// Writes `presets` as a gradient file (no favourites, no sort). bool export_file(const std::filesystem::path &path, - const std::vector &presets) const; + const std::vector &presets) const; private: void on_modified(); - std::vector presets_; + std::vector presets_; std::vector favorites_; - GradientSort sort_ = GradientSort::Default; - std::filesystem::path path_; - bool autosave_ = true; + GradientSort sort_ = GradientSort::Default; + std::filesystem::path path_; + bool autosave_ = true; }; } // namespace meta diff --git a/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp b/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp index 6463915..c280026 100644 --- a/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp +++ b/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp @@ -7,7 +7,8 @@ #include "meta/ext/color_gradient/color_gradient.hpp" -namespace meta { +namespace meta +{ /** * @brief Colour of a gradient at position `t`. diff --git a/Meta/src/ext/color_gradient/gradient_library.cpp b/Meta/src/ext/color_gradient/gradient_library.cpp index a0ad395..bf17d6b 100644 --- a/Meta/src/ext/color_gradient/gradient_library.cpp +++ b/Meta/src/ext/color_gradient/gradient_library.cpp @@ -8,41 +8,45 @@ #include "meta/ext/color_gradient/gradient_library.hpp" #include "meta/logger.hpp" -namespace meta { +namespace meta +{ -namespace { +namespace +{ constexpr char kFileFormat[] = "meta.gradients"; -constexpr int kFileVersion = 1; +constexpr int kFileVersion = 1; constexpr char kDefaultName[] = "Gradient"; -std::string trim(std::string_view text) { +std::string trim(std::string_view text) +{ const auto not_space = [](unsigned char c) { return !std::isspace(c); }; const auto begin = std::find_if(text.begin(), text.end(), not_space); const auto end = std::find_if(text.rbegin(), text.rend(), not_space).base(); return begin < end ? std::string(begin, end) : std::string(); } -void sort_stops(std::vector &stops) { - std::stable_sort( - stops.begin(), stops.end(), - [](const Stop &a, const Stop &b) { return a.position < b.position; }); +void sort_stops(std::vector &stops) +{ + std::stable_sort(stops.begin(), + stops.end(), + [](const Stop &a, const Stop &b) + { return a.position < b.position; }); } -bool parse_color(const nlohmann::json &json, std::array &out) { - if (!json.is_array() || json.size() < 3) - return false; +bool parse_color(const nlohmann::json &json, std::array &out) +{ + if (!json.is_array() || json.size() < 3) return false; - const std::size_t n = std::min(4, json.size()); + const std::size_t n = std::min(4, json.size()); std::array color = {0.f, 0.f, 0.f, 1.f}; - bool over_one = false; + bool over_one = false; - for (std::size_t k = 0; k < n; ++k) { - if (!json[k].is_number()) - return false; + for (std::size_t k = 0; k < n; ++k) + { + if (!json[k].is_number()) return false; color[k] = json[k].get(); - if (color[k] > 1.f) - over_one = true; + if (color[k] > 1.f) over_one = true; } // 0-255 encoded colours (e.g. Hesiod's data/color_gradient.json) @@ -58,9 +62,9 @@ bool parse_color(const nlohmann::json &json, std::array &out) { } std::optional parse_preset(const nlohmann::json &json, - std::string_view fallback_name) { - if (!json.is_object()) - return std::nullopt; + std::string_view fallback_name) +{ + if (!json.is_object()) return std::nullopt; const nlohmann::json *stops_json = nullptr; if (json.contains("stops") && json["stops"].is_array()) @@ -68,69 +72,75 @@ std::optional parse_preset(const nlohmann::json &json, else if (json.contains("value") && json["value"].is_array()) stops_json = &json["value"]; - if (!stops_json) - return std::nullopt; + if (!stops_json) return std::nullopt; Preset preset; if (json.contains("name") && json["name"].is_string()) preset.name = trim(json["name"].get()); - if (preset.name.empty()) - preset.name = std::string(fallback_name); + if (preset.name.empty()) preset.name = std::string(fallback_name); - for (const auto &s : *stops_json) { + for (const auto &s : *stops_json) + { if (!s.is_object() || !s.contains("position") || !s["position"].is_number() || !s.contains("color")) continue; std::array color; - if (!parse_color(s["color"], color)) - continue; + if (!parse_color(s["color"], color)) continue; preset.stops.push_back( {std::clamp(s["position"].get(), 0.f, 1.f), color}); } - if (preset.stops.size() < 2) - return std::nullopt; + if (preset.stops.size() < 2) return std::nullopt; sort_stops(preset.stops); return preset; } std::vector parse_preset_list(const nlohmann::json &array, - std::string_view fallback_name) { + std::string_view fallback_name) +{ std::vector out; - std::size_t index = 0; + std::size_t index = 0; - for (const auto &g : array) { + for (const auto &g : array) + { ++index; - const std::string fallback = - array.size() > 1 - ? std::string(fallback_name) + " " + std::to_string(index) - : std::string(fallback_name); + const std::string fallback = array.size() > 1 + ? std::string(fallback_name) + " " + + std::to_string(index) + : std::string(fallback_name); if (auto preset = parse_preset(g, fallback)) out.push_back(std::move(*preset)); else Logger::log()->warn( - "parse_gradient_file: skipping invalid gradient entry #{}", index); + "parse_gradient_file: skipping invalid gradient entry #{}", + index); } return out; } -bool read_json_file(const std::filesystem::path &path, nlohmann::json &out) { +bool read_json_file(const std::filesystem::path &path, nlohmann::json &out) +{ std::ifstream file(path); - if (!file) { + if (!file) + { Logger::log()->error("GradientLibrary: cannot open '{}'", path.string()); return false; } - try { + try + { file >> out; - } catch (const std::exception &e) { + } + catch (const std::exception &e) + { Logger::log()->error("GradientLibrary: cannot parse '{}': {}", - path.string(), e.what()); + path.string(), + e.what()); return false; } @@ -138,13 +148,15 @@ bool read_json_file(const std::filesystem::path &path, nlohmann::json &out) { } bool write_json_file(const std::filesystem::path &path, - const nlohmann::json &json) { + const nlohmann::json &json) +{ std::error_code ec; if (path.has_parent_path()) std::filesystem::create_directories(path.parent_path(), ec); std::ofstream file(path); - if (!file) { + if (!file) + { Logger::log()->error("GradientLibrary: cannot write '{}'", path.string()); return false; } @@ -159,35 +171,37 @@ bool write_json_file(const std::filesystem::path &path, // Free functions // --------------------------------------------------------------------------- -std::string_view to_string(GradientSort sort) { - switch (sort) { - case GradientSort::Name: - return "name"; - case GradientSort::Luminance: - return "luminance"; - case GradientSort::Hue: - return "hue"; +std::string_view to_string(GradientSort sort) +{ + switch (sort) + { + case GradientSort::Name: return "name"; + case GradientSort::Luminance: return "luminance"; + case GradientSort::Hue: return "hue"; case GradientSort::Default: - default: - return "default"; + default: return "default"; } } -std::optional gradient_sort_from_string(std::string_view text) { - for (GradientSort s : {GradientSort::Default, GradientSort::Name, - GradientSort::Luminance, GradientSort::Hue}) - if (text == to_string(s)) - return s; +std::optional gradient_sort_from_string(std::string_view text) +{ + for (GradientSort s : {GradientSort::Default, + GradientSort::Name, + GradientSort::Luminance, + GradientSort::Hue}) + if (text == to_string(s)) return s; return std::nullopt; } -nlohmann::json gradient_file_json(const std::vector &presets) { +nlohmann::json gradient_file_json(const std::vector &presets) +{ nlohmann::json json; json["format"] = kFileFormat; json["version"] = kFileVersion; json["gradients"] = nlohmann::json::array(); - for (const auto &preset : presets) { + for (const auto &preset : presets) + { nlohmann::json g; g["name"] = preset.name; g["stops"] = nlohmann::json::array(); @@ -199,9 +213,10 @@ nlohmann::json gradient_file_json(const std::vector &presets) { return json; } -std::optional> -parse_gradient_file(const nlohmann::json &json, - std::string_view fallback_name) { +std::optional> parse_gradient_file( + const nlohmann::json &json, + std::string_view fallback_name) +{ std::vector out; if (json.is_array()) @@ -212,8 +227,7 @@ parse_gradient_file(const nlohmann::json &json, else if (auto preset = parse_preset(json, fallback_name)) out.push_back(std::move(*preset)); - if (out.empty()) - return std::nullopt; + if (out.empty()) return std::nullopt; return out; } @@ -221,12 +235,14 @@ parse_gradient_file(const nlohmann::json &json, // GradientLibrary // --------------------------------------------------------------------------- -GradientLibrary &GradientLibrary::instance() { +GradientLibrary &GradientLibrary::instance() +{ static GradientLibrary library; return library; } -void GradientLibrary::set_path(std::filesystem::path path) { +void GradientLibrary::set_path(std::filesystem::path path) +{ path_ = std::move(path); } @@ -236,36 +252,42 @@ void GradientLibrary::set_autosave(bool on) { autosave_ = on; } bool GradientLibrary::autosave() const { return autosave_; } -bool GradientLibrary::load() { - if (path_.empty()) { +bool GradientLibrary::load() +{ + if (path_.empty()) + { Logger::log()->warn("GradientLibrary::load: no path set"); return false; } std::error_code ec; - if (!std::filesystem::exists(path_, ec)) { + if (!std::filesystem::exists(path_, ec)) + { Logger::log()->trace("GradientLibrary::load: no file at '{}'", path_.string()); return false; } nlohmann::json json; - if (!read_json_file(path_, json)) - return false; + if (!read_json_file(path_, json)) return false; - if (!json_from(json)) { + if (!json_from(json)) + { Logger::log()->warn("GradientLibrary::load: '{}' is not a gradient library", path_.string()); return false; } Logger::log()->trace("GradientLibrary::load: {} presets from '{}'", - presets_.size(), path_.string()); + presets_.size(), + path_.string()); return true; } -bool GradientLibrary::save() const { - if (path_.empty()) { +bool GradientLibrary::save() const +{ + if (path_.empty()) + { Logger::log()->trace("GradientLibrary::save: no path set, skipping"); return false; } @@ -274,18 +296,22 @@ bool GradientLibrary::save() const { const std::vector &GradientLibrary::presets() const { return presets_; } -bool GradientLibrary::has(std::string_view name) const { +bool GradientLibrary::has(std::string_view name) const +{ return find(name) != nullptr; } -const Preset *GradientLibrary::find(std::string_view name) const { - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [name](const Preset &p) { return p.name == name; }); +const Preset *GradientLibrary::find(std::string_view name) const +{ + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [name](const Preset &p) + { return p.name == name; }); return it == presets_.end() ? nullptr : &*it; } -std::string GradientLibrary::add(Preset preset) { +std::string GradientLibrary::add(Preset preset) +{ preset.name = unique_name(preset.name); sort_stops(preset.stops); @@ -297,12 +323,13 @@ std::string GradientLibrary::add(Preset preset) { return name; } -bool GradientLibrary::update(std::string_view name, std::vector stops) { - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [name](const Preset &p) { return p.name == name; }); - if (it == presets_.end()) - return false; +bool GradientLibrary::update(std::string_view name, std::vector stops) +{ + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [name](const Preset &p) + { return p.name == name; }); + if (it == presets_.end()) return false; sort_stops(stops); it->stops = std::move(stops); @@ -312,38 +339,38 @@ bool GradientLibrary::update(std::string_view name, std::vector stops) { return true; } -bool GradientLibrary::rename(std::string_view from, std::string_view to) { +bool GradientLibrary::rename(std::string_view from, std::string_view to) +{ const std::string new_name = trim(to); - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [from](const Preset &p) { return p.name == from; }); - if (it == presets_.end() || new_name.empty()) - return false; - if (new_name == it->name) - return true; - if (has(new_name)) - return false; + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [from](const Preset &p) + { return p.name == from; }); + if (it == presets_.end() || new_name.empty()) return false; + if (new_name == it->name) return true; + if (has(new_name)) return false; const std::string old_name = it->name; it->name = new_name; for (auto &favorite : favorites_) - if (favorite == old_name) - favorite = new_name; + if (favorite == old_name) favorite = new_name; - Logger::log()->trace("GradientLibrary::rename: '{}' -> '{}'", old_name, + Logger::log()->trace("GradientLibrary::rename: '{}' -> '{}'", + old_name, new_name); on_modified(); return true; } -bool GradientLibrary::remove(std::string_view name) { - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [name](const Preset &p) { return p.name == name; }); - if (it == presets_.end()) - return false; +bool GradientLibrary::remove(std::string_view name) +{ + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [name](const Preset &p) + { return p.name == name; }); + if (it == presets_.end()) return false; const std::string removed = it->name; // `name` may alias it->name presets_.erase(it); @@ -355,45 +382,48 @@ bool GradientLibrary::remove(std::string_view name) { return true; } -void GradientLibrary::clear() { +void GradientLibrary::clear() +{ Logger::log()->trace("GradientLibrary::clear ({} presets)", presets_.size()); presets_.clear(); favorites_.clear(); on_modified(); } -std::string -GradientLibrary::unique_name(std::string_view base, - const std::vector &reserved) const { +std::string GradientLibrary::unique_name( + std::string_view base, + const std::vector &reserved) const +{ std::string name = trim(base); - if (name.empty()) - name = kDefaultName; + if (name.empty()) name = kDefaultName; - const auto taken = [&](const std::string &candidate) { - return has(candidate) || std::find(reserved.begin(), reserved.end(), - candidate) != reserved.end(); + const auto taken = [&](const std::string &candidate) + { + return has(candidate) || + std::find(reserved.begin(), reserved.end(), candidate) != + reserved.end(); }; - if (!taken(name)) - return name; + if (!taken(name)) return name; - for (int i = 2;; ++i) { + for (int i = 2;; ++i) + { const std::string candidate = name + " (" + std::to_string(i) + ")"; - if (!taken(candidate)) - return candidate; + if (!taken(candidate)) return candidate; } } -bool GradientLibrary::is_favorite(std::string_view name) const { +bool GradientLibrary::is_favorite(std::string_view name) const +{ return std::find(favorites_.begin(), favorites_.end(), name) != favorites_.end(); } -void GradientLibrary::set_favorite(std::string_view name, bool on) { +void GradientLibrary::set_favorite(std::string_view name, bool on) +{ const auto it = std::find(favorites_.begin(), favorites_.end(), name); const bool present = it != favorites_.end(); - if (on == present) - return; + if (on == present) return; if (on) favorites_.emplace_back(name); @@ -404,39 +434,42 @@ void GradientLibrary::set_favorite(std::string_view name, bool on) { on_modified(); } -const std::vector &GradientLibrary::favorites() const { +const std::vector &GradientLibrary::favorites() const +{ return favorites_; } GradientSort GradientLibrary::sort() const { return sort_; } -void GradientLibrary::set_sort(GradientSort sort) { - if (sort == sort_) - return; +void GradientLibrary::set_sort(GradientSort sort) +{ + if (sort == sort_) return; sort_ = sort; on_modified(); } -nlohmann::json GradientLibrary::json_to() const { +nlohmann::json GradientLibrary::json_to() const +{ nlohmann::json json = gradient_file_json(presets_); json["favorites"] = favorites_; json["sort"] = std::string(to_string(sort_)); return json; } -bool GradientLibrary::json_from(const nlohmann::json &json) { +bool GradientLibrary::json_from(const nlohmann::json &json) +{ if (!json.is_object() || !json.contains("gradients") || !json["gradients"].is_array()) return false; - std::vector presets = - parse_preset_list(json["gradients"], kDefaultName); + std::vector presets = parse_preset_list(json["gradients"], + kDefaultName); std::vector favorites; if (json.contains("favorites") && json["favorites"].is_array()) - for (const auto &f : json["favorites"]) { - if (!f.is_string()) - continue; + for (const auto &f : json["favorites"]) + { + if (!f.is_string()) continue; const std::string name = f.get(); if (std::find(favorites.begin(), favorites.end(), name) == favorites.end()) @@ -445,12 +478,13 @@ bool GradientLibrary::json_from(const nlohmann::json &json) { GradientSort sort = GradientSort::Default; if (json.contains("sort") && json["sort"].is_string()) - if (const auto s = - gradient_sort_from_string(json["sort"].get())) + if (const auto s = gradient_sort_from_string( + json["sort"].get())) sort = *s; presets_.clear(); - for (auto &preset : presets) { + for (auto &preset : presets) + { preset.name = unique_name(preset.name); presets_.push_back(std::move(preset)); } @@ -461,30 +495,36 @@ bool GradientLibrary::json_from(const nlohmann::json &json) { return true; } -GradientImportReport -GradientLibrary::import_file(const std::filesystem::path &path) { +GradientImportReport GradientLibrary::import_file( + const std::filesystem::path &path) +{ GradientImportReport report; nlohmann::json json; - if (!read_json_file(path, json)) - return report; + if (!read_json_file(path, json)) return report; const auto parsed = parse_gradient_file(json, path.stem().string()); - if (!parsed) { + if (!parsed) + { Logger::log()->warn("GradientLibrary::import_file: no gradients in '{}'", path.string()); return report; } - for (Preset preset : *parsed) { - if (const Preset *existing = find(preset.name)) { - if (existing->stops == preset.stops) { + for (Preset preset : *parsed) + { + if (const Preset *existing = find(preset.name)) + { + if (existing->stops == preset.stops) + { ++report.skipped; continue; } preset.name = unique_name(preset.name); ++report.renamed; - } else { + } + else + { ++report.added; } presets_.push_back(std::move(preset)); @@ -494,23 +534,27 @@ GradientLibrary::import_file(const std::filesystem::path &path) { Logger::log()->trace( "GradientLibrary::import_file: '{}': {} added, {} renamed, {} skipped", - path.string(), report.added, report.renamed, report.skipped); + path.string(), + report.added, + report.renamed, + report.skipped); - if (report.added + report.renamed > 0) - on_modified(); + if (report.added + report.renamed > 0) on_modified(); return report; } bool GradientLibrary::export_file(const std::filesystem::path &path, - const std::vector &presets) const { + const std::vector &presets) const +{ Logger::log()->trace("GradientLibrary::export_file: {} presets to '{}'", - presets.size(), path.string()); + presets.size(), + path.string()); return write_json_file(path, gradient_file_json(presets)); } -void GradientLibrary::on_modified() { - if (autosave_ && !path_.empty()) - save(); +void GradientLibrary::on_modified() +{ + if (autosave_ && !path_.empty()) save(); changed.notify(); } diff --git a/Meta/src/ext/color_gradient/gradient_metrics.cpp b/Meta/src/ext/color_gradient/gradient_metrics.cpp index 0304e7b..9755fa3 100644 --- a/Meta/src/ext/color_gradient/gradient_metrics.cpp +++ b/Meta/src/ext/color_gradient/gradient_metrics.cpp @@ -7,30 +7,33 @@ #include "meta/ext/color_gradient/gradient_metrics.hpp" -namespace meta { +namespace meta +{ -namespace { +namespace +{ -std::vector sorted_stops(const std::vector &stops) { +std::vector sorted_stops(const std::vector &stops) +{ std::vector sorted = stops; - std::stable_sort( - sorted.begin(), sorted.end(), - [](const Stop &a, const Stop &b) { return a.position < b.position; }); + std::stable_sort(sorted.begin(), + sorted.end(), + [](const Stop &a, const Stop &b) + { return a.position < b.position; }); return sorted; } // Expects sorted, non-empty stops. -std::array sample_sorted(const std::vector &sorted, float t) { +std::array sample_sorted(const std::vector &sorted, float t) +{ t = std::clamp(t, 0.f, 1.f); - if (t <= sorted.front().position) - return sorted.front().color; - if (t >= sorted.back().position) - return sorted.back().color; + if (t <= sorted.front().position) return sorted.front().color; + if (t >= sorted.back().position) return sorted.back().color; - for (std::size_t i = 1; i < sorted.size(); ++i) { - if (t > sorted[i].position) - continue; + for (std::size_t i = 1; i < sorted.size(); ++i) + { + if (t > sorted[i].position) continue; const Stop &a = sorted[i - 1]; const Stop &b = sorted[i]; @@ -46,12 +49,14 @@ std::array sample_sorted(const std::vector &sorted, float t) { return sorted.back().color; } -float sample_position(int i, int samples) { +float sample_position(int i, int samples) +{ return samples == 1 ? 0.5f : float(i) / float(samples - 1); } // HSV hue in degrees [0, 360) and saturation in [0, 1]. -void hue_saturation(const std::array &c, float &hue, float &sat) { +void hue_saturation(const std::array &c, float &hue, float &sat) +{ const float r = std::clamp(c[0], 0.f, 1.f); const float g = std::clamp(c[1], 0.f, 1.f); const float b = std::clamp(c[2], 0.f, 1.f); @@ -61,8 +66,7 @@ void hue_saturation(const std::array &c, float &hue, float &sat) { hue = 0.f; sat = mx > 0.f ? d / mx : 0.f; - if (d <= 1e-6f) - return; + if (d <= 1e-6f) return; float h; if (mx == r) @@ -73,27 +77,27 @@ void hue_saturation(const std::array &c, float &hue, float &sat) { h = (r - g) / d + 4.f; h *= 60.f; - if (h < 0.f) - h += 360.f; + if (h < 0.f) h += 360.f; hue = h; } } // namespace -std::array sample_gradient(const std::vector &stops, float t) { - if (stops.empty()) - return {0.f, 0.f, 0.f, 1.f}; +std::array sample_gradient(const std::vector &stops, float t) +{ + if (stops.empty()) return {0.f, 0.f, 0.f, 1.f}; return sample_sorted(sorted_stops(stops), t); } -float gradient_luminance(const std::vector &stops, int samples) { - if (stops.empty() || samples <= 0) - return 0.f; +float gradient_luminance(const std::vector &stops, int samples) +{ + if (stops.empty() || samples <= 0) return 0.f; const std::vector sorted = sorted_stops(stops); - float sum = 0.f; + float sum = 0.f; - for (int i = 0; i < samples; ++i) { + for (int i = 0; i < samples; ++i) + { const auto c = sample_sorted(sorted, sample_position(i, samples)); sum += 0.2126f * std::clamp(c[0], 0.f, 1.f) + 0.7152f * std::clamp(c[1], 0.f, 1.f) + @@ -103,19 +107,21 @@ float gradient_luminance(const std::vector &stops, int samples) { return sum / float(samples); } -float gradient_hue(const std::vector &stops, int samples) { - if (stops.empty() || samples <= 0) - return -1.f; +float gradient_hue(const std::vector &stops, int samples) +{ + if (stops.empty() || samples <= 0) return -1.f; const std::vector sorted = sorted_stops(stops); - double sx = 0.0; - double sy = 0.0; - double weight = 0.0; + double sx = 0.0; + double sy = 0.0; + double weight = 0.0; - for (int i = 0; i < samples; ++i) { + for (int i = 0; i < samples; ++i) + { float hue = 0.f; float sat = 0.f; - hue_saturation(sample_sorted(sorted, sample_position(i, samples)), hue, + hue_saturation(sample_sorted(sorted, sample_position(i, samples)), + hue, sat); const double rad = double(hue) * std::numbers::pi / 180.0; @@ -124,14 +130,11 @@ float gradient_hue(const std::vector &stops, int samples) { weight += sat; } - if (weight < 1e-4) - return -1.f; + if (weight < 1e-4) return -1.f; double deg = std::atan2(sy, sx) * 180.0 / std::numbers::pi; - if (deg < 0.0) - deg += 360.0; - if (deg >= 360.0) - deg -= 360.0; + if (deg < 0.0) deg += 360.0; + if (deg >= 360.0) deg -= 360.0; return float(deg); } diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp index b1291fa..27607db 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -63,22 +63,25 @@ class ComboPopup : public QWidget void mouseReleaseEvent(QMouseEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void hideEvent(QHideEvent *event) override; + void closeEvent(QCloseEvent *event) override; private: - int index_at(const QPoint &pos) const; - int row_height() const; + int index_at(const QPoint &pos) const; + int row_height() const; + void animate_to(int height); - /// Portion of the fixed-size window currently revealed by the open animation. + /// Portion of the fixed-size window currently revealed by the animation. QRect card_rect() const; const Theme *theme_ = nullptr; QStringList items_; int current_ = -1; int hovered_ = -1; - QVariantAnimation *open_animation_ = nullptr; + QVariantAnimation *animation_ = nullptr; int full_height_ = 0; int revealed_ = 0; bool flipped_ = false; + bool closing_ = false; }; /// Shared closed-state painting for both combo flavours. diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp index 57621d4..6148b15 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp @@ -35,7 +35,13 @@ class IntSlider : public Control const RowContext &ctx, QWidget *parent = nullptr); - /// A rail needs max > min to span; without it the row falls back to stock. + /** @brief Accept any attribute that declares a range, bounded or not. + * + * Both constraint keys must be present, but their values are not screened. + * Seed is the case that matters: it declares [0, INT_MAX], which no rail can + * span, and it appears on nearly every node. It is rendered in the unbounded + * mode below rather than handed to another design. + */ static bool can_render(const Attribute &attr); int get() const override { return value_; } @@ -63,6 +69,21 @@ class IntSlider : public Control void refresh_field(); void restyle_field(bool editing = false); + /** @brief Advance an unbounded drag to cursor position `x`. + * + * Moves the value by whole steps over the distance dragged rather than to + * the position under the cursor. Ctrl is fine, Shift is coarse, as in stock. + */ + void drag_by(int x, Qt::KeyboardModifiers modifiers); + + /** @brief Move to `value` as one complete edit. + * + * Glides when the rail can show the motion and seats immediately when it + * cannot, so the three callers that just want "go there and commit" -- typed + * value, wheel notch, double-click reset -- do not each repeat the branch. + */ + void commit_value(int value); + int min_ = 0; int max_ = 1; int value_ = 0; @@ -70,10 +91,20 @@ class IntSlider : public Control std::string category_; std::string key_; + /** @brief No usable range, so the thumb reports drag rate, not position. + * + * Fixed at construction: an attribute's constraints do not change under it. + */ + bool unbounded_ = false; + Glide *glide_ = nullptr; ///< animates the painted position only qreal norm_ = 0.0; QLineEdit *field_ = nullptr; bool dragging_ = false; + + // --- unbounded drag reference, both only meaningful while dragging_ + int drag_origin_x_ = 0; + int value_at_press_ = 0; }; } // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp index c1f221f..a84ec16 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -33,11 +33,14 @@ class ParamSlider : public Control const RowContext &ctx, QWidget *parent = nullptr); - /** @brief Decline attributes with no usable range. + /** @brief Accept any attribute that declares a range, bounded or not. * - * A rail needs `max > min` to span. Without both keys meta::common::min/max - * return the numeric limits, which produces a rail no drag can meaningfully - * address -- so the row falls back to the stock spin box instead. + * Both constraint keys must be present: without them the attribute is not + * asking to be a slider at all, and meta::common::min/max would invent + * limits it never declared. + * + * The values themselves are not screened. A range with no usable span is + * rendered in the unbounded mode below rather than declined. */ static bool can_render(const Attribute &attr); @@ -70,6 +73,25 @@ class ParamSlider : public Control void refresh_field(); void restyle_field(bool editing = false); + /** @brief Advance an unbounded drag to cursor position `x`. + * + * Moves the value by the distance dragged rather than to the position under + * the cursor, and moves the thumb to match until it reaches the end of the + * rail. Ctrl is fine, Shift is coarse, as in stock. + */ + void drag_by(int x, Qt::KeyboardModifiers modifiers); + + /// Seat `value` and publish it. No animation: use for unbounded rows. + void apply_value(float value); + + /** @brief Move to `value` as one complete edit. + * + * Glides when the rail can show the motion and seats immediately when it + * cannot, so the three callers that just want "go there and commit" -- typed + * value, wheel notch, double-click reset -- do not each repeat the branch. + */ + void commit_value(float value); + float min_ = 0.f; float max_ = 1.f; float value_ = 0.f; @@ -79,11 +101,21 @@ class ParamSlider : public Control std::string category_; std::string key_; ///< attribute name, for the defaults lookup on reset + /** @brief No usable range, so the thumb reports drag rate, not position. + * + * Fixed at construction: an attribute's constraints do not change under it. + */ + bool unbounded_ = false; + Glide *glide_ = nullptr; ///< animates the displayed position, 0..1 qreal norm_ = 0.0; ///< what is painted; may lag value_ mid-glide QLineEdit *field_ = nullptr; bool dragging_ = false; bool hovered_rail_ = false; + + // --- unbounded drag reference, both only meaningful while dragging_ + int drag_origin_x_ = 0; + float value_at_press_ = 0.f; }; } // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp index 0590e12..568229a 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -41,6 +41,7 @@ class ClipBox : public QWidget int body_height() const; QSize sizeHint() const override; + QSize minimumSizeHint() const override; protected: void resizeEvent(QResizeEvent *event) override; @@ -49,6 +50,8 @@ class ClipBox : public QWidget bool eventFilter(QObject *watched, QEvent *event) override; private: + void update_reveal_geometry(); + QWidget *body_ = nullptr; int reveal_ = 0; bool follow_ = true; diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp index 0970fba..c03df9d 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp @@ -41,6 +41,19 @@ struct SliderVisual std::string category; ///< selects the group accent for the rail fill bool modified = false; bool locked = false; + + /** @brief The range has no usable limits, so the thumb is a rate handle. + * + * Changes what the rail means. A bounded rail fills from its left edge to + * the thumb, because the thumb's position *is* the value. An unbounded one + * has no such position to fill towards: the thumb rests at the centre and + * reports drag distance, so a fill would be claiming a proportion that does + * not exist. + */ + bool unbounded = false; + + /// Drag in progress. Only read when `unbounded`, to mark the rest position. + bool dragging = false; }; /** @brief Paint label, rail well, accent fill and thumb. @@ -63,18 +76,22 @@ void paint_slider_row(QPainter &painter, * A bound of FLT_MAX or INT_MAX does not mean "a very wide slider", it means * "no limit". A rail a couple of hundred pixels wide cannot show that: every * value a user would type lands in the first pixel, and a drag moves the value - * by astronomical steps. That is the broken behaviour on the unbounded rows. + * by astronomical steps. * - * Declining them here lets them fall through to stock, whose SliderFloat has a - * proper unbounded mode: the handle sits centred at rest and drags relatively - * instead of mapping to an absolute position. Using the fallback chain is the - * point of the design registry, so this belongs in can_render() rather than as - * a special case inside the paint code. + * This used to gate can_render(), so those rows fell through to stock. That + * fixed the drag but cost more than it bought: it hit 197 rows, 86 of them the + * Seed that sits near the top of nearly every node, so almost every panel grew + * a stock row among the industrial ones. The sliders now carry their own + * unbounded mode instead, and this selects between the two. * * The sentinel test deliberately matches stock's `is_range_bounded()` exactly, * against the type's own limits. A looser threshold would leave a gap where a - * merely huge range is declined here but still counted as bounded there, which - * breaks it in both designs rather than neither. + * merely huge range counted as unbounded here but bounded there, so the two + * designs would disagree about which control a row gets. + * + * Note a half-open range still lands here: Seed is [0, INT_MAX], and a lower + * bound alone cannot give the rail a span. The real bound is still enforced, + * it just clamps the value rather than positioning the thumb. */ template bool has_usable_range(T lo, T hi) { diff --git a/MetaUI/qt/include/meta_qt/ui/theme.hpp b/MetaUI/qt/include/meta_qt/ui/theme.hpp index b07f074..181b362 100644 --- a/MetaUI/qt/include/meta_qt/ui/theme.hpp +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -63,7 +63,7 @@ struct Metrics // --- rail and thumb int rail_height = 6; - int rail_radius = 1; + int rail_radius = 3; int thumb_width = 10; int thumb_height = 18; @@ -82,11 +82,11 @@ struct Metrics int section_row_spacing = 10; int section_card_margin = 14; ///< inset of a card from the panel edge int section_card_gap = 10; ///< vertical gap between consecutive cards - int section_card_radius = 6; + int section_card_radius = 10; int row_bar_height = 30; ///< the bar a value row is drawn inside // --- shared - int radius = 2; + int radius = 4; int glide_ms = 260; ///< value glide; nothing snaps int switch_ms = 150; ///< switch knob slide int section_ms = 200; ///< disclosure rotation @@ -155,13 +155,18 @@ struct Theme QColor field_border_hover{"#5a5a5a"}; // --- ink. Only text encodes state; see state_ink(). - QColor ink_primary{"#e0e0e0"}; - QColor ink_section_title{"#d0d0d0"}; - QColor ink_secondary{"#9a9a9a"}; ///< value at default - QColor ink_dim{"#8a8a8a"}; - QColor ink_locked{"#606060"}; + // + // Every step here sits closer to white than the first pass did. The design + // was sampled at a contrast that looked right in isolation but reads as + // washed out next to the host's own panels, and the row label is the text a + // user spends the most time on. + QColor ink_primary{"#ececec"}; + QColor ink_section_title{"#e0e0e0"}; + QColor ink_secondary{"#b9b9b9"}; ///< value at default + QColor ink_dim{"#a4a4a4"}; + QColor ink_locked{"#6c6c6c"}; QColor ink_modified{"#ffffff"}; - QColor ink_icon{"#c9c9c9"}; + QColor ink_icon{"#d6d6d6"}; // --- metal QColor thumb_top{"#d6d6d6"}; diff --git a/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp b/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp index 317437e..8620f41 100644 --- a/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp @@ -16,7 +16,8 @@ class QPixmap; class QScrollArea; class QToolButton; -namespace meta::qt { +namespace meta::qt +{ class PresetGridWidget; @@ -26,7 +27,8 @@ class PresetGridWidget; // Custom-painted gradient bar with interactive stop handles. // --------------------------------------------------------------------------- -class GradientBarWidget : public QWidget { +class GradientBarWidget : public QWidget +{ Q_OBJECT public: @@ -37,7 +39,7 @@ class GradientBarWidget : public QWidget { static constexpr int RADIUS = 4; explicit GradientBarWidget(std::vector &stops, - QWidget *parent = nullptr); + QWidget *parent = nullptr); void sort_stops(); @@ -56,11 +58,11 @@ class GradientBarWidget : public QWidget { private: QRectF bar_rect() const; QRectF stop_rect(const Stop &s) const; - int hit_test(const QPoint &pos) const; + int hit_test(const QPoint &pos) const; std::vector &stops_; - int selected_idx_ = -1; - bool dragging_ = false; + int selected_idx_ = -1; + bool dragging_ = false; }; // --------------------------------------------------------------------------- @@ -81,13 +83,14 @@ class GradientBarWidget : public QWidget { // height never squashes or clips the gradient visualization. // --------------------------------------------------------------------------- -class GradientPicker : public QWidget { +class GradientPicker : public QWidget +{ Q_OBJECT public: - explicit GradientPicker(std::vector &stops, + explicit GradientPicker(std::vector &stops, const std::vector &presets, - QWidget *parent = nullptr); + QWidget *parent = nullptr); // Called externally when the attribute's preset list changes. void set_presets(const std::vector &presets); @@ -109,23 +112,24 @@ class GradientPicker : public QWidget { void edit_ended(); // committed (drag release, colour picked, preset applied) protected: - void resizeEvent(QResizeEvent *e) override; - bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *e) override; + bool eventFilter(QObject *watched, QEvent *event) override; QSize sizeHint() const override; QSize minimumSizeHint() const override; private: - struct Entry { + struct Entry + { Preset preset; - bool user = false; // true: GradientLibrary entry, false: host preset + bool user = false; // true: GradientLibrary entry, false: host preset }; QWidget *build_toolbar(); - void schedule_rebuild(); - void rebuild_entries(); - void rebuild_preset_grid(); - QPixmap make_swatch(const Entry &entry, bool favorite) const; - void apply_stops(const std::vector &stops); + void schedule_rebuild(); + void rebuild_entries(); + void rebuild_preset_grid(); + QPixmap make_swatch(const Entry &entry, bool favorite) const; + void apply_stops(const std::vector &stops); std::vector host_names() const; @@ -134,20 +138,20 @@ class GradientPicker : public QWidget { void on_export_clicked(); void show_entry_menu(Entry entry, const QPoint &global_pos); void export_presets(const std::vector &presets, - const QString &suggested_file); + const QString &suggested_file); - std::vector &stops_; + std::vector &stops_; std::vector presets_; // host presets (attribute metadata) - std::vector entries_; // host + library, display order + std::vector entries_; // host + library, display order GradientBarWidget *bar_widget_ = nullptr; - QToolButton *save_button_ = nullptr; - QToolButton *import_button_ = nullptr; - QToolButton *export_button_ = nullptr; - QComboBox *sort_combo_ = nullptr; - QScrollArea *scroll_area_ = nullptr; - PresetGridWidget *preset_grid_ = nullptr; - bool rebuild_pending_ = false; + QToolButton *save_button_ = nullptr; + QToolButton *import_button_ = nullptr; + QToolButton *export_button_ = nullptr; + QComboBox *sort_combo_ = nullptr; + QScrollArea *scroll_area_ = nullptr; + PresetGridWidget *preset_grid_ = nullptr; + bool rebuild_pending_ = false; static constexpr int SWATCH_W = 60; // each preset swatch width static constexpr int SWATCH_H = 32; // each preset swatch height diff --git a/MetaUI/qt/src/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp index 9621e20..78e9762 100644 --- a/MetaUI/qt/src/designs/industrial/combo.cpp +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -36,23 +37,41 @@ ComboPopup::ComboPopup(const Theme &theme, const QStringList &items, int current, QWidget *parent) - : QWidget(parent, Qt::Popup), + : QWidget(parent, + Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint), theme_(&theme), items_(items), current_(current), hovered_(current) { - // Must be set before the native window is created, which happens on the first - // show(). Setting it later leaves the unrevealed part of the surface painting - // opaque black instead of nothing. - // - // WA_NoSystemBackground is deliberately *not* set alongside it: together they - // leave the surface undefined here rather than clear. + // Windows needs BOTH the frameless flag and an alpha backing store before + // show(); otherwise the unrevealed part of the popup is an opaque black box. + // Disable the native popup shadow too: it outlines the full window even + // while the card is only partially revealed. paintEvent owns the border. setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_DeleteOnClose); setMouseTracking(true); setFocusPolicy(Qt::StrongFocus); + + animation_ = new QVariantAnimation(this); + animation_->setDuration(theme_->metrics.section_ms); + animation_->setEasingCurve(QEasingCurve::OutCubic); + connect(animation_, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant &value) + { + revealed_ = value.toInt(); + update(); + }); + connect(animation_, + &QVariantAnimation::finished, + this, + [this]() + { + if (closing_) close(); + }); } int ComboPopup::row_height() const { return kRowHeight; } @@ -85,30 +104,30 @@ void ComboPopup::popup_for(const QRect &field_global) // to full size, and resizing a native window every frame is expensive anyway. // Reveal the card inside a fixed, translucent window instead. setGeometry(left, y, width, full_height_); + revealed_ = 0; + closing_ = false; + animate_to(full_height_); show(); setFocus(Qt::PopupFocusReason); +} - open_animation_ = new QVariantAnimation(this); - open_animation_->setDuration(theme_->metrics.section_ms); - open_animation_->setEasingCurve(QEasingCurve::OutCubic); - open_animation_->setStartValue(0); - open_animation_->setEndValue(full_height_); - - connect(open_animation_, - &QVariantAnimation::valueChanged, - this, - [this](const QVariant &v) - { - revealed_ = v.toInt(); - update(); - }); - - open_animation_->start(); +void ComboPopup::animate_to(int height) +{ + animation_->stop(); + { + // Retarget from the current reveal without emitting samples at the old + // animation time, including when dismissed before opening has finished. + QSignalBlocker blocker(animation_); + animation_->setStartValue(revealed_); + animation_->setEndValue(height); + animation_->setCurrentTime(0); + } + animation_->start(); } QRect ComboPopup::card_rect() const { - const int h = revealed_ > 0 ? revealed_ : full_height_; + const int h = std::clamp(revealed_, 0, full_height_); // Opening downward, the card grows from its top edge, which sits against the // field. Flipped, it grows upward from its bottom edge, which is the edge @@ -119,7 +138,9 @@ QRect ComboPopup::card_rect() const int ComboPopup::index_at(const QPoint &pos) const { - if (!rect().contains(pos)) return -1; + if (!card_rect().contains(pos) || pos.y() < kPopupPadding || + pos.y() >= full_height_ - kPopupPadding) + return -1; const int index = (pos.y() - kPopupPadding) / row_height(); return index >= 0 && index < items_.size() ? index : -1; @@ -128,10 +149,15 @@ int ComboPopup::index_at(const QPoint &pos) const void ComboPopup::paintEvent(QPaintEvent *) { QPainter painter(this); + // Clear the entire backing store, including the area uncovered by closing. + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillRect(rect(), Qt::transparent); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); painter.setRenderHint(QPainter::Antialiasing, true); const Theme &t = *theme_; const QRect card = card_rect(); + if (card.isEmpty()) return; // Everything is clipped to the revealed card, so the rows stay put and are // uncovered rather than sliding. Laying them out against the animating height @@ -172,6 +198,7 @@ void ComboPopup::paintEvent(QPaintEvent *) void ComboPopup::mouseMoveEvent(QMouseEvent *event) { + if (closing_) return; const int index = index_at(event->pos()); if (index != hovered_) { @@ -182,9 +209,10 @@ void ComboPopup::mouseMoveEvent(QMouseEvent *event) void ComboPopup::mousePressEvent(QMouseEvent *event) { + if (closing_) return; // Overriding this at all suppresses Qt's built-in "press outside dismisses", // so an outside press has to be handled here. - if (!rect().contains(event->pos())) + if (!card_rect().contains(event->pos())) { close(); return; @@ -195,19 +223,21 @@ void ComboPopup::mousePressEvent(QMouseEvent *event) void ComboPopup::mouseReleaseEvent(QMouseEvent *event) { + if (closing_) return; const int index = index_at(event->pos()); if (index >= 0) { - Q_EMIT selected(index); close(); + Q_EMIT selected(index); return; } - if (!rect().contains(event->pos())) close(); + if (!card_rect().contains(event->pos())) close(); } void ComboPopup::keyPressEvent(QKeyEvent *event) { + if (closing_) return; switch (event->key()) { case Qt::Key_Down: @@ -220,8 +250,8 @@ void ComboPopup::keyPressEvent(QKeyEvent *event) return; case Qt::Key_Return: case Qt::Key_Enter: - if (hovered_ >= 0) Q_EMIT selected(hovered_); close(); + if (hovered_ >= 0) Q_EMIT selected(hovered_); return; case Qt::Key_Escape: close(); return; default: break; @@ -230,8 +260,26 @@ void ComboPopup::keyPressEvent(QKeyEvent *event) QWidget::keyPressEvent(event); } +void ComboPopup::closeEvent(QCloseEvent *event) +{ + if (!isVisible() || revealed_ == 0) + { + animation_->stop(); + QWidget::closeEvent(event); + return; + } + + // Keep the popup alive until its card reaches zero height. Further dismissal + // events must not restart the animation or select an item a second time. + event->ignore(); + if (closing_) return; + closing_ = true; + animate_to(0); +} + void ComboPopup::hideEvent(QHideEvent *event) { + animation_->stop(); // hideEvent rather than destroyed(): WA_DeleteOnClose defers deletion by an // event-loop pass, by which point the dismissing click has already been // processed and reopened the popup. diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index 188d7be..c1f72a9 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -36,7 +36,8 @@ void register_design() // // Resolved per section rather than captured, because the theme can be // set after the design registers. - return new Section(title, DesignRegistry::instance().theme(kDesignName)); + return new Section(title, + DesignRegistry::instance().theme(kDesignName)); }); // --- float: 58% of the rows in a Hesiod node panel diff --git a/MetaUI/qt/src/designs/industrial/int_slider.cpp b/MetaUI/qt/src/designs/industrial/int_slider.cpp index 6154bb4..7289128 100644 --- a/MetaUI/qt/src/designs/industrial/int_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -15,6 +16,22 @@ namespace meta::qt::industrial { +namespace +{ +/// Thumb position an unbounded row rests at: the middle of the rail. +constexpr qreal kRestNorm = 0.5; + +/** @brief Drag sensitivity for an unbounded row, in pixels per whole step. + * + * Taken from stock SliderInt's PPU_UNBOUNDED rather than picked again, so a + * Seed feels the same now that it no longer falls through to stock. + */ +constexpr qreal kPixelsPerStep = 4.0; + +/// Ctrl divides the step by this, Shift multiplies it. Stock's PPU_MULT_FINE. +constexpr qreal kFineMultiplier = 10.0; +} // namespace + IntSlider::IntSlider(Attribute &attr, const RowContext &ctx, QWidget *parent) @@ -26,8 +43,20 @@ IntSlider::IntSlider(Attribute &attr, min_ = meta::common::min(attr); max_ = meta::common::max(attr); + unbounded_ = !has_usable_range(min_, max_); + + // An inverted or empty range cannot clamp, and std::clamp with hi below lo + // is undefined. Widening to the type's own limits keeps every clamp below + // well formed; an attribute whose bounds contradict each other is already + // the unbounded case as far as the rail is concerned. + if (unbounded_ && !(max_ > min_)) + { + min_ = std::numeric_limits::lowest(); + max_ = std::numeric_limits::max(); + } + value_ = std::clamp(attr.value(), min_, max_); - norm_ = to_norm(value_); + norm_ = unbounded_ ? kRestNorm : to_norm(value_); setFixedHeight(theme().metrics.row_height); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); @@ -53,6 +82,12 @@ IntSlider::IntSlider(Attribute &attr, { norm_ = t; update(); + + // Unbounded: a thumb settling back to centre is not an edit. The + // drag published as it went and end_edit() fired on release, so + // ending one here would close an edit the user has since started. + if (unbounded_) return; + end_edit(); }); glide_->jump(norm_); @@ -78,8 +113,7 @@ IntSlider::IntSlider(Attribute &attr, return; } - begin_edit(); - apply_value(std::clamp(typed, min_, max_), true); + commit_value(typed); }); connect(field_, @@ -95,16 +129,22 @@ bool IntSlider::can_render(const Attribute &attr) !metadata.find(meta::keys::constraints::max)) return false; - // Declines unbounded ranges so they fall through to the stock input, which is - // the right control for a number with no limits. - return has_usable_range(meta::common::min(attr), meta::common::max(attr)); + // The bounds themselves are not screened: a range with no usable span is + // rendered as a rate handle rather than handed to another design. + return true; } void IntSlider::set(const int &value) { value_ = std::clamp(value, min_, max_); - glide_->jump(to_norm(value_)); // a model sync seats immediately - norm_ = to_norm(value_); + + // Unbounded: the thumb encodes drag distance, not the value, so a sync from + // the model leaves it where it rests. jump() also cancels a recentre still + // running, which would otherwise fight the position set here. + const qreal target = unbounded_ ? kRestNorm : to_norm(value_); + + glide_->jump(target); // a model sync seats immediately + norm_ = target; refresh_field(); update(); } @@ -141,6 +181,8 @@ void IntSlider::paintEvent(QPaintEvent *) visual.category = category_; visual.modified = is_modified(); visual.locked = is_locked(); + visual.unbounded = unbounded_; + visual.dragging = dragging_; QFont label_font = row_label_font(); visual.label = elide_label(QString::fromStdString(label_), @@ -181,6 +223,26 @@ void IntSlider::mousePressEvent(QMouseEvent *event) } setFocus(Qt::MouseFocusReason); + + if (unbounded_) + { + // Seat the thumb before the drag is measured. A recentre from the previous + // drag may still be running, and its finished() would otherwise arrive + // mid-drag; jump() cancels it without emitting one. + glide_->jump(kRestNorm); + norm_ = kRestNorm; + drag_origin_x_ = event->pos().x(); + value_at_press_ = value_; + + dragging_ = true; + begin_edit(); + update(); + + // Deliberately no set_from_position(): a rate drag measures from where the + // press landed, so pressing the rail must not move the value at all. + return; + } + dragging_ = true; begin_edit(); set_from_position(event->pos().x()); @@ -189,6 +251,13 @@ void IntSlider::mousePressEvent(QMouseEvent *event) void IntSlider::mouseMoveEvent(QMouseEvent *event) { if (!dragging_) return; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + return; + } + set_from_position(event->pos().x()); } @@ -197,6 +266,19 @@ void IntSlider::mouseReleaseEvent(QMouseEvent *event) if (!dragging_) return; dragging_ = false; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + + // The thumb eases back to rest rather than snapping, like everything else + // in this design. The edit is over as soon as the button is up, though: + // holding it open for the animation would stall the model sync behind it. + end_edit(); + glide_->to(kRestNorm); + return; + } + set_from_position(event->pos().x()); end_edit(); } @@ -214,8 +296,7 @@ void IntSlider::mouseDoubleClickEvent(QMouseEvent *event) try { dragging_ = false; - begin_edit(); - apply_value(std::clamp(std::any_cast(def), min_, max_), true); + commit_value(std::any_cast(def)); // the reset glides where it can } catch (const std::bad_any_cast &) { @@ -235,9 +316,11 @@ void IntSlider::handle_wheel(QWheelEvent *event) } // One notch is one unit, which is what an integer control should do - // regardless of how wide its range happens to be. - begin_edit(); - apply_value(std::clamp(value_ + steps, min_, max_), true); + // regardless of how wide its range happens to be. Widened to 64 bits before + // the clamp because a Seed sits in [0, INT_MAX] and value_ + steps would + // otherwise overflow at the top of it. + const long long stepped = static_cast(value_) + steps; + commit_value(int(std::clamp(stepped, min_, max_))); event->accept(); } @@ -278,19 +361,67 @@ void IntSlider::set_from_position(int x) apply_value(from_norm(t), false); } +void IntSlider::drag_by(int x, Qt::KeyboardModifiers modifiers) +{ + const int dx = x - drag_origin_x_; + + qreal ppu = kPixelsPerStep; + if (modifiers & Qt::ControlModifier) + ppu *= kFineMultiplier; + else if (modifiers & Qt::ShiftModifier) + ppu /= kFineMultiplier; + + // The thumb follows the cursor pixel for pixel but stops at the ends of the + // rail. Its travel is an affordance, not a measurement: the value carries on + // changing after the thumb has run out of room, which is the whole point of + // a rate control. + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + const int travel = std::max(1, g.rail.width() - theme().metrics.thumb_width); + + norm_ = std::clamp(kRestNorm + qreal(dx) / qreal(travel), 0.0, 1.0); + glide_->jump(norm_); // no easing under the cursor, and cancels any recentre + + // Whole steps only, measured from the value at the press rather than + // accumulated per event: an integer row must never show a fraction, and a + // drag out and back has to land exactly where it started. Widened to 64 bits + // because a Seed sits in [0, INT_MAX] and this would overflow near the top. + const long long moved = static_cast(qreal(dx) / ppu); + const long long target = static_cast(value_at_press_) + moved; + + apply_value(int(std::clamp(target, min_, max_)), false); +} + +void IntSlider::commit_value(int value) +{ + begin_edit(); + apply_value(std::clamp(value, min_, max_), !unbounded_); + + // A bounded row ends its edit when the glide settles. An unbounded one has + // no glide to wait on, so it ends here. + if (unbounded_) end_edit(); +} + void IntSlider::apply_value(int value, bool glide) { const bool changed = value != value_; value_ = value; - if (glide) - { - glide_->to(to_norm(value_)); - } - else + // Unbounded: the thumb is a rate affordance rather than a position, so it is + // never driven from the value. drag_by() owns it and release eases it home. + if (!unbounded_) { - glide_->jump(to_norm(value_)); // a drag tracks the cursor, no glide - norm_ = to_norm(value_); + if (glide) + { + glide_->to(to_norm(value_)); + } + else + { + glide_->jump(to_norm(value_)); // a drag tracks the cursor, no glide + norm_ = to_norm(value_); + } } refresh_field(); diff --git a/MetaUI/qt/src/designs/industrial/panel_chrome.cpp b/MetaUI/qt/src/designs/industrial/panel_chrome.cpp index a71153f..e29c8fe 100644 --- a/MetaUI/qt/src/designs/industrial/panel_chrome.cpp +++ b/MetaUI/qt/src/designs/industrial/panel_chrome.cpp @@ -17,7 +17,7 @@ QString scrollbar_stylesheet(const Theme &theme) "QScrollBar::handle:vertical {" " background: %1;" " min-height: 30px;" - " border-radius: 2px;" + " border-radius: %3px;" " margin: 2px 3px 2px 3px;" "}" "QScrollBar::handle:vertical:hover { background: %2; }" @@ -28,7 +28,8 @@ QString scrollbar_stylesheet(const Theme &theme) "QScrollBar::add-page:vertical," "QScrollBar::sub-page:vertical { background: transparent; }") .arg(theme.field_border.name()) - .arg(theme.field_border_hover.name()); + .arg(theme.field_border_hover.name()) + .arg(theme.metrics.radius); } QString tooltip_stylesheet(const Theme &theme) diff --git a/MetaUI/qt/src/designs/industrial/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp index a0b7898..358f6dd 100644 --- a/MetaUI/qt/src/designs/industrial/param_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -19,7 +20,20 @@ namespace meta::qt::industrial namespace { constexpr qreal kLogFloor = 1e-6; ///< below this a log mapping is undefined -} + +/// Thumb position an unbounded row rests at: the middle of the rail. +constexpr qreal kRestNorm = 0.5; + +/** @brief Drag sensitivity for an unbounded row, in pixels per unit. + * + * Taken from stock SliderFloat's PPU_F rather than picked again, so a row that + * used to fall through to stock feels the same now that it does not. + */ +constexpr qreal kPixelsPerUnit = 200.0; + +/// Ctrl divides the step by this, Shift multiplies it. Stock's PPU_MULT_FINE. +constexpr qreal kFineMultiplier = 10.0; +} // namespace ParamSlider::ParamSlider(Attribute &attr, const RowContext &ctx, @@ -36,12 +50,25 @@ ParamSlider::ParamSlider(Attribute &attr, false); decimals_ = meta::common::try_get_format_decimals(meta::common::format(attr)); + unbounded_ = !has_usable_range(min_, max_); + + // An inverted or empty range cannot clamp, and std::clamp with hi below lo + // is undefined. Widening to the type's own limits keeps every clamp below + // well formed; an attribute whose bounds contradict each other is already + // the unbounded case as far as the rail is concerned. + if (unbounded_ && !(max_ > min_)) + { + min_ = std::numeric_limits::lowest(); + max_ = std::numeric_limits::max(); + } + // A log mapping needs a strictly positive lower bound; fall back to linear - // rather than producing NaNs across the whole rail. - if (log_scale_ && min_ <= kLogFloor) log_scale_ = false; + // rather than producing NaNs across the whole rail. An unbounded range has + // no span to lay a mapping over in the first place. + if (log_scale_ && (unbounded_ || min_ <= kLogFloor)) log_scale_ = false; value_ = std::clamp(attr.value(), min_, max_); - norm_ = to_norm(value_); + norm_ = unbounded_ ? kRestNorm : to_norm(value_); setFixedHeight(theme().metrics.row_height); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); @@ -53,8 +80,16 @@ ParamSlider::ParamSlider(Attribute &attr, [this](qreal t) { norm_ = t; - value_ = from_norm(t); - refresh_field(); + + // Unbounded: the glide carries the thumb home after a drag and + // does nothing else. Deriving the value from the thumb here would + // drag it back towards the centre along with the thumb. + if (!unbounded_) + { + value_ = from_norm(t); + refresh_field(); + } + update(); }); @@ -66,9 +101,16 @@ ParamSlider::ParamSlider(Attribute &attr, [this](qreal t) { norm_ = t; + update(); + + // Unbounded: a thumb settling back to centre is not an edit. The + // drag published as it went and end_edit() fired on release, so a + // commit here would raise a second edit out of an animation the + // user has already let go of. + if (unbounded_) return; + value_ = from_norm(t); refresh_field(); - update(); notify_value_changed(); end_edit(); }); @@ -95,8 +137,7 @@ ParamSlider::ParamSlider(Attribute &attr, return; } - begin_edit(); - glide_->to(to_norm(std::clamp(typed, min_, max_))); + commit_value(typed); }); connect(field_, @@ -114,18 +155,22 @@ bool ParamSlider::can_render(const Attribute &attr) !metadata.find(meta::keys::constraints::max)) return false; - // Declines unbounded ranges so they fall through to the stock input, which is - // the right control for a number with no limits. - return has_usable_range(meta::common::min(attr), meta::common::max(attr)); + // The bounds themselves are not screened: a range with no usable span is + // rendered as a rate handle rather than handed to another design. + return true; } void ParamSlider::set(const float &value) { const float clamped = std::clamp(value, min_, max_); + // Unbounded: the thumb encodes drag distance, not the value, so a sync from + // the model leaves it where it rests. jump() also cancels a recentre still + // running, which would otherwise fight the position set here. + glide_->jump(unbounded_ ? kRestNorm : to_norm(clamped)); + // jump() emits tick(), which derives value_ back out of the normalised // position -- lossy under a log mapping. Seat the authoritative value after. - glide_->jump(to_norm(clamped)); // a model sync seats immediately, no glide value_ = clamped; refresh_field(); @@ -184,6 +229,8 @@ void ParamSlider::paintEvent(QPaintEvent *) visual.category = category_; visual.modified = is_modified(); visual.locked = is_locked(); + visual.unbounded = unbounded_; + visual.dragging = dragging_; QFont label_font = row_label_font(); visual.label = elide_label(QString::fromStdString(label_), @@ -228,6 +275,26 @@ void ParamSlider::mousePressEvent(QMouseEvent *event) } setFocus(Qt::MouseFocusReason); + + if (unbounded_) + { + // Seat the thumb before the drag is measured. A recentre from the previous + // drag may still be running, and its finished() would otherwise arrive + // mid-drag; jump() cancels it without emitting one. + glide_->jump(kRestNorm); + norm_ = kRestNorm; + drag_origin_x_ = event->pos().x(); + value_at_press_ = value_; + + dragging_ = true; + begin_edit(); + update(); + + // Deliberately no set_from_position(): a rate drag measures from where the + // press landed, so pressing the rail must not move the value at all. + return; + } + dragging_ = true; begin_edit(); set_from_position(event->pos().x()); @@ -236,6 +303,13 @@ void ParamSlider::mousePressEvent(QMouseEvent *event) void ParamSlider::mouseMoveEvent(QMouseEvent *event) { if (!dragging_) return; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + return; + } + set_from_position(event->pos().x()); } @@ -244,6 +318,19 @@ void ParamSlider::mouseReleaseEvent(QMouseEvent *event) if (!dragging_) return; dragging_ = false; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + + // The thumb eases back to rest rather than snapping, like everything else + // in this design. The edit is over as soon as the button is up, though: + // holding it open for the animation would stall the model sync behind it. + end_edit(); + glide_->to(kRestNorm); + return; + } + set_from_position(event->pos().x()); end_edit(); } @@ -260,10 +347,8 @@ void ParamSlider::mouseDoubleClickEvent(QMouseEvent *event) try { - const float target = std::clamp(std::any_cast(def), min_, max_); dragging_ = false; - begin_edit(); - glide_->to(to_norm(target)); // the reset glides like everything else + commit_value(std::any_cast(def)); // the reset glides where it can } catch (const std::bad_any_cast &) { @@ -282,6 +367,15 @@ void ParamSlider::handle_wheel(QWheelEvent *event) return; } + if (unbounded_) + { + // One unit per notch. A percentage of the rail is the wrong measure when + // the rail represents no span, and it is what stock does here too. + commit_value(value_ + float(steps)); + event->accept(); + return; + } + // One notch moves 1% of the rail, which stays sane under a log mapping. begin_edit(); glide_->to(std::clamp(norm_ + steps * 0.01, 0.0, 1.0)); @@ -335,6 +429,63 @@ void ParamSlider::apply_norm(qreal t) notify_value_changed(); } +void ParamSlider::drag_by(int x, Qt::KeyboardModifiers modifiers) +{ + const int dx = x - drag_origin_x_; + + qreal ppu = kPixelsPerUnit; + if (modifiers & Qt::ControlModifier) + ppu *= kFineMultiplier; + else if (modifiers & Qt::ShiftModifier) + ppu /= kFineMultiplier; + + // The thumb follows the cursor pixel for pixel but stops at the ends of the + // rail. Its travel is an affordance, not a measurement: the value carries on + // changing after the thumb has run out of room, which is the whole point of + // a rate control. + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + const int travel = std::max(1, g.rail.width() - theme().metrics.thumb_width); + + norm_ = std::clamp(kRestNorm + qreal(dx) / qreal(travel), 0.0, 1.0); + glide_->jump(norm_); // no easing under the cursor, and cancels any recentre + + // Measured from the value at the press rather than accumulated per event, so + // a drag out and back returns to exactly where it started. + apply_value(float(qreal(value_at_press_) + qreal(dx) / ppu)); +} + +void ParamSlider::apply_value(float value) +{ + const float clamped = std::clamp(value, min_, max_); + const bool changed = clamped != value_; + + value_ = clamped; + refresh_field(); // runs even unchanged, to normalise what was typed + update(); + + if (changed) notify_value_changed(); +} + +void ParamSlider::commit_value(float value) +{ + begin_edit(); + + const float clamped = std::clamp(value, min_, max_); + + if (!unbounded_) + { + glide_->to(to_norm(clamped)); // finished() commits and ends the edit + return; + } + + // Nothing to glide towards: the thumb is already at rest and stays there. + apply_value(clamped); + end_edit(); +} + QString ParamSlider::format_value(float value) const { return QString::number(value, 'f', decimals_); diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp index eb657ac..f9beaa8 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -64,13 +65,36 @@ QString header_stylesheet(const Theme &theme) " min-height: %3px;" " text-align: left;" " font-weight: bold;" + // The header is opaque and sits directly on top of the card + // the section paints, so any corner it does not round itself + // it squares off again. Leaving these unset is what made the + // top of a section look nothing like the bottom: the body + // rounds the lower pair through its own stylesheet, so only + // the upper pair came out square. + // + // All four here, because a collapsed section is nothing but + // its header and has to round the whole card on its own. + " border-top-left-radius: %6px;" + " border-top-right-radius: %6px;" + " border-bottom-left-radius: %6px;" + " border-bottom-right-radius: %6px;" "}" - "QToolButton:hover { background-color: %4; color: %5; }") + "QToolButton:hover { background-color: %4; color: %5; }" + // Expanded, the body underneath carries the bottom corners, + // so the header has to square off or the two round away from + // each other and leave a notch in the seam. Last in the sheet + // so it wins over the grouped rule above whatever else + // matches. + "QToolButton:checked {" + " border-bottom-left-radius: 0px;" + " border-bottom-right-radius: 0px;" + "}") .arg(theme.section_header.name()) .arg(theme.ink_section_title.name()) .arg(m.section_header_height) .arg(theme.section_header_hover.name()) - .arg(theme.ink_primary.name()); + .arg(theme.ink_primary.name()) + .arg(m.section_card_radius); } } // namespace @@ -104,7 +128,7 @@ bool ClipBox::eventFilter(QObject *watched, QEvent *event) if (watched == body_ && event->type() == QEvent::LayoutRequest) { body_->setGeometry(0, 0, width(), body_height()); - if (follow_) updateGeometry(); + if (follow_) update_reveal_geometry(); } return QWidget::eventFilter(watched, event); @@ -124,13 +148,44 @@ void ClipBox::set_reveal(int px) { follow_ = false; reveal_ = std::max(0, px); - updateGeometry(); + update_reveal_geometry(); } void ClipBox::follow_body() { follow_ = true; + update_reveal_geometry(); +} + +void ClipBox::update_reveal_geometry() +{ updateGeometry(); + if (!isVisible()) return; + + // LayoutRequest travels one parent per event-loop pass. During an animation + // that lets inner layouts squeeze sections into the previous frame's height + // before the scroll content learns its new minimum. Invalidate the whole + // chain first, then allocate from the outside in before anything is painted. + QList ancestors; + for (QWidget *widget = parentWidget(); widget; + widget = widget->parentWidget()) + { + if (auto *ancestor = widget->layout()) + { + ancestor->invalidate(); + ancestors.prepend(ancestor); + } + // The scroll content's minimum drives its viewport. Layouts outside that + // scroll area do not need to be recalculated on every animation tick. + QWidget *parent = widget->parentWidget(); + auto *scroll = parent ? qobject_cast( + parent->parentWidget()) + : nullptr; + if (scroll && scroll->viewport() == parent) break; + if (widget->isWindow()) break; + } + for (auto *ancestor : ancestors) + ancestor->activate(); } QSize ClipBox::sizeHint() const @@ -139,6 +194,15 @@ QSize ClipBox::sizeHint() const return QSize(w, follow_ ? body_height() : reveal_); } +QSize ClipBox::minimumSizeHint() const +{ + // A Fixed size policy alone does not protect the reveal when an ancestor + // layout is briefly smaller than its new size hint. Propagate the animated + // height as a minimum so the scroll content grows instead of clipping every + // taller section to the same height. Keep horizontal sizing independent. + return QSize(0, sizeHint().height()); +} + void ClipBox::resizeEvent(QResizeEvent *event) { // Keep the body at full height whatever this widget's height is. The crop is @@ -181,6 +245,15 @@ Section::Section(const QString &title, const Theme &theme, QWidget *parent) toggle_button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); toggle_button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + // Pin the header's height instead of leaving it to the stylesheet's + // min-height. A QSS min-height styles the button without raising the + // minimum the *layout* honours, so while a section is animating and the + // panel is handing out less height than the children want, the header is + // one of the things that gives. That is what makes the title text creep + // upward during a fast collapse and only settle once the animation ends. + // A fixed height cannot be taken from. + toggle_button->setFixedHeight(theme.metrics.section_header_height); + // Fixed vertically: the base class leaves sections Preferred, which lets the // panel's QVBoxLayout hand each one a share of the leftover space and // re-divide it whenever any section changes height. The trailing stretch in @@ -279,7 +352,7 @@ void Section::set_expanded(bool new_state) // The first call restores persisted state during construction, before // anything is on screen. Animating that would play every section open at // startup, so seat it directly. - if (first_apply_ || was_expanded == new_state) + if (first_apply_) { first_apply_ = false; animation_->stop(); @@ -294,11 +367,23 @@ void Section::set_expanded(bool new_state) return; } + // Reapplying state must not finish a transition that is already heading + // there. + if (was_expanded == new_state) return; + const int full = clip_->body_height(); + const int current = clip_->sizeHint().height(); animation_->stop(); // a running animation ignores a retargeted end value - animation_->setStartValue(new_state ? 0 : full); - animation_->setEndValue(new_state ? full : 0); + clip_->set_reveal(current); + { + // Updating endpoints can emit values at the previous animation time. + // Only publish samples after the new transition has been rewound. + QSignalBlocker blocker(animation_); + animation_->setStartValue(current); + animation_->setEndValue(new_state ? full : 0); + animation_->setCurrentTime(0); + } animation_->start(); update(); diff --git a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp index 9bdccdb..c135899 100644 --- a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp +++ b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp @@ -89,7 +89,10 @@ void paint_slider_row(QPainter &painter, m.rail_radius); // --- fill. Always the group accent; never a state colour. - if (geometry.fill.width() > 0) + // + // Skipped when unbounded: with no limits there is no proportion of the rail + // to fill, and a bar growing from the left would read as one. + if (!visual.unbounded && geometry.fill.width() > 0) { painter.setPen(Qt::NoPen); painter.setBrush(theme.rail_fill(visual.category, visual.locked)); @@ -99,6 +102,19 @@ void paint_slider_row(QPainter &painter, m.rail_radius); } + // --- rest marker. Only while an unbounded drag is under way, and only then: + // at rest the thumb covers this exactly, so drawing it always would look + // like a stray hairline under the thumb. + if (visual.unbounded && visual.dragging) + { + painter.setPen(Qt::NoPen); + painter.setBrush(theme.thumb_grip); + painter.drawRect(QRect(geometry.rail.center().x(), + geometry.thumb.top(), + 1, + geometry.thumb.height())); + } + // --- thumb painter.setOpacity(visual.locked ? theme.locked_thumb_alpha : 1.0); diff --git a/MetaUI/qt/src/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp index c1cff23..68a7b4f 100644 --- a/MetaUI/qt/src/ui/theme.cpp +++ b/MetaUI/qt/src/ui/theme.cpp @@ -76,7 +76,19 @@ QFont row_label_font() // the host is using, so the panel blends into the application instead of // announcing itself. QFont font = QApplication::font(); - font.setPixelSize(12); + + // 13px rather than 12. At 12 the labels sat a step below the host's own + // text and the panel read as small print; 13 matches the value field's mono + // face, so a row's two halves are the same size. + font.setPixelSize(13); + + // Medium rather than the default Normal. At this size a regular weight + // leaves the stems thin enough that antialiasing does most of the work of + // forming them, which reads as blurry rather than light. One step up puts + // enough ink in the glyph to look deliberate, without promoting the row + // label to the weight the section titles use. + font.setWeight(QFont::Medium); + return font; } @@ -149,11 +161,17 @@ Theme Theme::from_palette(const QPalette &palette, const std::string &name) // --- ink. Dimming blends towards the window, so it reads as "less // prominent" whichever side of the light/dark line the scheme sits on. + // + // Every blend is shallower than the first pass. Dimming towards the window + // is the right formula, but it was applied hard enough that a row label and + // its value both landed well short of the host's own text, which reads as + // washed out rather than as a hierarchy. These keep the same ordering with + // less distance between the steps. t.ink_primary = text; - t.ink_section_title = mix(text, window, 0.12); - t.ink_secondary = mix(text, window, 0.38); - t.ink_dim = mix(text, window, 0.48); - t.ink_icon = mix(text, window, 0.20); + t.ink_section_title = mix(text, window, 0.05); + t.ink_secondary = mix(text, window, 0.22); + t.ink_dim = mix(text, window, 0.34); + t.ink_icon = mix(text, window, 0.12); t.ink_locked = palette.color(QPalette::Disabled, QPalette::Text); // BrightText is the maximum-contrast ink, which is exactly what "modified" diff --git a/MetaUI/qt/src/widgets/gradient_picker.cpp b/MetaUI/qt/src/widgets/gradient_picker.cpp index 05413a0..3d5eb73 100644 --- a/MetaUI/qt/src/widgets/gradient_picker.cpp +++ b/MetaUI/qt/src/widgets/gradient_picker.cpp @@ -33,21 +33,26 @@ #include "meta/logger.hpp" #include "meta_qt/widgets/gradient_picker.hpp" -namespace meta::qt { +namespace meta::qt +{ // --------------------------------------------------------------------------- // Colour conversion helpers // --------------------------------------------------------------------------- -static QColor to_qcolor(const std::array &c) { +static QColor to_qcolor(const std::array &c) +{ return QColor(int(std::clamp(c[0], 0.f, 1.f) * 255.f), int(std::clamp(c[1], 0.f, 1.f) * 255.f), int(std::clamp(c[2], 0.f, 1.f) * 255.f), int(std::clamp(c[3], 0.f, 1.f) * 255.f)); } -static std::array from_qcolor(const QColor &c) { - return {float(c.redF()), float(c.greenF()), float(c.blueF()), +static std::array from_qcolor(const QColor &c) +{ + return {float(c.redF()), + float(c.greenF()), + float(c.blueF()), float(c.alphaF())}; } @@ -57,17 +62,18 @@ static std::array from_qcolor(const QColor &c) { // Gives the process-wide GradientLibrary a per-user file on first use, unless // the host already chose one (set_path() before any picker exists). -static void ensure_gradient_library() { +static void ensure_gradient_library() +{ GradientLibrary &lib = GradientLibrary::instance(); - if (!lib.path().empty()) - return; + if (!lib.path().empty()) return; - QString dir = - QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + QString dir = QStandardPaths::writableLocation( + QStandardPaths::AppConfigLocation); if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - if (dir.isEmpty()) { + if (dir.isEmpty()) + { static bool warned = false; if (!warned) Logger::log()->warn("GradientPicker: no writable config location, the " @@ -83,13 +89,16 @@ static void ensure_gradient_library() { lib.path().string()); } -static QString gradient_file_filter() { +static QString gradient_file_filter() +{ return QObject::tr("Gradient files (*.json);;All files (*)"); } -static void draw_star(QPainter &p, const QPointF ¢er, qreal radius) { +static void draw_star(QPainter &p, const QPointF ¢er, qreal radius) +{ QPolygonF star; - for (int i = 0; i < 10; ++i) { + for (int i = 0; i < 10; ++i) + { const qreal r = (i % 2 == 0) ? radius : radius * 0.45; const qreal a = -std::numbers::pi / 2.0 + i * std::numbers::pi / 5.0; star << QPointF(center.x() + r * std::cos(a), center.y() + r * std::sin(a)); @@ -104,22 +113,26 @@ static void draw_star(QPainter &p, const QPointF ¢er, qreal radius) { // --------------------------------------------------------------------------- GradientBarWidget::GradientBarWidget(std::vector &stops, QWidget *parent) - : QWidget(parent), stops_(stops) { + : QWidget(parent), stops_(stops) +{ setFixedHeight(TOTAL_H); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } -void GradientBarWidget::sort_stops() { - std::sort(stops_.begin(), stops_.end(), [](const Stop &a, const Stop &b) { - return a.position < b.position; - }); +void GradientBarWidget::sort_stops() +{ + std::sort(stops_.begin(), + stops_.end(), + [](const Stop &a, const Stop &b) + { return a.position < b.position; }); } -void GradientBarWidget::paintEvent(QPaintEvent *) { +void GradientBarWidget::paintEvent(QPaintEvent *) +{ QPainter p(this); p.setRenderHint(QPainter::Antialiasing); - const QRectF br = bar_rect(); + const QRectF br = bar_rect(); const QPalette &pal = palette(); // Gradient bar @@ -133,14 +146,15 @@ void GradientBarWidget::paintEvent(QPaintEvent *) { } // Stop handles - for (int i = 0; i < static_cast(stops_.size()); ++i) { + for (int i = 0; i < static_cast(stops_.size()); ++i) + { const QRectF r = stop_rect(stops_[i]); - const bool sel = (i == selected_idx_); + const bool sel = (i == selected_idx_); // Small triangle pointing up from bar bottom to handle const float cx = float(r.center().x()); const float ty = float(br.bottom()); - QPolygonF tri; + QPolygonF tri; tri << QPointF(cx - 4, ty + 8) << QPointF(cx + 4, ty + 8) << QPointF(cx, ty + 1); p.setPen(Qt::NoPen); @@ -156,39 +170,50 @@ void GradientBarWidget::paintEvent(QPaintEvent *) { } } -void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) { +void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) +{ const int idx = hit_test(e->pos()); - if (idx >= 0) { + if (idx >= 0) + { // Edit existing stop colour const QColor picked = QColorDialog::getColor( - to_qcolor(stops_[idx].color), this, QString(), + to_qcolor(stops_[idx].color), + this, + QString(), QColorDialog::ShowAlphaChannel | QColorDialog::DontUseNativeDialog); - if (picked.isValid()) { + if (picked.isValid()) + { stops_[idx].color = from_qcolor(picked); update(); Q_EMIT value_changed(); Q_EMIT edit_ended(); } - } else if (bar_rect().contains(QPointF(e->pos()))) { + } + else if (bar_rect().contains(QPointF(e->pos()))) + { // Add new stop - const double pos = std::clamp( - (e->pos().x() - bar_rect().left()) / bar_rect().width(), 0.0, 1.0); + const double pos = std::clamp((e->pos().x() - bar_rect().left()) / + bar_rect().width(), + 0.0, + 1.0); constexpr double eps = 1e-3; - const bool too_close = - std::any_of(stops_.begin(), stops_.end(), [&](const Stop &s) { - return std::abs(double(s.position) - pos) < eps; - }); - - if (!too_close) { + const bool too_close = std::any_of( + stops_.begin(), + stops_.end(), + [&](const Stop &s) + { return std::abs(double(s.position) - pos) < eps; }); + + if (!too_close) + { stops_.push_back({float(pos), {1.f, 1.f, 1.f, 1.f}}); sort_stops(); - auto it = - std::find_if(stops_.begin(), stops_.end(), [pos](const Stop &s) { - return s.position == float(pos); - }); + auto it = std::find_if(stops_.begin(), + stops_.end(), + [pos](const Stop &s) + { return s.position == float(pos); }); if (it != stops_.end()) selected_idx_ = static_cast(std::distance(stops_.begin(), it)); update(); @@ -198,36 +223,41 @@ void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) { } } -void GradientBarWidget::mousePressEvent(QMouseEvent *e) { - if (e->button() == Qt::LeftButton) { +void GradientBarWidget::mousePressEvent(QMouseEvent *e) +{ + if (e->button() == Qt::LeftButton) + { selected_idx_ = hit_test(e->pos()); dragging_ = selected_idx_ >= 0; update(); } } -void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) { +void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) +{ if (!dragging_ || selected_idx_ < 0 || selected_idx_ >= static_cast(stops_.size())) return; const QRectF br = bar_rect(); - if (br.width() <= 0) - return; + if (br.width() <= 0) return; - const double pos = - std::clamp((e->pos().x() - br.left()) / br.width(), 0.0, 1.0); + const double pos = std::clamp((e->pos().x() - br.left()) / br.width(), + 0.0, + 1.0); stops_[selected_idx_].position = float(pos); // Maintain sorted order while keeping selected_idx_ tracking the moved stop while (selected_idx_ > 0 && - stops_[selected_idx_].position < stops_[selected_idx_ - 1].position) { + stops_[selected_idx_].position < stops_[selected_idx_ - 1].position) + { std::swap(stops_[selected_idx_], stops_[selected_idx_ - 1]); --selected_idx_; } while (selected_idx_ + 1 < static_cast(stops_.size()) && - stops_[selected_idx_].position > stops_[selected_idx_ + 1].position) { + stops_[selected_idx_].position > stops_[selected_idx_ + 1].position) + { std::swap(stops_[selected_idx_], stops_[selected_idx_ + 1]); ++selected_idx_; } @@ -236,26 +266,28 @@ void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) { Q_EMIT value_changed(); } -void GradientBarWidget::mouseReleaseEvent(QMouseEvent *) { - if (dragging_) { +void GradientBarWidget::mouseReleaseEvent(QMouseEvent *) +{ + if (dragging_) + { dragging_ = false; Q_EMIT edit_ended(); } } -void GradientBarWidget::contextMenuEvent(QContextMenuEvent *e) { +void GradientBarWidget::contextMenuEvent(QContextMenuEvent *e) +{ const int idx = hit_test(e->pos()); - if (idx < 0) - return; + if (idx < 0) return; // Only offer removal when at least 3 stops (keep minimum 2). - if (static_cast(stops_.size()) <= 2) - return; + if (static_cast(stops_.size()) <= 2) return; - QMenu menu(this); + QMenu menu(this); QAction *rm = menu.addAction(QObject::tr("Remove stop")); - if (menu.exec(e->globalPos()) == rm) { + if (menu.exec(e->globalPos()) == rm) + { stops_.erase(stops_.begin() + idx); if (selected_idx_ == idx) @@ -269,18 +301,21 @@ void GradientBarWidget::contextMenuEvent(QContextMenuEvent *e) { } } -QRectF GradientBarWidget::bar_rect() const { +QRectF GradientBarWidget::bar_rect() const +{ return QRectF(PAD, PAD, width() - 2 * PAD, BAR_H); } -QRectF GradientBarWidget::stop_rect(const Stop &s) const { +QRectF GradientBarWidget::stop_rect(const Stop &s) const +{ const QRectF br = bar_rect(); const double cx = br.left() + double(s.position) * br.width(); const double cy = br.bottom() + 3 + STOP_R; return QRectF(cx - STOP_R, cy - STOP_R, STOP_R * 2, STOP_R * 2); } -int GradientBarWidget::hit_test(const QPoint &pos) const { +int GradientBarWidget::hit_test(const QPoint &pos) const +{ for (int i = static_cast(stops_.size()) - 1; i >= 0; --i) if (stop_rect(stops_[i]).adjusted(-2, -2, 2, 2).contains(QPointF(pos))) return i; @@ -291,12 +326,18 @@ int GradientBarWidget::hit_test(const QPoint &pos) const { // PresetGridWidget: responsive grid that wraps swatches based on width // --------------------------------------------------------------------------- -class PresetGridWidget : public QWidget { +class PresetGridWidget : public QWidget +{ public: - explicit PresetGridWidget(int swatch_w, int swatch_h, int spacing = 4, + explicit PresetGridWidget(int swatch_w, + int swatch_h, + int spacing = 4, QWidget *parent = nullptr) - : QWidget(parent), swatch_w_(swatch_w), swatch_h_(swatch_h), - spacing_(spacing) { + : QWidget(parent), + swatch_w_(swatch_w), + swatch_h_(swatch_h), + spacing_(spacing) + { // Tell the layout system that our height depends on our width, so // QVBoxLayout / QScrollArea can query heightForWidth() instead of // relying on a stale, width-independent sizeHint(). @@ -307,18 +348,20 @@ class PresetGridWidget : public QWidget { init_layout(); } - void set_buttons(const std::vector &buttons) { + void set_buttons(const std::vector &buttons) + { buttons_ = buttons; current_cols_ = -1; reflow(width()); } - void reflow(int avail_w) { - if (buttons_.empty()) - return; + void reflow(int avail_w) + { + if (buttons_.empty()) return; const int cols = compute_cols(avail_w); - if (cols == current_cols_) { + if (cols == current_cols_) + { const int h = heightForWidth(avail_w); setMinimumHeight(h); return; @@ -331,7 +374,8 @@ class PresetGridWidget : public QWidget { init_layout(); for (size_t i = 0; i < buttons_.size(); ++i) - grid_->addWidget(buttons_[i], static_cast(i / cols), + grid_->addWidget(buttons_[i], + static_cast(i / cols), static_cast(i % cols)); const int h = heightForWidth(avail_w); @@ -341,43 +385,46 @@ class PresetGridWidget : public QWidget { bool hasHeightForWidth() const override { return true; } - int heightForWidth(int w) const override { - if (buttons_.empty()) - return 0; + int heightForWidth(int w) const override + { + if (buttons_.empty()) return 0; const int cols = compute_cols(w); const int rows = (static_cast(buttons_.size()) + cols - 1) / cols; return 4 + rows * swatch_h_ + (rows - 1) * spacing_; } - QSize sizeHint() const override { - if (buttons_.empty()) - return QSize(0, 0); + QSize sizeHint() const override + { + if (buttons_.empty()) return QSize(0, 0); const int cols = current_cols_ > 0 ? current_cols_ : 1; const int w = 4 + cols * swatch_w_ + (cols - 1) * spacing_; return QSize(w, heightForWidth(width() > 0 ? width() : w)); } - QSize minimumSizeHint() const override { - if (buttons_.empty()) - return QSize(0, 0); + QSize minimumSizeHint() const override + { + if (buttons_.empty()) return QSize(0, 0); return QSize(swatch_w_ + 4, heightForWidth(width() > 0 ? width() : swatch_w_ + 4)); } protected: - void resizeEvent(QResizeEvent *event) override { + void resizeEvent(QResizeEvent *event) override + { QWidget::resizeEvent(event); reflow(event->size().width()); } private: - int compute_cols(int avail_w) const { + int compute_cols(int avail_w) const + { const int margins_w = 4; return std::max(1, (avail_w - margins_w + spacing_) / (swatch_w_ + spacing_)); } - void init_layout() { + void init_layout() + { grid_ = new QGridLayout(this); grid_->setContentsMargins(2, 2, 2, 2); grid_->setSpacing(spacing_); @@ -385,11 +432,11 @@ class PresetGridWidget : public QWidget { grid_->setSizeConstraint(QLayout::SetNoConstraint); } - int swatch_w_; - int swatch_h_; - int spacing_; - int current_cols_ = -1; - QGridLayout *grid_ = nullptr; + int swatch_w_; + int swatch_h_; + int spacing_; + int current_cols_ = -1; + QGridLayout *grid_ = nullptr; std::vector buttons_; }; @@ -397,10 +444,11 @@ class PresetGridWidget : public QWidget { // Constructor // --------------------------------------------------------------------------- -GradientPicker::GradientPicker(std::vector &stops, +GradientPicker::GradientPicker(std::vector &stops, const std::vector &presets, - QWidget *parent) - : QWidget(parent), stops_(stops), presets_(presets) { + QWidget *parent) + : QWidget(parent), stops_(stops), presets_(presets) +{ ensure_gradient_library(); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -435,9 +483,13 @@ GradientPicker::GradientPicker(std::vector &stops, // the toolbar stays under the bar instead of floating mid-widget. main_layout->addStretch(0); - connect(bar_widget_, &GradientBarWidget::value_changed, this, + connect(bar_widget_, + &GradientBarWidget::value_changed, + this, &GradientPicker::value_changed); - connect(bar_widget_, &GradientBarWidget::edit_ended, this, + connect(bar_widget_, + &GradientBarWidget::edit_ended, + this, &GradientPicker::edit_ended); // Any picker (or the host) editing the library refreshes this grid @@ -451,13 +503,15 @@ GradientPicker::GradientPicker(std::vector &stops, // Toolbar // --------------------------------------------------------------------------- -QWidget *GradientPicker::build_toolbar() { +QWidget *GradientPicker::build_toolbar() +{ auto *bar = new QWidget(this); auto *layout = new QHBoxLayout(bar); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(4); - const auto make_button = [bar](const QString &text, const QString &tip) { + const auto make_button = [bar](const QString &text, const QString &tip) + { auto *button = new QToolButton(bar); button->setText(text); button->setToolTip(tip); @@ -470,16 +524,22 @@ QWidget *GradientPicker::build_toolbar() { save_button_ = make_button(tr("Save..."), tr("Save the current gradient to your library")); - import_button_ = - make_button(tr("Import..."), tr("Import gradients from JSON files")); - export_button_ = - make_button(tr("Export..."), tr("Export your library to a JSON file")); - - connect(save_button_, &QToolButton::clicked, this, + import_button_ = make_button(tr("Import..."), + tr("Import gradients from JSON files")); + export_button_ = make_button(tr("Export..."), + tr("Export your library to a JSON file")); + + connect(save_button_, + &QToolButton::clicked, + this, &GradientPicker::on_save_clicked); - connect(import_button_, &QToolButton::clicked, this, + connect(import_button_, + &QToolButton::clicked, + this, &GradientPicker::on_import_clicked); - connect(export_button_, &QToolButton::clicked, this, + connect(export_button_, + &QToolButton::clicked, + this, &GradientPicker::on_export_clicked); sort_combo_ = new QComboBox(bar); @@ -491,7 +551,9 @@ QWidget *GradientPicker::build_toolbar() { // `activated` fires for user picks only, so syncing the index from the // library in rebuild_preset_grid() cannot loop back. - connect(sort_combo_, QOverload::of(&QComboBox::activated), this, + connect(sort_combo_, + QOverload::of(&QComboBox::activated), + this, [](int index) { GradientLibrary::instance().set_sort( static_cast(index)); @@ -511,40 +573,44 @@ QWidget *GradientPicker::build_toolbar() { // Preset grid // --------------------------------------------------------------------------- -void GradientPicker::set_presets(const std::vector &presets) { +void GradientPicker::set_presets(const std::vector &presets) +{ presets_ = presets; rebuild_preset_grid(); } -void GradientPicker::update_bar() { - if (bar_widget_) - bar_widget_->update(); +void GradientPicker::update_bar() +{ + if (bar_widget_) bar_widget_->update(); } -void GradientPicker::schedule_rebuild() { +void GradientPicker::schedule_rebuild() +{ // Deferred: the notification may come from a slot of a swatch button that // the rebuild is about to delete, and bursts (imports) coalesce. - if (rebuild_pending_) - return; + if (rebuild_pending_) return; rebuild_pending_ = true; QMetaObject::invokeMethod( this, - [this]() { + [this]() + { rebuild_pending_ = false; rebuild_preset_grid(); }, Qt::QueuedConnection); } -void GradientPicker::rebuild_entries() { +void GradientPicker::rebuild_entries() +{ const GradientLibrary &lib = GradientLibrary::instance(); - const GradientSort sort = lib.sort(); + const GradientSort sort = lib.sort(); - struct Keyed { - Entry entry; - bool favorite; - float key; + struct Keyed + { + Entry entry; + bool favorite; + float key; std::string lower_name; std::size_t index; }; @@ -552,17 +618,23 @@ void GradientPicker::rebuild_entries() { std::vector keyed; keyed.reserve(presets_.size() + lib.presets().size()); - const auto push = [&](const Preset &preset, bool user) { - Keyed k{Entry{preset, user}, lib.is_favorite(preset.name), 0.f, preset.name, + const auto push = [&](const Preset &preset, bool user) + { + Keyed k{Entry{preset, user}, + lib.is_favorite(preset.name), + 0.f, + preset.name, keyed.size()}; - std::transform(k.lower_name.begin(), k.lower_name.end(), + std::transform(k.lower_name.begin(), + k.lower_name.end(), k.lower_name.begin(), [](unsigned char c) { return char(std::tolower(c)); }); if (sort == GradientSort::Luminance) k.key = gradient_luminance(preset.stops); - else if (sort == GradientSort::Hue) { + else if (sort == GradientSort::Hue) + { const float hue = gradient_hue(preset.stops); k.key = hue < 0.f ? 1e6f : hue; // achromatic last } @@ -575,22 +647,22 @@ void GradientPicker::rebuild_entries() { for (const auto &preset : lib.presets()) push(preset, true); - std::stable_sort(keyed.begin(), keyed.end(), - [sort](const Keyed &a, const Keyed &b) { - if (a.favorite != b.favorite) - return a.favorite; + std::stable_sort(keyed.begin(), + keyed.end(), + [sort](const Keyed &a, const Keyed &b) + { + if (a.favorite != b.favorite) return a.favorite; - switch (sort) { + switch (sort) + { case GradientSort::Name: return a.lower_name < b.lower_name; case GradientSort::Luminance: case GradientSort::Hue: - if (a.key != b.key) - return a.key < b.key; + if (a.key != b.key) return a.key < b.key; return a.lower_name < b.lower_name; case GradientSort::Default: - default: - return a.index < b.index; + default: return a.index < b.index; } }); @@ -600,8 +672,9 @@ void GradientPicker::rebuild_entries() { entries_.push_back(std::move(k.entry)); } -QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { - QPixmap pix(SWATCH_W, SWATCH_H); +QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const +{ + QPixmap pix(SWATCH_W, SWATCH_H); QPainter pp(&pix); pp.setRenderHint(QPainter::Antialiasing); @@ -618,10 +691,10 @@ QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { QString::fromStdString(entry.preset.name)); // Favourite star (top-left) and library marker (top-right) - if (favorite) - draw_star(pp, QPointF(8, 8), 5.5); + if (favorite) draw_star(pp, QPointF(8, 8), 5.5); - if (entry.user) { + if (entry.user) + { pp.setPen(QPen(QColor(40, 40, 40), 1)); pp.setBrush(Qt::white); pp.drawEllipse(QPointF(pix.width() - 7, 7), 3, 3); @@ -631,35 +704,39 @@ QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { pp.setPen(QPen(QColor(80, 80, 80), 1)); pp.setBrush(Qt::NoBrush); pp.drawRoundedRect(pix.rect().adjusted(0, 0, -1, -1), - GradientBarWidget::RADIUS, GradientBarWidget::RADIUS); + GradientBarWidget::RADIUS, + GradientBarWidget::RADIUS); return pix; } -void GradientPicker::rebuild_preset_grid() { +void GradientPicker::rebuild_preset_grid() +{ const GradientLibrary &lib = GradientLibrary::instance(); rebuild_entries(); - if (sort_combo_) { + if (sort_combo_) + { const QSignalBlocker blocker(sort_combo_); sort_combo_->setCurrentIndex(static_cast(lib.sort())); } - if (export_button_) - export_button_->setEnabled(!lib.presets().empty()); + if (export_button_) export_button_->setEnabled(!lib.presets().empty()); scroll_area_->setVisible(!entries_.empty()); // Delete all existing child buttons inside preset_grid_ - qDeleteAll(preset_grid_->findChildren( - QString(), Qt::FindDirectChildrenOnly)); + qDeleteAll( + preset_grid_->findChildren(QString(), + Qt::FindDirectChildrenOnly)); std::vector buttons; buttons.reserve(entries_.size()); - for (std::size_t i = 0; i < entries_.size(); ++i) { - const Entry &entry = entries_[i]; - const bool favorite = lib.is_favorite(entry.preset.name); + for (std::size_t i = 0; i < entries_.size(); ++i) + { + const Entry &entry = entries_[i]; + const bool favorite = lib.is_favorite(entry.preset.name); const QString name = QString::fromStdString(entry.preset.name); auto *btn = new QPushButton(preset_grid_); @@ -678,11 +755,16 @@ void GradientPicker::rebuild_preset_grid() { btn->setContextMenuPolicy(Qt::CustomContextMenu); const std::vector preset_stops = entry.preset.stops; - connect(btn, &QPushButton::clicked, this, + connect(btn, + &QPushButton::clicked, + this, [this, preset_stops]() { apply_stops(preset_stops); }); - connect(btn, &QPushButton::customContextMenuRequested, this, - [this, btn, i](const QPoint &pos) { + connect(btn, + &QPushButton::customContextMenuRequested, + this, + [this, btn, i](const QPoint &pos) + { if (i < entries_.size()) show_entry_menu(entries_[i], btn->mapToGlobal(pos)); }); @@ -697,9 +779,11 @@ void GradientPicker::rebuild_preset_grid() { updateGeometry(); } -void GradientPicker::apply_stops(const std::vector &stops) { +void GradientPicker::apply_stops(const std::vector &stops) +{ stops_ = stops; - if (bar_widget_) { + if (bar_widget_) + { bar_widget_->sort_stops(); bar_widget_->update(); } @@ -707,7 +791,8 @@ void GradientPicker::apply_stops(const std::vector &stops) { Q_EMIT edit_ended(); } -std::vector GradientPicker::host_names() const { +std::vector GradientPicker::host_names() const +{ std::vector names; names.reserve(presets_.size()); for (const auto &preset : presets_) @@ -715,7 +800,8 @@ std::vector GradientPicker::host_names() const { return names; } -std::vector GradientPicker::entry_names() const { +std::vector GradientPicker::entry_names() const +{ std::vector names; names.reserve(entries_.size()); for (const auto &entry : entries_) @@ -727,7 +813,8 @@ std::vector GradientPicker::entry_names() const { // Library actions // --------------------------------------------------------------------------- -std::string GradientPicker::save_current_as_preset(const std::string &name) { +std::string GradientPicker::save_current_as_preset(const std::string &name) +{ GradientLibrary &lib = GradientLibrary::instance(); Preset preset; @@ -737,33 +824,41 @@ std::string GradientPicker::save_current_as_preset(const std::string &name) { return lib.add(std::move(preset)); } -void GradientPicker::on_save_clicked() { +void GradientPicker::on_save_clicked() +{ GradientLibrary &lib = GradientLibrary::instance(); - bool ok = false; + bool ok = false; const QString text = QInputDialog::getText( - this, tr("Save gradient"), tr("Preset name:"), QLineEdit::Normal, - QString::fromStdString(lib.unique_name("Gradient", host_names())), &ok); + this, + tr("Save gradient"), + tr("Preset name:"), + QLineEdit::Normal, + QString::fromStdString(lib.unique_name("Gradient", host_names())), + &ok); - if (!ok || text.trimmed().isEmpty()) - return; + if (!ok || text.trimmed().isEmpty()) return; save_current_as_preset(text.trimmed().toStdString()); } -void GradientPicker::on_import_clicked() { +void GradientPicker::on_import_clicked() +{ const QStringList files = QFileDialog::getOpenFileNames( - this, tr("Import gradients"), QString(), gradient_file_filter()); - if (files.isEmpty()) - return; + this, + tr("Import gradients"), + QString(), + gradient_file_filter()); + if (files.isEmpty()) return; GradientLibrary &lib = GradientLibrary::instance(); - QStringList failed; - std::size_t imported = 0; + QStringList failed; + std::size_t imported = 0; - for (const QString &file : files) { - const GradientImportReport report = - lib.import_file(std::filesystem::path(file.toStdString())); + for (const QString &file : files) + { + const GradientImportReport report = lib.import_file( + std::filesystem::path(file.toStdString())); if (!report.ok) failed << QFileInfo(file).fileName(); else @@ -772,49 +867,58 @@ void GradientPicker::on_import_clicked() { Logger::log()->trace("GradientPicker::on_import_clicked: {} imported from " "{} file(s), {} failed", - imported, files.size(), failed.size()); + imported, + files.size(), + failed.size()); if (!failed.isEmpty()) QMessageBox::warning( - this, tr("Import gradients"), + this, + tr("Import gradients"), tr("No gradients could be read from:\n%1").arg(failed.join('\n'))); } -void GradientPicker::on_export_clicked() { +void GradientPicker::on_export_clicked() +{ export_presets(GradientLibrary::instance().presets(), "gradients.json"); } void GradientPicker::export_presets(const std::vector &presets, - const QString &suggested_file) { - QString file = QFileDialog::getSaveFileName( - this, tr("Export gradients"), suggested_file, gradient_file_filter()); - if (file.isEmpty()) - return; - if (!file.endsWith(".json", Qt::CaseInsensitive)) - file += ".json"; + const QString &suggested_file) +{ + QString file = QFileDialog::getSaveFileName(this, + tr("Export gradients"), + suggested_file, + gradient_file_filter()); + if (file.isEmpty()) return; + if (!file.endsWith(".json", Qt::CaseInsensitive)) file += ".json"; if (!GradientLibrary::instance().export_file( - std::filesystem::path(file.toStdString()), presets)) - QMessageBox::warning(this, tr("Export gradients"), + std::filesystem::path(file.toStdString()), + presets)) + QMessageBox::warning(this, + tr("Export gradients"), tr("Could not write \"%1\".").arg(file)); } -void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) { +void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) +{ // `entry` is a copy on purpose: the menu runs a nested event loop during // which entries_ may be rebuilt. - GradientLibrary &lib = GradientLibrary::instance(); + GradientLibrary &lib = GradientLibrary::instance(); const std::string name = entry.preset.name; QMenu menu(this); - QAction *favorite = - menu.addAction(lib.is_favorite(name) ? tr("Remove from favorites") - : tr("Add to favorites")); + QAction *favorite = menu.addAction(lib.is_favorite(name) + ? tr("Remove from favorites") + : tr("Add to favorites")); QAction *rename = nullptr; QAction *replace = nullptr; QAction *remove = nullptr; - if (entry.user) { + if (entry.user) + { menu.addSeparator(); rename = menu.addAction(tr("Rename...")); replace = menu.addAction(tr("Replace with current gradient")); @@ -825,33 +929,44 @@ void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) { QAction *export_action = menu.addAction(tr("Export...")); QAction *chosen = menu.exec(global_pos); - if (!chosen) - return; + if (!chosen) return; - if (chosen == favorite) { + if (chosen == favorite) + { lib.set_favorite(name, !lib.is_favorite(name)); - } else if (chosen == rename) { - bool ok = false; - const QString text = QInputDialog::getText( - this, tr("Rename gradient"), tr("New name:"), QLineEdit::Normal, - QString::fromStdString(name), &ok); - if (!ok || text.trimmed().isEmpty()) - return; + } + else if (chosen == rename) + { + bool ok = false; + const QString text = QInputDialog::getText(this, + tr("Rename gradient"), + tr("New name:"), + QLineEdit::Normal, + QString::fromStdString(name), + &ok); + if (!ok || text.trimmed().isEmpty()) return; if (!lib.rename(name, text.trimmed().toStdString())) QMessageBox::warning( - this, tr("Rename gradient"), + this, + tr("Rename gradient"), tr("A gradient named \"%1\" already exists.").arg(text.trimmed())); - } else if (chosen == replace) { + } + else if (chosen == replace) + { lib.update(name, stops_); - } else if (chosen == remove) { - const auto answer = - QMessageBox::question(this, tr("Delete gradient"), - tr("Delete \"%1\" from your library?") - .arg(QString::fromStdString(name))); - if (answer == QMessageBox::Yes) - lib.remove(name); - } else if (chosen == export_action) { + } + else if (chosen == remove) + { + const auto answer = QMessageBox::question( + this, + tr("Delete gradient"), + tr("Delete \"%1\" from your library?") + .arg(QString::fromStdString(name))); + if (answer == QMessageBox::Yes) lib.remove(name); + } + else if (chosen == export_action) + { export_presets({entry.preset}, QString::fromStdString(name) + ".json"); } } @@ -860,30 +975,35 @@ void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) { // Geometry // --------------------------------------------------------------------------- -void GradientPicker::resizeEvent(QResizeEvent *e) { +void GradientPicker::resizeEvent(QResizeEvent *e) +{ QWidget::resizeEvent(e); - if (scroll_area_ && scroll_area_->viewport() && preset_grid_) { + if (scroll_area_ && scroll_area_->viewport() && preset_grid_) + { preset_grid_->reflow(scroll_area_->viewport()->width()); } } -bool GradientPicker::eventFilter(QObject *watched, QEvent *event) { +bool GradientPicker::eventFilter(QObject *watched, QEvent *event) +{ if (scroll_area_ && watched == scroll_area_->viewport() && - event->type() == QEvent::Resize) { + event->type() == QEvent::Resize) + { auto *re = static_cast(event); - if (preset_grid_) - preset_grid_->reflow(re->size().width()); + if (preset_grid_) preset_grid_->reflow(re->size().width()); } return QWidget::eventFilter(watched, event); } -QSize GradientPicker::sizeHint() const { +QSize GradientPicker::sizeHint() const +{ const int top_h = GradientBarWidget::TOTAL_H + 4 + TOOLBAR_H; const int preset_h = entries_.empty() ? 0 : (SWATCH_H + 4) * 3 + 8; return {300, top_h + (entries_.empty() ? 0 : 4 + preset_h)}; } -QSize GradientPicker::minimumSizeHint() const { +QSize GradientPicker::minimumSizeHint() const +{ const int top_h = GradientBarWidget::TOTAL_H + 4 + TOOLBAR_H; const int preset_h = entries_.empty() ? 0 : SWATCH_H + 8; return {160, top_h + (entries_.empty() ? 0 : 4 + preset_h)}; diff --git a/tests/test_qt/gradient_picker_snap/main.cpp b/tests/test_qt/gradient_picker_snap/main.cpp index 99cdaa2..ad530af 100644 --- a/tests/test_qt/gradient_picker_snap/main.cpp +++ b/tests/test_qt/gradient_picker_snap/main.cpp @@ -16,17 +16,20 @@ using meta::GradientSort; using meta::Preset; using meta::Stop; -static Preset make(const std::string &name, std::vector stops) { +static Preset make(const std::string &name, std::vector stops) +{ return {name, std::move(stops)}; } -static void snap(QWidget &w, const QString &path) { +static void snap(QWidget &w, const QString &path) +{ w.resize(320, 260); w.grab().save(path); } -int main(int argc, char **argv) { - QApplication app(argc, argv); +int main(int argc, char **argv) +{ + QApplication app(argc, argv); const QString dir = argc > 1 ? argv[1] : "."; QDir().mkpath(dir); @@ -41,21 +44,25 @@ int main(int argc, char **argv) { {{0.f, {0.78f, 0.86f, 1.f, 1.f}}, {1.f, {1.f, 1.f, 1.f, 1.f}}}), make("Sand", {{0.f, {0.76f, 0.7f, 0.5f, 1.f}}, {1.f, {0.94f, 0.9f, 0.7f, 1.f}}}), - make("Grass", {{0.f, {0.12f, 0.27f, 0.12f, 1.f}}, - {1.f, {0.5f, 0.75f, 0.3f, 1.f}}}), + make( + "Grass", + {{0.f, {0.12f, 0.27f, 0.12f, 1.f}}, {1.f, {0.5f, 0.75f, 0.3f, 1.f}}}), make("Ocean", {{0.f, {0.f, 0.05f, 0.25f, 1.f}}, {1.f, {0.2f, 0.6f, 0.9f, 1.f}}}), - make("Lava", {{0.f, {0.1f, 0.f, 0.f, 1.f}}, - {0.6f, {0.9f, 0.2f, 0.f, 1.f}}, - {1.f, {1.f, 0.9f, 0.3f, 1.f}}}), + make("Lava", + {{0.f, {0.1f, 0.f, 0.f, 1.f}}, + {0.6f, {0.9f, 0.2f, 0.f, 1.f}}, + {1.f, {1.f, 0.9f, 0.3f, 1.f}}}), make("Greys", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {1.f, 1.f, 1.f, 1.f}}})}; - lib.add(make("My sunset", {{0.f, {0.2f, 0.f, 0.3f, 1.f}}, - {0.5f, {0.9f, 0.3f, 0.2f, 1.f}}, - {1.f, {1.f, 0.8f, 0.4f, 1.f}}})); - lib.add(make("Mint", {{0.f, {0.f, 0.3f, 0.25f, 1.f}}, - {1.f, {0.6f, 1.f, 0.85f, 1.f}}})); + lib.add(make("My sunset", + {{0.f, {0.2f, 0.f, 0.3f, 1.f}}, + {0.5f, {0.9f, 0.3f, 0.2f, 1.f}}, + {1.f, {1.f, 0.8f, 0.4f, 1.f}}})); + lib.add( + make("Mint", + {{0.f, {0.f, 0.3f, 0.25f, 1.f}}, {1.f, {0.6f, 1.f, 0.85f, 1.f}}})); lib.set_favorite("Ocean", true); lib.set_favorite("Mint", true); @@ -63,8 +70,11 @@ int main(int argc, char **argv) { {0.5f, {0.9f, 0.5f, 0.1f, 1.f}}, {1.f, {1.f, 1.f, 0.8f, 1.f}}}; - for (GradientSort sort : {GradientSort::Default, GradientSort::Name, - GradientSort::Luminance, GradientSort::Hue}) { + for (GradientSort sort : {GradientSort::Default, + GradientSort::Name, + GradientSort::Luminance, + GradientSort::Hue}) + { lib.set_sort(sort); meta::qt::GradientPicker picker(stops, host); snap(picker, diff --git a/tests/test_qt/test_combo_animation/CMakeLists.txt b/tests/test_qt/test_combo_animation/CMakeLists.txt new file mode 100644 index 0000000..e037321 --- /dev/null +++ b/tests/test_qt/test_combo_animation/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(test_combo_animation main.cpp) +target_link_libraries(test_combo_animation meta_qt) diff --git a/tests/test_qt/test_combo_animation/main.cpp b/tests/test_qt/test_combo_animation/main.cpp new file mode 100644 index 0000000..e24c46a --- /dev/null +++ b/tests/test_qt/test_combo_animation/main.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#endif + +#include + +#include "meta_qt/designs/industrial/combo.hpp" + +using meta::qt::industrial::ComboPopup; + +namespace +{ +int failures = 0; + +void check(bool condition, const char *message) +{ + if (!condition) + { + ++failures; + std::cerr << message << '\n'; + } +} + +QImage frame(ComboPopup *popup) +{ + return popup->grab().toImage().convertToFormat(QImage::Format_ARGB32); +} + +int opaque_rows(const QImage &image) +{ + int count = 0; + for (int y = 0; y < image.height(); ++y) + if (qAlpha(image.pixel(image.width() / 2, y))) ++count; + return count; +} + +void key(ComboPopup *popup, int code) +{ + QKeyEvent event(QEvent::KeyPress, code, Qt::NoModifier); + QApplication::sendEvent(popup, &event); +} + +void mouse(ComboPopup *popup, QEvent::Type type, const QPoint &pos) +{ + QMouseEvent event(type, + QPointF(pos), + QPointF(popup->mapToGlobal(pos)), + Qt::LeftButton, + Qt::LeftButton, + Qt::NoModifier); + QApplication::sendEvent(popup, &event); +} + +void exercise(bool flipped, int dismissal, bool interrupt_open) +{ + meta::qt::Theme theme; + QWidget owner; + QPointer popup = new ComboPopup( + theme, + {"add", "exclusion", "gradients", "maximum", "replace"}, + 0, + &owner); + popup->setAttribute(Qt::WA_DontShowOnScreen); + int selections = 0; + QObject::connect(popup, + &ComboPopup::selected, + [&selections](int) { ++selections; }); + + const QRect screen = QApplication::primaryScreen()->availableGeometry(); + const QRect field(screen.left() + 100, + flipped ? screen.bottom() - 30 : screen.top() + 30, + 240, + 24); + popup->popup_for(field); + auto *animation = popup->findChild(); + animation->pause(); + + check(popup->windowFlags().testFlag(Qt::FramelessWindowHint), + "Windows translucent popup must be frameless"); +#ifdef Q_OS_WIN + if (QApplication::platformName() == "windows") + { + // QWidget::grab() cannot see the OS shadow. Inspect the native window + // class as well so a full-size outline cannot escape the pixel checks. + const auto hwnd = reinterpret_cast(popup->winId()); + check((GetClassLongPtr(hwnd, GCL_STYLE) & CS_DROPSHADOW) == 0, + "native popup shadow must not outline the unrevealed window"); + } +#endif + check(opaque_rows(frame(popup)) == 0, + "zero reveal must not flash the fully open card"); + + animation->setCurrentTime(theme.metrics.section_ms / 5); + const QImage opening = frame(popup); + const int partial = opaque_rows(opening); + check(partial > 0 && partial < opening.height(), + "opening must reveal gradually"); + const int hidden_y = flipped ? 0 : opening.height() - 1; + check(qAlpha(opening.pixel(opening.width() / 2, hidden_y)) == 0, + "unrevealed popup surface must be transparent"); + + if (!interrupt_open) animation->setCurrentTime(theme.metrics.section_ms); + const int before_close = opaque_rows(frame(popup)); + switch (dismissal) + { + case 0: key(popup, Qt::Key_Escape); break; + case 1: mouse(popup, QEvent::MouseButtonPress, QPoint(-10, -10)); break; + case 2: key(popup, Qt::Key_Return); break; + case 3: + mouse(popup, + QEvent::MouseButtonRelease, + QPoint(popup->width() / 2, flipped ? popup->height() - 16 : 16)); + break; + } + check(popup && popup->isVisible(), + "dismissal must keep popup visible while closing"); + if (!popup || !popup->isVisible()) return; + check(opaque_rows(frame(popup)) == before_close, + "closing must start at current reveal without jumping"); + animation->pause(); + animation->setCurrentTime(theme.metrics.section_ms / 3); + const int during_close = opaque_rows(frame(popup)); + check(during_close > 0 && during_close < before_close, + "closing must shrink and clear the previously painted surface"); + key(popup, Qt::Key_Return); + popup->close(); + check(opaque_rows(frame(popup)) == during_close, + "repeated dismissal must not restart closing"); + check(selections == (dismissal >= 2 ? 1 : 0), + "selection must be emitted exactly once, and never on dismissal"); + + animation->setCurrentTime(theme.metrics.section_ms); + check(!popup->isVisible(), "popup must hide when closing finishes"); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + check(popup.isNull(), "closed popup must be deleted"); +} +} // namespace + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + for (bool flipped : {false, true}) + for (int dismissal = 0; dismissal < 4; ++dismissal) + for (bool interrupt : {false, true}) + exercise(flipped, dismissal, interrupt); + std::cout << "16 popup scenarios; failures=" << failures << '\n'; + return failures ? 1 : 0; +} diff --git a/tests/test_qt/test_section_animation/CMakeLists.txt b/tests/test_qt/test_section_animation/CMakeLists.txt new file mode 100644 index 0000000..9ec01a9 --- /dev/null +++ b/tests/test_qt/test_section_animation/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(test_section_animation main.cpp) +target_link_libraries(test_section_animation meta_qt) diff --git a/tests/test_qt/test_section_animation/main.cpp b/tests/test_qt/test_section_animation/main.cpp new file mode 100644 index 0000000..604e889 --- /dev/null +++ b/tests/test_qt/test_section_animation/main.cpp @@ -0,0 +1,172 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "meta_qt/designs/industrial/section.hpp" + +using meta::qt::industrial::Section; + +namespace +{ +void flush() +{ + for (int i = 0; i < 8; ++i) + { + QCoreApplication::sendPostedEvents(); + QCoreApplication::processEvents(); + } +} + +// Observe actual paint events as well as settled geometry: a transient bad +// layout must not be hidden by draining several rounds of LayoutRequest first. +class PanelCheck : public QObject +{ +public: + std::array
sections{}; + int failures = 0; + int paints = 0; + bool watching = false; + + void check() + { + for (int i = 0; i < 4; ++i) + { + auto *s = sections[i]; + if (s->height() != s->sizeHint().height()) + { + if (failures < 5) + std::cerr << "section=" << i << " actual=" << s->height() + << " hint=" << s->sizeHint().height() << '\n'; + fail("section height differs from its requested reveal"); + } + const auto *layout = s->parentWidget()->layout(); + const int expected_y = i ? sections[i - 1]->geometry().bottom() + 1 + + layout->spacing() + : layout->contentsMargins().top(); + if (s->y() != expected_y) fail("section position or gap changed"); + } + } + + void fail(const char *message) + { + if (failures++ < 5) std::cerr << message << '\n'; + } + + bool eventFilter(QObject *, QEvent *event) override + { + if (watching && event->type() == QEvent::Paint) + { + ++paints; + check(); + } + return false; + } +}; + +int exercise(int viewport_height) +{ + meta::qt::Theme theme; + QScrollArea scroll; + scroll.setAttribute(Qt::WA_DontShowOnScreen); + scroll.setWidgetResizable(true); + scroll.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + scroll.resize(520, viewport_height); + auto *container = new QWidget; + auto *outer = new QVBoxLayout(container); + outer->setAlignment(Qt::AlignTop); + auto *node = new QWidget; + auto *node_layout = new QVBoxLayout(node); + node_layout->setAlignment(Qt::AlignTop); + outer->addWidget(node); + auto *page = new QWidget; + auto *layout = new QVBoxLayout(page); + layout->setAlignment(Qt::AlignTop); + node_layout->addWidget(page); + + PanelCheck checker; + const std::array rows{3, 3, 2, 8}; + for (int i = 0; i < 4; ++i) + { + auto *s = new Section(QString::number(i), theme); + checker.sections[i] = s; + for (int j = 0; j < rows[i]; ++j) + { + auto *row = new QLabel(QString("Parameter %1").arg(j)); + row->setFixedHeight(36); + s->content_layout->addWidget(row); + } + layout->addWidget(s); + s->set_expanded(i != 3); + s->installEventFilter(&checker); + } + scroll.setWidget(container); + scroll.show(); + flush(); + checker.watching = true; + + auto advance = [&](Section *s, int ms) + { + s->findChild()->setCurrentTime(ms); + flush(); + checker.check(); + }; + auto *last = checker.sections.back(); + for (bool expanded : {true, false}) + { + last->set_expanded(expanded); + for (int ms = 0; ms <= theme.metrics.section_ms; ms += 16) + advance(last, ms); + advance(last, theme.metrics.section_ms); + } + + // Reverse each section while the last card is collapsed. The new transition + // must start at the displayed height, including repeated same-state calls. + for (int i = 0; i < 4; ++i) + { + auto *s = checker.sections[i]; + const bool initially_open = i != 3; + s->set_expanded(!initially_open); + advance(s, 48); + const int before = s->sizeHint().height(); + s->set_expanded(initially_open); + if (s->sizeHint().height() != before) + checker.fail("reversing animation jumps to an endpoint"); + advance(s, 32); + const int repeated = s->sizeHint().height(); + s->set_expanded(initially_open); + if (s->sizeHint().height() != repeated) + checker.fail("same-state request jumps to an endpoint"); + advance(s, theme.metrics.section_ms); + } + + // Fully open sections must still follow body changes after animation ends. + auto *extra = new QLabel("Added parameter"); + extra->setFixedHeight(36); + const int old_height = checker.sections[0]->height(); + checker.sections[0]->content_layout->addWidget(extra); + flush(); + checker.check(); + if (checker.sections[0]->height() <= old_height) + checker.fail("expanded section did not follow new content"); + + std::cout << "viewport=" << viewport_height << " paints=" << checker.paints + << " failures=" << checker.failures << '\n'; + checker.watching = false; + return checker.failures; +} +} // namespace + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + int failures = 0; + for (int height : {1100, 740, 400}) + failures += exercise(height); + return failures ? 1 : 0; +} diff --git a/tests/unittests/test_gradient_library.cpp b/tests/unittests/test_gradient_library.cpp index 1196900..f82c7d0 100644 --- a/tests/unittests/test_gradient_library.cpp +++ b/tests/unittests/test_gradient_library.cpp @@ -12,23 +12,27 @@ #include "meta/ext/color_gradient/gradient_library.hpp" -namespace { +namespace +{ -std::filesystem::path make_temp_dir(const std::string &tag) { - const auto dir = - std::filesystem::temp_directory_path() / ("meta_gradient_library_" + tag); +std::filesystem::path make_temp_dir(const std::string &tag) +{ + const auto dir = std::filesystem::temp_directory_path() / + ("meta_gradient_library_" + tag); std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); return dir; } -meta::Preset make_preset(const std::string &name, float r, float g, float b) { +meta::Preset make_preset(const std::string &name, float r, float g, float b) +{ return {name, {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {r, g, b, 1.f}}}}; } } // namespace -TEST(GradientLibraryTest, AddMakesNamesUnique) { +TEST(GradientLibraryTest, AddMakesNamesUnique) +{ meta::GradientLibrary lib; EXPECT_EQ(lib.add(make_preset("Fire", 1.f, 0.f, 0.f)), "Fire"); @@ -45,7 +49,8 @@ TEST(GradientLibraryTest, AddMakesNamesUnique) { EXPECT_FALSE(lib.has("Water")); } -TEST(GradientLibraryTest, AddSortsStopsByPosition) { +TEST(GradientLibraryTest, AddSortsStopsByPosition) +{ meta::GradientLibrary lib; lib.add({"Rev", {{1.f, {1.f, 1.f, 1.f, 1.f}}, {0.f, {0.f, 0.f, 0.f, 1.f}}}}); @@ -56,7 +61,8 @@ TEST(GradientLibraryTest, AddSortsStopsByPosition) { EXPECT_EQ(lib.find("nope"), nullptr); } -TEST(GradientLibraryTest, UpdateRenameRemove) { +TEST(GradientLibraryTest, UpdateRenameRemove) +{ meta::GradientLibrary lib; lib.add(make_preset("A", 1.f, 0.f, 0.f)); lib.add(make_preset("B", 0.f, 1.f, 0.f)); @@ -83,7 +89,8 @@ TEST(GradientLibraryTest, UpdateRenameRemove) { EXPECT_TRUE(lib.presets().empty()); } -TEST(GradientLibraryTest, FavoritesFollowRenameAndRemoval) { +TEST(GradientLibraryTest, FavoritesFollowRenameAndRemoval) +{ meta::GradientLibrary lib; lib.add(make_preset("A", 1.f, 0.f, 0.f)); @@ -104,10 +111,11 @@ TEST(GradientLibraryTest, FavoritesFollowRenameAndRemoval) { EXPECT_TRUE(lib.favorites().empty()); } -TEST(GradientLibraryTest, ChangedFiresOncePerEffectiveMutation) { +TEST(GradientLibraryTest, ChangedFiresOncePerEffectiveMutation) +{ meta::GradientLibrary lib; - int count = 0; - auto conn = lib.changed.subscribe([&count]() { ++count; }); + int count = 0; + auto conn = lib.changed.subscribe([&count]() { ++count; }); lib.add(make_preset("A", 1.f, 0.f, 0.f)); EXPECT_EQ(count, 1); @@ -125,7 +133,8 @@ TEST(GradientLibraryTest, ChangedFiresOncePerEffectiveMutation) { EXPECT_EQ(count, 4); } -TEST(GradientLibraryTest, JsonRoundTrip) { +TEST(GradientLibraryTest, JsonRoundTrip) +{ meta::GradientLibrary lib; lib.add(make_preset("A", 1.f, 0.f, 0.f)); lib.add(make_preset("B", 0.f, 1.f, 0.f)); @@ -155,7 +164,8 @@ TEST(GradientLibraryTest, JsonRoundTrip) { EXPECT_TRUE(empty.presets().empty()); } -TEST(GradientLibraryTest, AutosaveAndLoad) { +TEST(GradientLibraryTest, AutosaveAndLoad) +{ const auto dir = make_temp_dir("autosave"); const auto file = dir / "nested" / "gradients.json"; @@ -194,7 +204,8 @@ TEST(GradientLibraryTest, AutosaveAndLoad) { EXPECT_TRUE(std::filesystem::exists(dir / "manual.json")); } -TEST(GradientLibraryTest, CorruptFileLeavesStateUntouched) { +TEST(GradientLibraryTest, CorruptFileLeavesStateUntouched) +{ const auto dir = make_temp_dir("corrupt"); meta::GradientLibrary lib; @@ -217,7 +228,8 @@ TEST(GradientLibraryTest, CorruptFileLeavesStateUntouched) { EXPECT_EQ(lib.presets().size(), 1u); } -TEST(GradientLibraryTest, ImportPolicy) { +TEST(GradientLibraryTest, ImportPolicy) +{ const auto dir = make_temp_dir("import"); meta::GradientLibrary source; @@ -251,7 +263,8 @@ TEST(GradientLibraryTest, ImportPolicy) { EXPECT_EQ(lib.presets().size(), 4u); } -TEST(GradientLibraryTest, ParsesHesiodFileShapes) { +TEST(GradientLibraryTest, ParsesHesiodFileShapes) +{ // Hesiod data/color_gradients/.json: Meta's ColorGradient::json_to // shape, no name -> the fallback (file stem) is used const auto per_file = nlohmann::json::parse(R"({ @@ -260,7 +273,7 @@ TEST(GradientLibraryTest, ParsesHesiodFileShapes) { {"color": [0.1, 0.2, 0.3, 1.0], "position": 0.0}, {"color": [0.5, 0.6, 0.7, 1.0], "position": 1.0} ]})"); - auto parsed = meta::parse_gradient_file(per_file, "051c4a"); + auto parsed = meta::parse_gradient_file(per_file, "051c4a"); ASSERT_TRUE(parsed.has_value()); ASSERT_EQ(parsed->size(), 1u); EXPECT_EQ((*parsed)[0].name, "051c4a"); @@ -305,10 +318,14 @@ TEST(GradientLibraryTest, ParsesHesiodFileShapes) { EXPECT_FALSE(meta::parse_gradient_file(nlohmann::json::object()).has_value()); } -TEST(GradientLibraryTest, SortNamesRoundTrip) { +TEST(GradientLibraryTest, SortNamesRoundTrip) +{ using meta::GradientSort; - for (GradientSort s : {GradientSort::Default, GradientSort::Name, - GradientSort::Luminance, GradientSort::Hue}) { + for (GradientSort s : {GradientSort::Default, + GradientSort::Name, + GradientSort::Luminance, + GradientSort::Hue}) + { const auto back = meta::gradient_sort_from_string(meta::to_string(s)); ASSERT_TRUE(back.has_value()); EXPECT_EQ(*back, s); diff --git a/tests/unittests/test_gradient_metrics.cpp b/tests/unittests/test_gradient_metrics.cpp index 9b2d4d6..d8d3e16 100644 --- a/tests/unittests/test_gradient_metrics.cpp +++ b/tests/unittests/test_gradient_metrics.cpp @@ -9,18 +9,21 @@ #include "meta/ext/color_gradient/gradient_metrics.hpp" -namespace { +namespace +{ -meta::Stop stop(float t, float r, float g, float b, float a = 1.f) { +meta::Stop stop(float t, float r, float g, float b, float a = 1.f) +{ return {t, {r, g, b, a}}; } } // namespace -TEST(GradientMetricsTest, StopAndPresetEquality) { +TEST(GradientMetricsTest, StopAndPresetEquality) +{ const meta::Preset a{"x", {stop(0.f, 0.f, 0.f, 0.f), stop(1.f, 1.f, 1.f, 1.f)}}; - meta::Preset b = a; + meta::Preset b = a; EXPECT_EQ(a, b); b.stops[1].color[0] = 0.5f; EXPECT_NE(a, b); @@ -29,7 +32,8 @@ TEST(GradientMetricsTest, StopAndPresetEquality) { EXPECT_NE(a, b); } -TEST(GradientMetricsTest, SampleInterpolatesAndClamps) { +TEST(GradientMetricsTest, SampleInterpolatesAndClamps) +{ // deliberately unsorted const std::vector stops = {stop(1.f, 1.f, 1.f, 1.f, 1.f), stop(0.f, 0.f, 0.f, 0.f, 0.f)}; @@ -46,7 +50,8 @@ TEST(GradientMetricsTest, SampleInterpolatesAndClamps) { EXPECT_FLOAT_EQ(empty[3], 1.f); } -TEST(GradientMetricsTest, SampleHoldsEndColorsOutsideStopRange) { +TEST(GradientMetricsTest, SampleHoldsEndColorsOutsideStopRange) +{ const std::vector stops = {stop(0.25f, 1.f, 0.f, 0.f), stop(0.75f, 0.f, 0.f, 1.f)}; EXPECT_FLOAT_EQ(meta::sample_gradient(stops, 0.1f)[0], 1.f); @@ -54,7 +59,8 @@ TEST(GradientMetricsTest, SampleHoldsEndColorsOutsideStopRange) { EXPECT_NEAR(meta::sample_gradient(stops, 0.5f)[0], 0.5f, 1e-5f); } -TEST(GradientMetricsTest, LuminanceOrdersDarkToLight) { +TEST(GradientMetricsTest, LuminanceOrdersDarkToLight) +{ const std::vector black = {stop(0.f, 0.f, 0.f, 0.f), stop(1.f, 0.f, 0.f, 0.f)}; const std::vector grey = {stop(0.f, 0.5f, 0.5f, 0.5f), @@ -71,7 +77,8 @@ TEST(GradientMetricsTest, LuminanceOrdersDarkToLight) { EXPECT_FLOAT_EQ(meta::gradient_luminance({}), 0.f); } -TEST(GradientMetricsTest, HueOfPrimaries) { +TEST(GradientMetricsTest, HueOfPrimaries) +{ const std::vector red = {stop(0.f, 1.f, 0.f, 0.f), stop(1.f, 1.f, 0.f, 0.f)}; const std::vector green = {stop(0.f, 0.f, 1.f, 0.f), @@ -85,15 +92,17 @@ TEST(GradientMetricsTest, HueOfPrimaries) { EXPECT_NEAR(meta::gradient_hue(blue), 240.f, 0.5f); } -TEST(GradientMetricsTest, HueAveragesAcrossWrapAround) { +TEST(GradientMetricsTest, HueAveragesAcrossWrapAround) +{ // 350 deg -> 10 deg through red: the circular mean sits near 0, not 180 const std::vector stops = {stop(0.f, 1.f, 0.f, 1.f / 6.f), stop(1.f, 1.f, 1.f / 6.f, 0.f)}; - const float h = meta::gradient_hue(stops); + const float h = meta::gradient_hue(stops); EXPECT_TRUE(h < 5.f || h > 355.f) << h; } -TEST(GradientMetricsTest, AchromaticGradientHasNoHue) { +TEST(GradientMetricsTest, AchromaticGradientHasNoHue) +{ const std::vector ramp = {stop(0.f, 0.f, 0.f, 0.f), stop(1.f, 1.f, 1.f, 1.f)}; EXPECT_FLOAT_EQ(meta::gradient_hue(ramp), -1.f); diff --git a/tests/unittests/test_gradient_picker.cpp b/tests/unittests/test_gradient_picker.cpp index a1c02ef..141199a 100644 --- a/tests/unittests/test_gradient_picker.cpp +++ b/tests/unittests/test_gradient_picker.cpp @@ -15,24 +15,29 @@ #include "meta/ext/color_gradient/gradient_library.hpp" #include "meta_qt/widgets/gradient_picker.hpp" -namespace { +namespace +{ -std::vector host_presets() { +std::vector host_presets() +{ return { {"Host B", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {1.f, 0.f, 0.f, 1.f}}}}, {"Host A", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {0.f, 0.f, 1.f, 1.f}}}}}; } -meta::Preset lib_preset(const std::string &name) { +meta::Preset lib_preset(const std::string &name) +{ return {name, {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {0.f, 1.f, 0.f, 1.f}}}}; } // Points the process-wide library at a scratch file and empties it, so the // tests never touch a real per-user library. -struct IsolatedLibrary { - IsolatedLibrary() { - const auto dir = - std::filesystem::temp_directory_path() / "meta_gradient_picker_test"; +struct IsolatedLibrary +{ + IsolatedLibrary() + { + const auto dir = std::filesystem::temp_directory_path() / + "meta_gradient_picker_test"; std::filesystem::remove_all(dir); auto &lib = meta::GradientLibrary::instance(); @@ -41,7 +46,8 @@ struct IsolatedLibrary { lib.set_sort(meta::GradientSort::Default); } - ~IsolatedLibrary() { + ~IsolatedLibrary() + { auto &lib = meta::GradientLibrary::instance(); lib.clear(); lib.set_sort(meta::GradientSort::Default); @@ -49,23 +55,26 @@ struct IsolatedLibrary { }; // Library notifications rebuild the grid through a queued call. -void flush() { +void flush() +{ QCoreApplication::sendPostedEvents(); QCoreApplication::processEvents(); } -int swatch_count(const meta::qt::GradientPicker &picker) { +int swatch_count(const meta::qt::GradientPicker &picker) +{ return static_cast(picker.findChildren().size()); } } // namespace -TEST(GradientPickerTest, MergesHostAndLibraryPresets) { +TEST(GradientPickerTest, MergesHostAndLibraryPresets) +{ IsolatedLibrary isolated; meta::GradientLibrary::instance().add(lib_preset("Lib")); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); EXPECT_EQ(picker.entry_names(), @@ -74,18 +83,18 @@ TEST(GradientPickerTest, MergesHostAndLibraryPresets) { int user_count = 0; for (auto *button : picker.findChildren()) - if (button->property("preset_user").toBool()) - ++user_count; + if (button->property("preset_user").toBool()) ++user_count; EXPECT_EQ(user_count, 1); } -TEST(GradientPickerTest, FavoritesPinnedFirstThenSortKey) { +TEST(GradientPickerTest, FavoritesPinnedFirstThenSortKey) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); lib.add(lib_preset("Lib")); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); lib.set_favorite("Lib", true); @@ -104,28 +113,30 @@ TEST(GradientPickerTest, FavoritesPinnedFirstThenSortKey) { (std::vector{"Host A", "Host B", "Lib"})); } -TEST(GradientPickerTest, LuminanceSortGoesDarkToLight) { +TEST(GradientPickerTest, LuminanceSortGoesDarkToLight) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); lib.add( {"Bright", {{0.f, {1.f, 1.f, 1.f, 1.f}}, {1.f, {1.f, 1.f, 1.f, 1.f}}}}); lib.add( {"Dark", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {0.1f, 0.1f, 0.1f, 1.f}}}}); lib.set_sort(meta::GradientSort::Luminance); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, {}); EXPECT_EQ(picker.entry_names(), (std::vector{"Dark", "Bright"})); } -TEST(GradientPickerTest, SaveCurrentAsPresetAvoidsHostNames) { +TEST(GradientPickerTest, SaveCurrentAsPresetAvoidsHostNames) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); - std::vector stops = {{1.f, {1.f, 1.f, 0.f, 1.f}}, - {0.f, {0.f, 0.f, 0.f, 1.f}}}; + std::vector stops = {{1.f, {1.f, 1.f, 0.f, 1.f}}, + {0.f, {0.f, 0.f, 0.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); EXPECT_EQ(picker.save_current_as_preset("Mine"), "Mine"); @@ -143,12 +154,13 @@ TEST(GradientPickerTest, SaveCurrentAsPresetAvoidsHostNames) { EXPECT_EQ(swatch_count(picker), 5); } -TEST(GradientPickerTest, LibraryChangesRebuildTheGrid) { +TEST(GradientPickerTest, LibraryChangesRebuildTheGrid) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); EXPECT_EQ(swatch_count(picker), 2);