d010a03b44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
9.7 KiB
C++
311 lines
9.7 KiB
C++
#include <imgui.h>
|
|
#include <imgui_internal.h>
|
|
#include "app_base.h"
|
|
#include "core/panel_menu.h"
|
|
#include "core/icons_tabler.h"
|
|
#include "core/logger.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#include <windows.h>
|
|
#include <shellapi.h>
|
|
#endif
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
struct AppMeta {
|
|
std::string display_name;
|
|
std::string description;
|
|
ImU32 accent;
|
|
};
|
|
|
|
struct AppEntry {
|
|
std::string name;
|
|
fs::path exe_path;
|
|
AppMeta meta;
|
|
};
|
|
|
|
static std::vector<AppEntry> g_apps;
|
|
static bool g_show_main = true;
|
|
static char g_filter[128] = {0};
|
|
static std::string g_last_status;
|
|
|
|
static ImU32 hex_to_imu32(std::string const& hex_in) {
|
|
std::string h = hex_in;
|
|
if (!h.empty() && h[0] == '#') h.erase(0, 1);
|
|
if (h.size() != 6) return IM_COL32(100, 116, 139, 255);
|
|
unsigned int v = 0;
|
|
std::stringstream ss;
|
|
ss << std::hex << h;
|
|
ss >> v;
|
|
int r = (v >> 16) & 0xFF;
|
|
int g = (v >> 8) & 0xFF;
|
|
int b = v & 0xFF;
|
|
return IM_COL32(r, g, b, 255);
|
|
}
|
|
|
|
static std::string camel_case_from_snake(std::string const& s) {
|
|
std::string out;
|
|
out.reserve(s.size() + 2);
|
|
bool cap = true;
|
|
for (char c : s) {
|
|
if (c == '_' || c == '-') {
|
|
out.push_back(' ');
|
|
cap = true;
|
|
} else if (cap) {
|
|
out.push_back((char)std::toupper((unsigned char)c));
|
|
cap = false;
|
|
} else {
|
|
out.push_back((char)std::tolower((unsigned char)c));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static std::unordered_map<std::string, AppMeta> load_manifest() {
|
|
std::unordered_map<std::string, AppMeta> map;
|
|
fs::path path = fs::path(fn::local_path("hub_manifest.tsv"));
|
|
std::ifstream f(path);
|
|
if (!f.is_open()) {
|
|
fn_log::log_warn("hub: manifest not found at %s", path.string().c_str());
|
|
return map;
|
|
}
|
|
std::string line;
|
|
bool header = true;
|
|
while (std::getline(f, line)) {
|
|
if (header) { header = false; continue; }
|
|
if (line.empty()) continue;
|
|
std::vector<std::string> cols;
|
|
std::stringstream ss(line);
|
|
std::string item;
|
|
while (std::getline(ss, item, '\t')) cols.push_back(item);
|
|
if (cols.size() < 4) continue;
|
|
AppMeta m;
|
|
m.display_name = cols[1];
|
|
m.description = cols[2];
|
|
m.accent = hex_to_imu32(cols[3]);
|
|
map[cols[0]] = m;
|
|
}
|
|
fn_log::log_info("hub: loaded %zu manifest rows from %s",
|
|
map.size(), path.string().c_str());
|
|
return map;
|
|
}
|
|
|
|
static fs::path apps_root_dir() {
|
|
return fs::path(fn::exe_dir()).parent_path();
|
|
}
|
|
|
|
static void scan_apps() {
|
|
g_apps.clear();
|
|
auto manifest = load_manifest();
|
|
fs::path root = apps_root_dir();
|
|
std::error_code ec;
|
|
if (!fs::exists(root, ec) || !fs::is_directory(root, ec)) {
|
|
fn_log::log_warn("hub: apps root not found: %s", root.string().c_str());
|
|
return;
|
|
}
|
|
std::string self_name = fs::path(fn::exe_dir()).filename().string();
|
|
for (auto const& entry : fs::directory_iterator(root, ec)) {
|
|
if (!entry.is_directory(ec)) continue;
|
|
std::string name = entry.path().filename().string();
|
|
if (name == self_name) continue;
|
|
fs::path exe = entry.path() / (name + ".exe");
|
|
if (!(fs::exists(exe, ec) && fs::is_regular_file(exe, ec))) continue;
|
|
AppEntry e;
|
|
e.name = name;
|
|
e.exe_path = exe;
|
|
auto it = manifest.find(name);
|
|
if (it != manifest.end()) {
|
|
e.meta = it->second;
|
|
} else {
|
|
e.meta.display_name = camel_case_from_snake(name);
|
|
e.meta.description = "";
|
|
e.meta.accent = IM_COL32(100, 116, 139, 255);
|
|
}
|
|
g_apps.push_back(std::move(e));
|
|
}
|
|
std::sort(g_apps.begin(), g_apps.end(),
|
|
[](AppEntry const& a, AppEntry const& b) {
|
|
return a.meta.display_name < b.meta.display_name;
|
|
});
|
|
fn_log::log_info("hub: scanned %zu apps at %s",
|
|
g_apps.size(), root.string().c_str());
|
|
}
|
|
|
|
static bool launch_app(AppEntry const& app) {
|
|
#ifdef _WIN32
|
|
std::wstring wexe(app.exe_path.wstring());
|
|
std::wstring wdir(app.exe_path.parent_path().wstring());
|
|
HINSTANCE r = ShellExecuteW(NULL, L"open", wexe.c_str(), NULL,
|
|
wdir.c_str(), SW_SHOWNORMAL);
|
|
bool ok = ((INT_PTR)r > 32);
|
|
if (ok) {
|
|
fn_log::log_info("hub: launched %s", app.name.c_str());
|
|
g_last_status = std::string("Launched ") + app.meta.display_name;
|
|
} else {
|
|
fn_log::log_warn("hub: ShellExecuteW failed for %s (code=%lld)",
|
|
app.name.c_str(), (long long)(INT_PTR)r);
|
|
g_last_status = std::string("FAILED to launch ") + app.meta.display_name;
|
|
}
|
|
return ok;
|
|
#else
|
|
(void)app;
|
|
g_last_status = "Launch only supported on Windows";
|
|
fn_log::log_warn("hub: launch requested on non-Windows build");
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
static bool matches_filter(AppEntry const& app) {
|
|
if (g_filter[0] == '\0') return true;
|
|
auto lower = [](std::string s) {
|
|
std::transform(s.begin(), s.end(), s.begin(),
|
|
[](unsigned char c) { return (char)std::tolower(c); });
|
|
return s;
|
|
};
|
|
std::string f = lower(g_filter);
|
|
return lower(app.name).find(f) != std::string::npos ||
|
|
lower(app.meta.display_name).find(f) != std::string::npos ||
|
|
lower(app.meta.description).find(f) != std::string::npos;
|
|
}
|
|
|
|
static ImU32 with_alpha(ImU32 c, float a) {
|
|
ImVec4 v = ImGui::ColorConvertU32ToFloat4(c);
|
|
v.w = a;
|
|
return ImGui::ColorConvertFloat4ToU32(v);
|
|
}
|
|
|
|
static ImU32 lighten(ImU32 c, float amount) {
|
|
ImVec4 v = ImGui::ColorConvertU32ToFloat4(c);
|
|
v.x = v.x + (1.0f - v.x) * amount;
|
|
v.y = v.y + (1.0f - v.y) * amount;
|
|
v.z = v.z + (1.0f - v.z) * amount;
|
|
return ImGui::ColorConvertFloat4ToU32(v);
|
|
}
|
|
|
|
static void draw_card(int idx, AppEntry const& app, float card_w, float card_h) {
|
|
ImGui::PushID(idx);
|
|
ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
ImGui::InvisibleButton("card", ImVec2(card_w, card_h));
|
|
bool hovered = ImGui::IsItemHovered();
|
|
bool clicked = ImGui::IsItemClicked();
|
|
bool held = ImGui::IsItemActive();
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 p0 = pos;
|
|
ImVec2 p1 = ImVec2(pos.x + card_w, pos.y + card_h);
|
|
|
|
ImU32 bg = with_alpha(app.meta.accent, hovered ? 0.28f : 0.16f);
|
|
if (held) bg = with_alpha(app.meta.accent, 0.40f);
|
|
ImU32 border = with_alpha(app.meta.accent, hovered ? 0.95f : 0.55f);
|
|
float rounding = 8.0f;
|
|
|
|
dl->AddRectFilled(p0, p1, bg, rounding);
|
|
dl->AddRect(p0, p1, border, rounding, 0, 1.5f);
|
|
|
|
// Accent stripe on left
|
|
float stripe_w = 6.0f;
|
|
dl->AddRectFilled(p0, ImVec2(p0.x + stripe_w, p1.y), app.meta.accent, rounding,
|
|
ImDrawFlags_RoundCornersLeft);
|
|
|
|
float pad_x = stripe_w + 12.0f;
|
|
float pad_y = 10.0f;
|
|
ImVec2 text_pos = ImVec2(p0.x + pad_x, p0.y + pad_y);
|
|
|
|
// Title
|
|
ImU32 title_col = IM_COL32(245, 245, 250, 255);
|
|
dl->AddText(text_pos, title_col, app.meta.display_name.c_str());
|
|
|
|
// Description (wrapped) below
|
|
if (!app.meta.description.empty()) {
|
|
ImVec2 desc_pos = ImVec2(text_pos.x,
|
|
text_pos.y + ImGui::GetTextLineHeight() + 4.0f);
|
|
ImU32 desc_col = IM_COL32(195, 200, 210, 230);
|
|
float wrap_w = card_w - pad_x - 10.0f;
|
|
dl->AddText(NULL, 0.0f, desc_pos, desc_col,
|
|
app.meta.description.c_str(), NULL, wrap_w);
|
|
}
|
|
|
|
if (hovered) {
|
|
ImGui::SetTooltip("%s\n%s", app.name.c_str(),
|
|
app.exe_path.string().c_str());
|
|
}
|
|
if (clicked) launch_app(app);
|
|
|
|
ImGui::PopID();
|
|
}
|
|
|
|
static void draw_main() {
|
|
if (!ImGui::Begin(TI_APPS " Apps", &g_show_main)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
ImGui::TextUnformatted("Hub Launcher");
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("(%zu apps)", g_apps.size());
|
|
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 90.0f);
|
|
if (ImGui::Button(TI_REFRESH " Refresh")) {
|
|
scan_apps();
|
|
}
|
|
ImGui::Separator();
|
|
ImGui::SetNextItemWidth(-1.0f);
|
|
ImGui::InputTextWithHint("##filter", "Filter apps...",
|
|
g_filter, sizeof(g_filter));
|
|
ImGui::Spacing();
|
|
|
|
float card_w = 280.0f;
|
|
float card_h = 96.0f;
|
|
float spacing = 12.0f;
|
|
float avail = ImGui::GetContentRegionAvail().x;
|
|
int cols = std::max(1, (int)((avail + spacing) / (card_w + spacing)));
|
|
|
|
int shown = 0;
|
|
int idx = 0;
|
|
for (auto const& app : g_apps) {
|
|
if (!matches_filter(app)) continue;
|
|
if (shown % cols != 0) ImGui::SameLine(0.0f, spacing);
|
|
draw_card(idx++, app, card_w, card_h);
|
|
++shown;
|
|
if (shown % cols == 0) ImGui::Dummy(ImVec2(0.0f, spacing - 4.0f));
|
|
}
|
|
if (shown == 0) {
|
|
ImGui::TextDisabled("No apps match filter.");
|
|
}
|
|
if (!g_last_status.empty()) {
|
|
ImGui::Separator();
|
|
ImGui::TextUnformatted(g_last_status.c_str());
|
|
}
|
|
ImGui::End();
|
|
}
|
|
|
|
static void render() {
|
|
if (g_show_main) draw_main();
|
|
}
|
|
|
|
int main(int /*argc*/, char** /*argv*/) {
|
|
static fn_ui::PanelToggle panels[] = {
|
|
{ "Apps", nullptr, &g_show_main },
|
|
};
|
|
scan_apps();
|
|
fn::AppConfig cfg;
|
|
cfg.title = "App Hub Launcher";
|
|
cfg.about = { "app_hub_launcher", "0.2.0",
|
|
"Lista y arranca apps C++ desplegadas en Windows Desktop" };
|
|
cfg.log = { "app_hub_launcher.log", 1 };
|
|
cfg.panels = panels;
|
|
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
|
return fn::run_app(cfg, render);
|
|
}
|