diff --git a/src/editor/document.cpp b/src/editor/document.cpp index 0485dd7..2655ae0 100644 --- a/src/editor/document.cpp +++ b/src/editor/document.cpp @@ -137,7 +137,9 @@ void Document::reset(splash::Scene scene, const fs::path& project, const std::st void Document::refreshAssets(const std::vector& projectPaths) { for (const std::string& p : projectPaths) { - if (fs::path(p).extension() == ".mesh") m_meshes[p] = analyseMesh(resolve(p)); + const fs::path ext = fs::path(p).extension(); + if (ext == ".mesh") m_meshes[p] = analyseMesh(resolve(p)); + else if (ext == ".lua" || ext == ".wav" || ext == ".anim") m_files[p] = isFile(resolve(p)); else m_textures[p] = analyseTexture(resolve(p)); } ++m_assetsId; @@ -152,7 +154,14 @@ void Document::cacheAssets(const splash::Object& o) { for (const splash::Material& mat : o.mesh->materials) if (!mat.texture.empty() && !m_textures.count(mat.texture)) m_textures[mat.texture] = analyseTexture(resolve(mat.texture)); } - if (o.script && !o.script->lua.empty() && !m_files.count(o.script->lua)) m_files[o.script->lua] = isFile(resolve(o.script->lua)); + auto file = [&](const std::string& p) { + if (!p.empty() && !m_files.count(p)) m_files[p] = isFile(resolve(p)); + }; + if (o.script) file(o.script->lua); + if (o.trigger) file(o.trigger->lua); + if (o.audio) file(o.audio->clip); + if (o.skin) + for (const std::string& c : o.skin->clips) file(c); for (const splash::Object& c : o.children) cacheAssets(c); } diff --git a/src/editor/project.cpp b/src/editor/project.cpp index 9286635..0a6b684 100644 --- a/src/editor/project.cpp +++ b/src/editor/project.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "scene.hh" @@ -70,6 +71,61 @@ std::optional sceneInProject(const fs::path& project, const fs::path& return rel; } +static bool sameBytes(const fs::path& a, const fs::path& b) { + std::error_code ec; + if (fs::file_size(a, ec) != fs::file_size(b, ec) || ec) return false; + std::ifstream fa(a, std::ios::binary), fb(b, std::ios::binary); + if (!fa || !fb) return false; + return std::equal(std::istreambuf_iterator(fa), {}, std::istreambuf_iterator(fb), {}); +} +static std::string genericUtf8(const fs::path& p) { + std::u8string u = p.generic_u8string(); + return std::string(u.begin(), u.end()); +} + +AdoptedAsset adoptAsset(const fs::path& project, const fs::path& file, const std::string& folder) { + AdoptedAsset r; + std::error_code ec; + if (project.empty() || file.empty()) { + r.error = "no project is open"; + return r; + } + if (!fs::is_regular_file(file, ec)) { + r.error = utf8(file) + " is not a file"; + return r; + } + fs::path root = fs::weakly_canonical(project, ec); + fs::path src = fs::weakly_canonical(file, ec); + if (ec) { + r.error = ec.message(); + return r; + } + fs::path rel = src.lexically_relative(root); + if (!rel.empty() && rel != "." && *rel.begin() != "..") { + r.path = genericUtf8(rel); + return r; + } + fs::path dir = root / fromUtf8(folder); + fs::create_directories(dir, ec); + if (ec) { + r.error = "could not create " + utf8(dir) + ": " + ec.message(); + return r; + } + const std::string stem = utf8(src.stem()), ext = utf8(src.extension()); + fs::path dest = dir / src.filename(); + for (int n = 2; fs::exists(dest, ec) && !sameBytes(src, dest); ++n) dest = dir / fromUtf8(stem + "_" + std::to_string(n) + ext); + if (!fs::exists(dest, ec)) { + fs::copy_file(src, dest, ec); + if (ec) { + r.error = "could not copy " + utf8(src.filename()) + ": " + ec.message(); + return r; + } + r.copied = true; + } + r.path = genericUtf8(dest.lexically_relative(root)); + return r; +} + void RecentScenes::load() { m_items.clear(); if (m_file.empty()) return; diff --git a/src/editor/project.hh b/src/editor/project.hh index e5c3842..dcec7f1 100644 --- a/src/editor/project.hh +++ b/src/editor/project.hh @@ -28,6 +28,17 @@ NewProject createProject(const std::filesystem::path& dir); // project (with a .scene extension added if missing), or nullopt outside it. std::optional sceneInProject(const std::filesystem::path& project, const std::filesystem::path& file); +// A file picked for an asset field, as the project-relative path the scene +// stores. A file already inside `project` is used where it is; one outside is +// copied into `project`/`folder`, reusing an identical file of the same name +// and numbering the copy ("rock_2.png") when a different one is there. +struct AdoptedAsset { + std::string path; // project-relative, '/'-separated; empty on failure + bool copied = false; + std::string error; // empty on success +}; +AdoptedAsset adoptAsset(const std::filesystem::path& project, const std::filesystem::path& file, const std::string& folder); + // Recently opened scenes, newest first, as absolute paths. Stored one per line. class RecentScenes { public: diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index c5ea1fc..d9ce839 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -794,6 +794,71 @@ void testProject() { CHECK(std::find(back.items().begin(), back.items().end(), proj / "s9.scene") == back.items().end()); } +void testAdoptAsset() { + namespace fs = std::filesystem; + std::error_code ec; + fs::path dir = g_outDir / "adopt"; + fs::remove_all(dir, ec); + fs::path proj = dir / "game", outside = dir / "elsewhere"; + fs::create_directories(proj / "textures", ec); + fs::create_directories(outside, ec); + auto write = [](const fs::path& p, const std::string& bytes) { std::ofstream(p, std::ios::binary) << bytes; }; + auto read = [](const fs::path& p) { + std::ifstream in(p, std::ios::binary); + return std::string((std::istreambuf_iterator(in)), {}); + }; + + // Inside the project: used where it is, never copied. + write(proj / "textures" / "rock.png", "rock"); + editor::AdoptedAsset in = editor::adoptAsset(proj, proj / "textures" / "rock.png", "textures"); + CHECK(in.error.empty() && in.path == "textures/rock.png" && !in.copied); + + // Outside: copied into the folder, created if missing. + write(outside / "door.lua", "print(1)"); + editor::AdoptedAsset lua = editor::adoptAsset(proj, outside / "door.lua", "scripts"); + CHECK(lua.error.empty() && lua.path == "scripts/door.lua" && lua.copied); + CHECK(read(proj / "scripts" / "door.lua") == "print(1)"); + // The same file again reuses the copy. + editor::AdoptedAsset again = editor::adoptAsset(proj, outside / "door.lua", "scripts"); + CHECK(again.path == "scripts/door.lua" && !again.copied); + // A different file of the same name is numbered, never overwrites. + fs::create_directories(outside / "b", ec); + write(outside / "b" / "door.lua", "print(2)"); + editor::AdoptedAsset other = editor::adoptAsset(proj, outside / "b" / "door.lua", "scripts"); + CHECK(other.path == "scripts/door_2.lua" && other.copied); + CHECK(read(proj / "scripts" / "door.lua") == "print(1)" && read(proj / "scripts" / "door_2.lua") == "print(2)"); + + // Failures carry a reason and no path. + editor::AdoptedAsset missing = editor::adoptAsset(proj, outside / "nope.wav", "audio"); + CHECK(!missing.error.empty() && missing.path.empty()); + editor::AdoptedAsset noProject = editor::adoptAsset({}, outside / "door.lua", "scripts"); + CHECK(!noProject.error.empty() && noProject.path.empty()); + + // Every file field is checked on disk: script, trigger script, audio clip, + // skin clips. Picking a file refreshes its entry. + write(proj / "scripts" / "zone.lua", ""); + fs::create_directories(proj / "audio", ec); + write(proj / "audio" / "bell.wav", "RIFF"); + splash::Scene sc; + splash::Object ob = named("Thing"); + ob.trigger.emplace(); + ob.trigger->lua = "scripts/zone.lua"; + ob.audio.emplace(); + ob.audio->clip = "audio/bell.wav"; + ob.skin.emplace(); + ob.skin->clips = {"anims/walk.anim"}; + sc.objects.push_back(ob); + editor::Document doc; + doc.reset(sc, proj, "game"); + CHECK(doc.fileExists("scripts/zone.lua") && doc.fileExists("audio/bell.wav")); + CHECK(!doc.fileExists("anims/walk.anim") && !doc.fileExists("scripts/new.lua")); + fs::create_directories(proj / "anims", ec); + write(proj / "anims" / "walk.anim", "x"); + write(proj / "scripts" / "new.lua", ""); + doc.refreshAssets({"anims/walk.anim", "scripts/new.lua"}); + CHECK(doc.fileExists("anims/walk.anim") && doc.fileExists("scripts/new.lua")); +} + } // namespace int main(int argc, char** argv) { @@ -816,6 +881,7 @@ int main(int argc, char** argv) { testLiveExport(); testPlay(); testProject(); + testAdoptAsset(); if (g_failures) { std::fprintf(stderr, "editor_tests: %d of %d checks failed\n", g_failures, g_checks); return 1; diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index 804a4c0..4184c6a 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -172,6 +172,8 @@ static std::string utf8(const std::filesystem::path& p) { return std::string(u.begin(), u.end()); } +static std::filesystem::path fromUtf8(const std::string& s) { return std::filesystem::path(std::u8string(s.begin(), s.end())); } + static float buttonWidth(const char* ic, const char* label) { Fonts& f = fonts(); const float iconW = ic ? measure(f.medium, type::icon, ic).x + space::sm - 2 : 0; @@ -529,6 +531,96 @@ static void notify(State& st, std::string msg, bool bad) { st.noticeUntil = ImGui::GetTime() + 8; } +// The other asset fields' file dialogs (texture, scripts, audio, skin clips), +// answered on their own thread and applied next frame to the object they were +// opened for. A file outside the project is copied into it first. +enum class AssetSlot { Texture, Lua, TriggerLua, Clip, SkinClips }; +static AssetSlot g_assetSlot = AssetSlot::Texture; +static editor::ObjectPath g_assetTarget; +static std::vector g_assetPicked; + +static void SDLCALL onAssetPicked(void*, const char* const* files, int) { + if (!files || !files[0]) return; + std::lock_guard lock(g_pickMutex); + g_assetPicked.clear(); + for (const char* const* f = files; *f; ++f) g_assetPicked.emplace_back(*f); +} + +static void pickAsset(AssetSlot slot, const editor::ObjectPath& target) { + { + std::lock_guard lock(g_pickMutex); + g_assetSlot = slot; + g_assetTarget = target; + } + static const SDL_DialogFileFilter images[] = {{"Images", "png;jpg;jpeg;bmp;tga;gif"}}; + static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; + static const SDL_DialogFileFilter wav[] = {{"WAV audio", "wav"}}; + static const SDL_DialogFileFilter anim[] = {{"Animation clips", "anim"}}; + const SDL_DialogFileFilter* filter = slot == AssetSlot::Texture ? images + : slot == AssetSlot::Clip ? wav + : slot == AssetSlot::SkinClips ? anim + : lua; + SDL_ShowOpenFileDialog(onAssetPicked, nullptr, nullptr, filter, 1, nullptr, slot == AssetSlot::SkinClips); +} + +static void applyPickedAssets(State& st, editor::Document& doc) { + std::vector files; + AssetSlot slot; + editor::ObjectPath target; + { + std::lock_guard lock(g_pickMutex); + files.swap(g_assetPicked); + slot = g_assetSlot; + target = g_assetTarget; + } + if (files.empty() || !doc.object(target)) return; + const char* folder = slot == AssetSlot::Texture ? "textures" + : slot == AssetSlot::Clip ? "audio" + : slot == AssetSlot::SkinClips ? "anims" + : "scripts"; + std::vector paths; + for (const std::string& file : files) { + editor::AdoptedAsset a = editor::adoptAsset(doc.projectRoot(), fromUtf8(file), folder); + if (!a.error.empty()) { + notify(st, "Could not use " + utf8(fromUtf8(file).filename()) + ": " + a.error, true); + return; + } + paths.push_back(a.path); + } + std::string note; + if (slot == AssetSlot::SkinClips && paths.size() > 16) { + note = "Kept the first 16 of " + std::to_string(paths.size()) + " clips; a skin plays at most 16."; + paths.resize(16); + } + const std::string& first = paths.front(); + doc.edit(target, [&](splash::Object& o) { + switch (slot) { + case AssetSlot::Texture: + if (!o.mesh) return; + if (o.mesh->materials.empty()) o.mesh->materials.emplace_back(); + o.mesh->materials[0].texture = first; + break; + case AssetSlot::Lua: + if (o.script) o.script->lua = first; + break; + case AssetSlot::TriggerLua: + if (o.trigger) o.trigger->lua = first; + break; + case AssetSlot::Clip: + if (!o.audio) return; + o.audio->clip = first; + if (o.audio->clipName.empty()) o.audio->clipName = utf8(fromUtf8(first).stem()); + break; + case AssetSlot::SkinClips: + if (o.skin) o.skin->clips = paths; + break; + } + }); + doc.endMerge(); + doc.refreshAssets(paths); + if (!note.empty()) notify(st, note, false); +} + // ---- File menu: projects, scenes, and the unsaved-changes prompt ---- static float centerY(ImFont* font, float size, float y0, float y1) { @@ -550,8 +642,6 @@ static void SDLCALL onFilePicked(void* kind, const char* const* files, int) { static const SDL_DialogFileFilter kSceneFilter[] = {{"psxsplash scene", "scene"}}; -static std::filesystem::path fromUtf8(const std::string& s) { return std::filesystem::path(std::u8string(s.begin(), s.end())); } - // Opens `file` as the document. The scene is parsed first, so a file that does // not load leaves the open document as it was. static bool openScene(State& st, editor::Document& doc, const std::filesystem::path& file) { @@ -972,6 +1062,7 @@ static void importPending(State& st, editor::Document& doc, const viewport::Ps1V pickedFor = g_modelTarget; } if (!picked.empty()) importModelFile(st, doc, view, picked, pickedFor); + applyPickedAssets(st, doc); std::vector> files; files.swap(st.drop.files); for (const auto& [file, pos] : files) { @@ -2419,15 +2510,19 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc dl->AddRect(ImVec2(x0, top), ImVec2(x1, y + space::sm), rgb(0xffffff, 10), radius::card); y += space::sm + space::md; }; + // Returns true on the frame its fix button is clicked. auto card = [&](const char* id, const char* title, const std::string& body, const char* fix) { - float h = problemCard(id, ImVec2(x0 + space::sm, y + space::xs), x1 - x0 - space::sm * 2, title, body.c_str(), fix); + bool fixed = false; + float h = problemCard(id, ImVec2(x0 + space::sm, y + space::xs), x1 - x0 - space::sm * 2, title, body.c_str(), fix, &fixed); y += h + space::sm; + return fixed; }; - // Card for a referenced file that is missing or could not be read. + // Card for a referenced file that is missing or could not be read; its + // Locate file button reopens the field's file dialog. auto fileCard = [&](const char* id, editor::AssetStatus st, const std::string& path, const std::string& error) { - if (st == editor::AssetStatus::Ok) return; - if (st == editor::AssetStatus::Missing) card(id, "File not found", path.empty() ? "No file is set." : path, "Locate file"); - else card(id, "File could not be read", error, "Locate file"); + if (st == editor::AssetStatus::Ok) return false; + if (st == editor::AssetStatus::Missing) return card(id, "File not found", path.empty() ? "No file is set." : path, "Locate file"); + return card(id, "File could not be read", error, "Locate file"); }; // Start active: the object is in the game either way; off loads it hidden, @@ -2493,8 +2588,15 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc removeFlag(editor::ComponentKind::Mesh)); y += 34 + space::xs; const ImRect modelRow = row("lmodel", "Model", "The 3D model to draw. Click to pick a .glb or .gltf file, or drop one here."); - if (assetField("model", modelRow, icon::box, meshSt == editor::AssetStatus::Ok ? kind::mesh : color::warn, modelName.c_str(), - tris.empty() ? nullptr : tris.c_str())) { + bool pickModel = assetField("model", modelRow, icon::box, meshSt == editor::AssetStatus::Ok ? kind::mesh : color::warn, + modelName.c_str(), tris.empty() ? nullptr : tris.c_str()); + st.modelField = modelRow; + st.modelField.ClipWith(r); + st.modelFieldPath = path; + if (st.drop.hovering && st.modelField.Contains(st.drop.pos)) + dl->AddRect(modelRow.Min - ImVec2(1, 1), modelRow.Max + ImVec2(1, 1), color::accent, radius::field + 1, 0, 2.0f); + if (fileCard("fixmesh", meshSt, m.mesh, mi ? mi->error : std::string())) pickModel = true; + if (pickModel) { { std::lock_guard lock(g_pickMutex); g_modelTarget = path; @@ -2502,24 +2604,26 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc static const SDL_DialogFileFilter modelFilter[] = {{"glTF models", "glb;gltf"}}; SDL_ShowOpenFileDialog(onModelPicked, nullptr, nullptr, modelFilter, 1, nullptr, false); } - st.modelField = modelRow; - st.modelField.ClipWith(r); - st.modelFieldPath = path; - if (st.drop.hovering && st.modelField.Contains(st.drop.pos)) - dl->AddRect(modelRow.Min - ImVec2(1, 1), modelRow.Max + ImVec2(1, 1), color::accent, radius::field + 1, 0, 2.0f); - fileCard("fixmesh", meshSt, m.mesh, mi ? mi->error : std::string()); - assetField("texture", row("ltex", "Texture", "Image painted on the model. Converted to PS1 colours on export."), icon::grid, - texSt != editor::AssetStatus::Ok || to4 ? color::warn : color::textDim, texName.c_str(), texMeta.c_str()); - fileCard("fixtex", texSt, texPath, ti ? ti->error : std::string()); + if (assetField("texture", row("ltex", "Texture", "Image painted on the model. Click to pick an image. Converted to PS1 colours on export."), + icon::grid, texSt != editor::AssetStatus::Ok || to4 ? color::warn : color::textDim, texName.c_str(), texMeta.c_str()) | + fileCard("fixtex", texSt, texPath, ti ? ti->error : std::string())) + pickAsset(AssetSlot::Texture, path); if (to4) { int cur = m.bitDepth == splash::BitDepth::Bpp8 ? ti->vramBytes8 : ti->vramBytes16; std::string body = texName + " has " + std::to_string(ti->colors15) + (ti->colors15 == 1 ? " colour" : " colours") + " and takes " + fmtKB(cur) + " of VRAM. At 4 bpp it keeps every colour and takes " + fmtKB(ti->vramBytes4) + "."; - card("fix4bpp", "Texture could be 4 bpp", body, "Convert to 4 bpp"); + if (card("fix4bpp", "Texture could be 4 bpp", body, "Convert to 4 bpp")) + doc.edit(path, [](splash::Object& ob) { ob.mesh->bitDepth = splash::BitDepth::Bpp4; }); } - dropdown("lighting", row("llight", "Lighting", "How light reaches this mesh. Baked vertex lighting costs nothing at runtime."), - icon::sun, lightingLabel(m.vertexColors)); + // Menu order follows the labels' enum: Baked vertex, Flat, Mesh colours. + const splash::VertexColorMode modes[3] = {splash::VertexColorMode::Baked, splash::VertexColorMode::Flat, + splash::VertexColorMode::Mesh}; + int curMode = (int)(std::find(std::begin(modes), std::end(modes), m.vertexColors) - std::begin(modes)); + int pickMode = dropdownMenu("lighting", row("llight", "Lighting", "How light reaches this mesh. Baked vertex lighting costs nothing at runtime."), + icon::sun, lightingLabel(m.vertexColors), {"Baked vertex", "Flat", "Mesh colours"}, curMode); + if (pickMode >= 0 && modes[pickMode] != m.vertexColors) + doc.edit(path, [&](splash::Object& ob) { ob.mesh->vertexColors = modes[pickMode]; }); sectionEnd(top); } @@ -2553,7 +2657,22 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc int pick = dropdownMenu("lkind", row("llkind", "Type", "Point shines in every direction, spot in a cone, directional from far away."), nullptr, lightKindLabel(l.kind), {"Point", "Spot", "Directional"}, cur); if (pick >= 0) doc.edit(path, [&](splash::Object& ob) { ob.light->kind = kinds[pick]; }); - colorField("lcol", row("llcol", "Colour", "The colour of the light."), toColor(l.color), toHex(l.color).c_str()); + const ImRect colRow = row("llcol", "Colour", "The colour of the light. Click to pick one."); + if (colorField("lcol", colRow, toColor(l.color), toHex(l.color).c_str())) ImGui::OpenPopup("##lcolpick"); + // Right-aligned under the field, so it stays inside the inspector. + ImGui::SetNextWindowPos(ImVec2(colRow.Max.x, colRow.Max.y + space::xs), ImGuiCond_Always, ImVec2(1, 0)); + pushPopupStyle(); + if (ImGui::BeginPopup("##lcolpick")) { + float c[3] = {l.color[0], l.color[1], l.color[2]}; + ImGui::SetNextItemWidth(220); + if (ImGui::ColorPicker3("##lcolwheel", c, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_DisplayHex | + ImGuiColorEditFlags_NoAlpha)) + doc.edit(path, [&](splash::Object& ob) { ob.light->color = {c[0], c[1], c[2]}; }, "lcol"); + if (ImGui::IsItemDeactivated()) doc.endMerge(); + ImGui::EndPopup(); + } + ImGui::PopStyleVar(4); + ImGui::PopStyleColor(2); float lv = l.intensity; commit(numberField("lint", row("llint", "Intensity", "How bright the light is. 1 is normal."), fmtShort(l.intensity).c_str(), nullptr, 0, nullptr, &lv), @@ -2581,9 +2700,10 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc removeFlag(editor::ComponentKind::Script)); y += 34 + space::xs; bool found = doc.fileExists(lua); - assetField("lua", row("llua", "File", "The Lua file that runs for this object."), icon::fileCode, - found ? kind::script : color::warn, lua.empty() ? "None" : fileName(lua).c_str(), nullptr); - fileCard("fixlua", found ? editor::AssetStatus::Ok : editor::AssetStatus::Missing, lua, std::string()); + if (assetField("lua", row("llua", "File", "The Lua file that runs for this object. Click to pick one."), icon::fileCode, + found ? kind::script : color::warn, lua.empty() ? "None" : fileName(lua).c_str(), nullptr) | + fileCard("fixlua", found ? editor::AssetStatus::Ok : editor::AssetStatus::Missing, lua, std::string())) + pickAsset(AssetSlot::Lua, path); sectionEnd(top); } @@ -2633,8 +2753,9 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc fmtFixed(tr.size.y).c_str(), fmtFixed(tr.size.z).c_str(), nullptr, sz), "tsize", [&](splash::Object& ob) { ob.trigger->size = {std::max(0.0f, sz[0]), std::max(0.0f, sz[1]), std::max(0.0f, sz[2])}; }); bool found = !tr.lua.empty() && doc.fileExists(tr.lua); - assetField("tlua", row("ltlua", "Script", "The Lua file that gets the enter and exit calls."), icon::fileCode, - tr.lua.empty() || found ? kind::script : color::warn, tr.lua.empty() ? "None" : fileName(tr.lua).c_str(), nullptr); + if (assetField("tlua", row("ltlua", "Script", "The Lua file that gets the enter and exit calls. Click to pick one."), icon::fileCode, + tr.lua.empty() || found ? kind::script : color::warn, tr.lua.empty() ? "None" : fileName(tr.lua).c_str(), nullptr)) + pickAsset(AssetSlot::TriggerLua, path); sectionEnd(top); } if (o->interactable) { @@ -2661,9 +2782,10 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc top = y; compSection("s_audio", editor::ComponentKind::Audio); bool found = doc.fileExists(a.clip); - assetField("clip", row("lclip", "Clip", "A WAV file, converted to SPU ADPCM at export."), icon::music, - found ? kind::audio : color::warn, a.clip.empty() ? "None" : fileName(a.clip).c_str(), nullptr); - fileCard("fixclip", found ? editor::AssetStatus::Ok : editor::AssetStatus::Missing, a.clip, std::string()); + if (assetField("clip", row("lclip", "Clip", "A WAV file, converted to SPU ADPCM at export. Click to pick one."), icon::music, + found ? kind::audio : color::warn, a.clip.empty() ? "None" : fileName(a.clip).c_str(), nullptr) | + fileCard("fixclip", found ? editor::AssetStatus::Ok : editor::AssetStatus::Missing, a.clip, std::string())) + pickAsset(AssetSlot::Clip, path); sectionEnd(top); } if (o->skin) { @@ -2671,7 +2793,9 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc top = y; compSection("s_skin", editor::ComponentKind::Skin); std::string clips = std::to_string(sk.clips.size()) + (sk.clips.size() == 1 ? " clip" : " clips"); - assetField("clips", row("lclips", "Clips", "Animation clips, at most 16."), icon::play, kind::camera, clips.c_str(), nullptr); + if (assetField("clips", row("lclips", "Clips", "Animation clips, at most 16. Click to pick the .anim files; the pick replaces the list."), + icon::play, kind::camera, clips.c_str(), nullptr)) + pickAsset(AssetSlot::SkinClips, path); number("sfps", "Bake rate", "Frames per second the clips are sampled at.", static_cast(sk.fps), "fps", "sfps", 1, 30, [](splash::Object& ob, float v) { ob.skin->fps = static_cast(std::lround(v)); }); sectionEnd(top); diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index b2fac87..6be644f 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -760,7 +760,7 @@ bool toggle(const char* id, ImRect r, bool on) { return h.clicked; } -void colorField(const char* id, ImRect r, ImU32 col, const char* hex) { +bool colorField(const char* id, ImRect r, ImU32 col, const char* hex) { Fonts& f = fonts(); Hit h = interact(id, r); ImDrawList* dl = ImGui::GetWindowDrawList(); @@ -768,6 +768,7 @@ void colorField(const char* id, ImRect r, ImU32 col, const char* hex) { ImRect sw(r.Min + ImVec2(3, 3), ImVec2(r.Min.x + 34, r.Max.y - 3)); dl->AddRectFilled(sw.Min, sw.Max, col, radius::field - 1); text(dl, ImVec2(sw.Max.x + space::sm, centerY(f.regular, type::body, r.Min.y, r.Max.y)), f.regular, type::body, color::text, hex); + return h.clicked; } void slider(const char* id, ImRect r, float t, const char* value) { diff --git a/src/ui/widgets.h b/src/ui/widgets.h index 72f8935..e2f68f5 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -123,7 +123,8 @@ int picker(const char* id, ImVec2 pos, float width, const char* placeholder, std bool assetField(const char* id, ImRect r, const char* icon, ImU32 iconColor, const char* name, const char* meta); // Returns true on the frame the switch is clicked. bool toggle(const char* id, ImRect r, bool on); -void colorField(const char* id, ImRect r, ImU32 col, const char* hex); +// Returns true when clicked. +bool colorField(const char* id, ImRect r, ImU32 col, const char* hex); void slider(const char* id, ImRect r, float t, const char* value); // Problem card with an inline fix action. Returns its height.