diff --git a/src/app/main.cpp b/src/app/main.cpp index 7ae66dc..3df229a 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -276,13 +276,29 @@ int main(int argc, char **argv) { } engine.shutdown(); engine.init(cfg.sound_path(), cfg.volume_percent(), cfg.audio_backend()); + overlay.refresh_from_config(cfg); + bool prev_enabled = tray_state.enabled; + bool prev_muted = tray_state.muted; + bool prev_fullscreen_pause = tray_state.fullscreen_pause; + auto prev_mode = tray_state.fps_mode; + int prev_fixed = tray_state.fps_fixed; tray_state.enabled = cfg.enabled(); tray_state.muted = cfg.mute(); tray_state.fullscreen_pause = cfg.fullscreen_pause(); + auto new_mode = cfg.fps_mode() == "fixed" ? lizard::platform::FpsMode::Fixed + : lizard::platform::FpsMode::Auto; + int new_fixed = cfg.fps_fixed(); + tray_state.fps_mode = new_mode; + tray_state.fps_fixed = new_fixed; enabled = tray_state.enabled; muted = tray_state.muted; fullscreen_pause = tray_state.fullscreen_pause; - lizard::platform::update_tray(tray_state); + bool tray_changed = tray_state.enabled != prev_enabled || tray_state.muted != prev_muted || + tray_state.fullscreen_pause != prev_fullscreen_pause || + prev_mode != new_mode || prev_fixed != new_fixed; + if (tray_changed) { + lizard::platform::update_tray(tray_state); + } update_state(); } }); diff --git a/src/overlay/overlay.cpp b/src/overlay/overlay.cpp index 80fa4bd..0883915 100644 --- a/src/overlay/overlay.cpp +++ b/src/overlay/overlay.cpp @@ -31,6 +31,7 @@ void stbi_image_free(void *); #include #include #include +#include #include #include @@ -102,6 +103,7 @@ class Overlay { void spawn_badge(float x, float y); void run(std::stop_token st); void stop(); + void refresh_from_config(const app::Config &cfg); void set_paused(bool v) { m_paused = v; } void set_fps_mode(platform::FpsMode mode) { m_fps_mode = mode; @@ -115,9 +117,34 @@ class Overlay { private: friend struct ::OverlayTestAccess; int select_sprite(); + int select_sprite_locked(); void update(float dt); void render(); void update_frame_interval(); + void apply_pending_config(); + struct AtlasData { + std::vector sprites; + std::unordered_map lookup; + std::optional normalized_path; + }; + std::optional load_atlas_from_path(const std::optional &emoji_path); + void build_selector(const std::vector &emoji, + const std::unordered_map &emoji_weighted); + void spawn_badge_locked(int sprite, float x, float y); + static std::optional + normalize_path(const std::optional &path); + + struct PendingConfig { + std::string spawn_strategy; + int badge_min_px = 60; + int badge_max_px = 108; + int badges_per_second_max = 12; + std::string fps_mode; + int fps_fixed = 60; + std::optional emoji_atlas; + std::vector emoji; + std::unordered_map emoji_weighted; + }; platform::Window m_window{}; std::vector m_badges; @@ -146,6 +173,11 @@ class Overlay { platform::FpsMode m_fps_mode = platform::FpsMode::Auto; int m_fps_fixed = 60; std::atomic m_frame_interval_us{1000000 / 60}; + std::optional m_current_emoji_path; + std::mutex m_pending_mutex; + std::optional m_pending_config; + std::atomic m_has_pending_config{false}; + std::mutex m_spawn_config_mutex; }; void Overlay::update_frame_interval() { @@ -209,107 +241,16 @@ bool Overlay::init(const app::Config &cfg, std::optional m_badge_max_px = cfg.badge_max_px(); m_badges_per_second_max = cfg.badges_per_second_max(); update_frame_interval(); -#ifdef LIZARD_TEST - if (emoji_path && emoji_path->extension() == ".png") { - int w, h, channels; - unsigned char *pixels = stbi_load(emoji_path->string().c_str(), &w, &h, &channels, 4); - if (!pixels) { -#ifdef LIZARD_TEST - g_overlay_log_called = true; -#endif - spdlog::error("Failed to load emoji atlas {}: {}", emoji_path->string(), - stbi_failure_reason()); - return false; - } - stbi_image_free(pixels); - } - std::ifstream atlas_file; - std::istringstream atlas_default(R"({ - "sprites": { - "🦎": { "u0": 0.0, "v0": 0.0, "u1": 0.5, "v1": 0.5 }, - "🐍": { "u0": 0.5, "v0": 0.0, "u1": 1.0, "v1": 0.5 }, - "🐢": { "u0": 0.0, "v0": 0.5, "u1": 0.5, "v1": 1.0 } - } -})"); - std::istream *atlas = nullptr; - if (emoji_path) { - if (emoji_path->extension() == ".json") { - atlas_file.open(*emoji_path); - if (atlas_file.is_open()) { - atlas = &atlas_file; - } - } else { - std::filesystem::path json_path = *emoji_path; - json_path += ".json"; - if (std::filesystem::exists(json_path)) { - atlas_file.open(json_path); - if (atlas_file.is_open()) { - atlas = &atlas_file; - } - } - if (!atlas) { - json_path = emoji_path->parent_path() / "emoji_atlas.json"; - if (std::filesystem::exists(json_path)) { - atlas_file.open(json_path); - if (atlas_file.is_open()) { - atlas = &atlas_file; - } - } - } - } - } + auto emoji = cfg.emoji(); + auto emoji_weighted = cfg.emoji_weighted(); + auto normalized_path = normalize_path(emoji_path); + std::optional atlas; +#ifdef LIZARD_TEST + atlas = load_atlas_from_path(normalized_path); if (!atlas) { - atlas = &atlas_default; - } - try { - json j; - *atlas >> j; - if (j.contains("sprites") && j["sprites"].is_object()) { - for (const auto &[emoji, s] : j["sprites"].items()) { - Sprite sp{}; - sp.u0 = s.value("u0", 0.0f); - sp.v0 = s.value("v0", 0.0f); - sp.u1 = s.value("u1", 1.0f); - sp.v1 = s.value("v1", 1.0f); - m_sprite_lookup[emoji] = static_cast(m_sprites.size()); - m_sprites.push_back(sp); - } - } - } catch (const std::exception &e) { - spdlog::error("Failed to parse emoji atlas: {}", e.what()); - } - if (m_sprites.empty()) { - m_sprites.push_back({0.0f, 0.0f, 1.0f, 1.0f}); - m_sprite_lookup["🦎"] = 0; - } - - std::vector weights; - if (!cfg.emoji_weighted().empty()) { - for (const auto &[emoji, weight] : cfg.emoji_weighted()) { - auto it = m_sprite_lookup.find(emoji); - if (it != m_sprite_lookup.end()) { - m_selector_indices.push_back(it->second); - weights.push_back(weight); - } - } - } else { - for (const auto &emoji : cfg.emoji()) { - auto it = m_sprite_lookup.find(emoji); - if (it != m_sprite_lookup.end()) { - m_selector_indices.push_back(it->second); - weights.push_back(1.0); - } - } - } - if (m_selector_indices.empty()) { - for (int i = 0; i < static_cast(m_sprites.size()); ++i) { - m_selector_indices.push_back(i); - weights.push_back(1.0); - } + return false; } - m_selector = std::discrete_distribution<>(weights.begin(), weights.end()); - return true; #else platform::WindowDesc desc{}; #ifdef _WIN32 @@ -374,121 +315,20 @@ bool Overlay::init(const app::Config &cfg, std::optional return false; } - // Load atlas - int w, h, channels; - unsigned char *pixels = nullptr; - if (emoji_path && std::filesystem::exists(*emoji_path)) { - pixels = stbi_load(emoji_path->string().c_str(), &w, &h, &channels, 4); - } else { - pixels = stbi_load_from_memory(lizard::assets::lizard_regular_png, - lizard::assets::lizard_regular_png_len, &w, &h, &channels, 4); - } - if (!pixels) { - if (emoji_path && std::filesystem::exists(*emoji_path)) { - spdlog::error("Failed to load emoji atlas {}: {}", emoji_path->string(), - stbi_failure_reason()); - } else { - spdlog::error("Failed to load embedded emoji atlas: {}", stbi_failure_reason()); - } + atlas = load_atlas_from_path(normalized_path); + if (!atlas) { return false; } +#endif - // Pre-multiply RGB by alpha - for (int i = 0; i < w * h; ++i) { - unsigned char *p = pixels + i * 4; - unsigned char a = p[3]; - p[0] = static_cast(p[0] * a / 255); - p[1] = static_cast(p[1] * a / 255); - p[2] = static_cast(p[2] * a / 255); - } - m_texture.create(); - glBindTexture(GL_TEXTURE_2D, m_texture.id); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - stbi_image_free(pixels); - - // Load sprite UVs from atlas - std::ifstream atlas_file; - std::istringstream atlas_default(R"({ - "sprites": { - "🦎": { "u0": 0.0, "v0": 0.0, "u1": 0.5, "v1": 0.5 }, - "🐍": { "u0": 0.5, "v0": 0.0, "u1": 1.0, "v1": 0.5 }, - "🐢": { "u0": 0.0, "v0": 0.5, "u1": 0.5, "v1": 1.0 } - } -})"); - std::istream *atlas = nullptr; - if (emoji_path) { - std::filesystem::path json_path = *emoji_path; - json_path += ".json"; - if (std::filesystem::exists(json_path)) { - atlas_file.open(json_path); - if (atlas_file.is_open()) { - atlas = &atlas_file; - } - } - if (!atlas) { - json_path = emoji_path->parent_path() / "emoji_atlas.json"; - if (std::filesystem::exists(json_path)) { - atlas_file.open(json_path); - if (atlas_file.is_open()) { - atlas = &atlas_file; - } - } - } - } - if (!atlas) { - atlas = &atlas_default; - } - try { - json j; - *atlas >> j; - if (j.contains("sprites") && j["sprites"].is_object()) { - for (const auto &[emoji, s] : j["sprites"].items()) { - Sprite sp{}; - sp.u0 = s.value("u0", 0.0f); - sp.v0 = s.value("v0", 0.0f); - sp.u1 = s.value("u1", 1.0f); - sp.v1 = s.value("v1", 1.0f); - m_sprite_lookup[emoji] = static_cast(m_sprites.size()); - m_sprites.push_back(sp); - } - } - } catch (const std::exception &e) { - spdlog::error("Failed to parse emoji atlas: {}", e.what()); - } - if (m_sprites.empty()) { - m_sprites.push_back({0.0f, 0.0f, 1.0f, 1.0f}); - m_sprite_lookup["🦎"] = 0; + { + std::lock_guard lock(m_spawn_config_mutex); + m_sprite_lookup = std::move(atlas->lookup); + m_sprites = std::move(atlas->sprites); + m_current_emoji_path = std::move(atlas->normalized_path); + build_selector(emoji, emoji_weighted); } - // Build selector from config - std::vector weights; - if (!cfg.emoji_weighted().empty()) { - for (const auto &[emoji, weight] : cfg.emoji_weighted()) { - auto it = m_sprite_lookup.find(emoji); - if (it != m_sprite_lookup.end()) { - m_selector_indices.push_back(it->second); - weights.push_back(weight); - } - } - } else { - for (const auto &emoji : cfg.emoji()) { - auto it = m_sprite_lookup.find(emoji); - if (it != m_sprite_lookup.end()) { - m_selector_indices.push_back(it->second); - weights.push_back(1.0); - } - } - } - if (m_selector_indices.empty()) { - for (int i = 0; i < static_cast(m_sprites.size()); ++i) { - m_selector_indices.push_back(i); - weights.push_back(1.0); - } - } - m_selector = std::discrete_distribution<>(weights.begin(), weights.end()); - m_badge_capacity = 150; m_badges.reserve(m_badge_capacity); m_instanceData.reserve(m_badge_capacity * 10); @@ -497,6 +337,7 @@ bool Overlay::init(const app::Config &cfg, std::optional spawn_badge(0.0f, 0.0f); } +#ifndef LIZARD_TEST // Geometry const float verts[] = {-0.5f, -0.5f, 0.0f, 0.0f, 0.5f, -0.5f, 1.0f, 0.0f, 0.5f, 0.5f, 1.0f, 1.0f, -0.5f, 0.5f, 0.0f, 1.0f}; @@ -623,9 +464,253 @@ bool Overlay::init(const app::Config &cfg, std::optional glDeleteShader(vsId); glDeleteShader(fsId); +#endif m_running = true; return true; +} + +std::optional +Overlay::normalize_path(const std::optional &path) { + if (!path || path->empty()) { + return std::nullopt; + } + return path->lexically_normal(); +} + +std::optional +Overlay::load_atlas_from_path(const std::optional &emoji_path) { + auto normalized = normalize_path(emoji_path); + std::unordered_map lookup; + std::vector sprites; + +#ifdef LIZARD_TEST + if (normalized && normalized->extension() == ".png") { + int w = 0; + int h = 0; + int channels = 0; + unsigned char *pixels = stbi_load(normalized->string().c_str(), &w, &h, &channels, 4); + if (!pixels) { + g_overlay_log_called = true; + spdlog::error("Failed to load emoji atlas {}: {}", normalized->string(), + stbi_failure_reason()); + return std::nullopt; + } + stbi_image_free(pixels); + } +#else + int w = 0; + int h = 0; + int channels = 0; + unsigned char *pixels = nullptr; + if (normalized) { + pixels = stbi_load(normalized->string().c_str(), &w, &h, &channels, 4); + } else { + pixels = stbi_load_from_memory(lizard::assets::lizard_regular_png, + lizard::assets::lizard_regular_png_len, &w, &h, &channels, 4); + } + if (!pixels) { + if (normalized) { + spdlog::error("Failed to load emoji atlas {}: {}", normalized->string(), + stbi_failure_reason()); + } else { + spdlog::error("Failed to load embedded emoji atlas: {}", stbi_failure_reason()); + } + return std::nullopt; + } + + for (int i = 0; i < w * h; ++i) { + unsigned char *p = pixels + i * 4; + unsigned char a = p[3]; + p[0] = static_cast(p[0] * a / 255); + p[1] = static_cast(p[1] * a / 255); + p[2] = static_cast(p[2] * a / 255); + } + if (!m_texture.id) { + m_texture.create(); + } + glBindTexture(GL_TEXTURE_2D, m_texture.id); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + stbi_image_free(pixels); #endif + + std::ifstream atlas_file; + std::istringstream atlas_default(R"({ + "sprites": { + "🦎": { "u0": 0.0, "v0": 0.0, "u1": 0.5, "v1": 0.5 }, + "🐍": { "u0": 0.5, "v0": 0.0, "u1": 1.0, "v1": 0.5 }, + "🐢": { "u0": 0.0, "v0": 0.5, "u1": 0.5, "v1": 1.0 } + } +})"); + std::istream *atlas = nullptr; + if (normalized) { + if (normalized->extension() == ".json") { + atlas_file.open(*normalized); + if (atlas_file.is_open()) { + atlas = &atlas_file; + } + } else { + std::filesystem::path json_path = *normalized; + json_path += ".json"; + if (std::filesystem::exists(json_path)) { + atlas_file.open(json_path); + if (atlas_file.is_open()) { + atlas = &atlas_file; + } + } + if (!atlas) { + json_path = normalized->parent_path() / "emoji_atlas.json"; + if (std::filesystem::exists(json_path)) { + atlas_file.open(json_path); + if (atlas_file.is_open()) { + atlas = &atlas_file; + } + } + } + } + } + if (!atlas) { + atlas = &atlas_default; + } + try { + json j; + *atlas >> j; + if (j.contains("sprites") && j["sprites"].is_object()) { + for (const auto &[emoji, s] : j["sprites"].items()) { + Sprite sp{}; + sp.u0 = s.value("u0", 0.0f); + sp.v0 = s.value("v0", 0.0f); + sp.u1 = s.value("u1", 1.0f); + sp.v1 = s.value("v1", 1.0f); + lookup[emoji] = static_cast(sprites.size()); + sprites.push_back(sp); + } + } + } catch (const std::exception &e) { + spdlog::error("Failed to parse emoji atlas: {}", e.what()); + } + if (sprites.empty()) { + sprites.push_back({0.0f, 0.0f, 1.0f, 1.0f}); + lookup["🦎"] = 0; + } + + AtlasData data; + data.sprites = std::move(sprites); + data.lookup = std::move(lookup); + data.normalized_path = normalized; + return data; +} + +void Overlay::build_selector(const std::vector &emoji, + const std::unordered_map &emoji_weighted) { + m_selector_indices.clear(); + std::vector weights; + + if (!emoji_weighted.empty()) { + for (const auto &[symbol, weight] : emoji_weighted) { + auto it = m_sprite_lookup.find(symbol); + if (it != m_sprite_lookup.end()) { + m_selector_indices.push_back(it->second); + weights.push_back(weight); + } + } + } else if (!emoji.empty()) { + for (const auto &symbol : emoji) { + auto it = m_sprite_lookup.find(symbol); + if (it != m_sprite_lookup.end()) { + m_selector_indices.push_back(it->second); + weights.push_back(1.0); + } + } + } + + if (m_selector_indices.empty()) { + for (int i = 0; i < static_cast(m_sprites.size()); ++i) { + m_selector_indices.push_back(i); + weights.push_back(1.0); + } + } + + if (weights.empty()) { + weights.push_back(1.0); + if (m_selector_indices.empty()) { + m_selector_indices.push_back(0); + } + } + + m_selector = std::discrete_distribution<>(weights.begin(), weights.end()); +} + +void Overlay::refresh_from_config(const app::Config &cfg) { + PendingConfig pending; + pending.spawn_strategy = cfg.badge_spawn_strategy(); + pending.badge_min_px = cfg.badge_min_px(); + pending.badge_max_px = cfg.badge_max_px(); + pending.badges_per_second_max = cfg.badges_per_second_max(); + pending.fps_mode = cfg.fps_mode(); + pending.fps_fixed = cfg.fps_fixed(); + pending.emoji_atlas = normalize_path(cfg.emoji_atlas()); + pending.emoji = cfg.emoji(); + pending.emoji_weighted = cfg.emoji_weighted(); + + { + std::lock_guard lock(m_pending_mutex); + m_pending_config = std::move(pending); + } + m_has_pending_config.store(true, std::memory_order_release); +} + +void Overlay::apply_pending_config() { + PendingConfig pending; + { + std::lock_guard lock(m_pending_mutex); + if (!m_pending_config) { + return; + } + pending = *m_pending_config; + m_pending_config.reset(); + } + + auto normalized = normalize_path(pending.emoji_atlas); + bool atlas_changed = normalized != m_current_emoji_path; + std::optional atlas; + if (atlas_changed) { + atlas = load_atlas_from_path(normalized); + if (!atlas) { + normalized = m_current_emoji_path; + atlas.reset(); + } + } + + { + std::lock_guard lock(m_spawn_config_mutex); + if (atlas) { + m_badges.clear(); + m_spawn_times.clear(); + m_sprite_lookup = std::move(atlas->lookup); + m_sprites = std::move(atlas->sprites); + m_current_emoji_path = std::move(atlas->normalized_path); + } + if (pending.spawn_strategy == "cursor_follow") { + m_spawn_strategy = BadgeSpawnStrategy::CursorFollow; + } else { + m_spawn_strategy = BadgeSpawnStrategy::RandomScreen; + } + + m_badge_min_px = pending.badge_min_px; + m_badge_max_px = pending.badge_max_px; + m_badges_per_second_max = pending.badges_per_second_max; + + build_selector(pending.emoji, pending.emoji_weighted); + } + + if (pending.fps_mode == "fixed") { + set_fps_fixed(pending.fps_fixed); + set_fps_mode(platform::FpsMode::Fixed); + } else { + set_fps_mode(platform::FpsMode::Auto); + } } void Overlay::shutdown() { @@ -659,6 +744,22 @@ void Overlay::shutdown() { } int Overlay::select_sprite() { + std::lock_guard lock(m_spawn_config_mutex); + return select_sprite_locked(); +} + +void Overlay::spawn_badge(float x, float y) { + std::lock_guard lock(m_spawn_config_mutex); + int sprite = select_sprite_locked(); + spawn_badge_locked(sprite, x, y); +} + +void Overlay::spawn_badge(int sprite, float x, float y) { + std::lock_guard lock(m_spawn_config_mutex); + spawn_badge_locked(sprite, x, y); +} + +int Overlay::select_sprite_locked() { if (m_selector_indices.empty()) { return 0; } @@ -666,9 +767,7 @@ int Overlay::select_sprite() { return m_selector_indices[idx]; } -void Overlay::spawn_badge(float x, float y) { spawn_badge(select_sprite(), x, y); } - -void Overlay::spawn_badge(int sprite, float x, float y) { +void Overlay::spawn_badge_locked(int sprite, float x, float y) { if (m_badge_suppressed) { if (m_badges.size() < static_cast(m_badge_capacity * 0.8f)) { m_badge_suppressed = false; @@ -798,6 +897,9 @@ void Overlay::run(std::stop_token st) { using clock = std::chrono::steady_clock; auto last = clock::now(); while (m_running && !st.stop_requested()) { + if (m_has_pending_config.exchange(false, std::memory_order_acq_rel)) { + apply_pending_config(); + } auto frame = std::chrono::microseconds(m_frame_interval_us.load()); if (m_paused.load()) { std::this_thread::sleep_for(frame);