#include #include #include #include #include #include #include #include namespace faset::editor { namespace { namespace fs = std::filesystem; using ui::Kind; using ui::Widget; fs::path user_home() { #ifdef _WIN32 if (const auto* value = std::getenv("USERPROFILE")) return fs::path(value); #else if (const auto* value = std::getenv("HOME")) return fs::path(value); #endif return fs::current_path(); } fs::path recent_path() { #ifdef _WIN32 if (const auto* value = std::getenv("APPDATA")) return fs::path(value) / "Faset/recent-projects.json"; #else if (const auto* value = std::getenv("XDG_CONFIG_HOME"); value && *value) return fs::path(value) / "faset/recent-projects.json"; #endif return user_home() / ".config/faset/recent-projects.json"; } std::string trimmed(std::string text) { const auto first = text.find_first_not_of(" \t\r\n"); if (first == std::string::npos) return {}; return text.substr(first, text.find_last_not_of(" \t\r\n") - first + 1); } fs::path normalize(std::string text) { text = trimmed(std::move(text)); if (text.empty()) throw std::runtime_error("Enter a project directory."); if (text.find_first_of("\r\n\t") != std::string::npos || text.find('\0') != std::string::npos) throw std::runtime_error("A directory must fit on one line."); fs::path path = fs::u8path(text); if (text == "~") path = user_home(); else if (text.starts_with("~/") || text.starts_with("~\\")) path = user_home() / fs::u8path(text.substr(2)); path = fs::weakly_canonical(fs::absolute(path)); if (path.filename() == "project.faset.json") path = path.parent_path(); return path; } std::string utf8(const fs::path& path) { const auto value = path.u8string(); return std::string(reinterpret_cast(value.data()), value.size()); } ProjectSelection open_project(const fs::path& path) { if (!fs::is_directory(path)) throw std::runtime_error("Project directory does not exist."); if (!fs::is_regular_file(path / "project.faset.json")) throw std::runtime_error("No project.faset.json in this directory."); Json data; try { data = read_json(path / "project.faset.json"); } catch (...) { throw std::runtime_error("Cannot read project.faset.json."); } if (!data.is_object() || data.value("format", std::string()) != "faset.project" || data.value("version", 0) != 1) throw std::runtime_error("Unsupported project format or version."); auto name = data.value("name", std::string()); const auto dimension = data.value("dimension", 3); if (name.empty() || (dimension != 2 && dimension != 3)) throw std::runtime_error("Project name or scene type is invalid."); return {path, std::move(name), dimension, false}; } Json recent_records(const fs::path& file) { try { auto j = read_json(file); return j.is_array() ? j : Json::array(); } catch (...) { return Json::array(); } } void remember(const fs::path& project, const fs::path& store) { const auto selected = open_project(normalize(utf8(project))); auto records = recent_records(store); std::erase_if(records.get_ref(), [&](const Json& record) { return !record.is_string() || record.get() == utf8(selected.path); }); records.insert(records.begin(), utf8(selected.path)); while (records.size() > 12) records.erase(records.end() - 1); atomic_write_json(store, records); } Widget& button(Widget& parent, const std::string& id, const std::string& text, std::function action) { auto& w = parent.add(Kind::Button, id, text); w.on_click = [action = std::move(action)](Widget&) { action(); }; return w; } } // namespace void remember_project(const fs::path& project) { try { remember(project, recent_path()); } catch (...) { /* Recent history is not required to open a project. */ } } struct ProjectLauncher::Impl { render::Renderer& renderer; ui::Context ui; render::Snapshot frame_data; fs::path recents_file, browse_path; std::optional pending_browse; std::optional selected; bool create = false, cancelled = false, browsing = false, browser_valid = false; int dimension = 3; std::string error; Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial, const fs::path& recents) : renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"), recents_file(recents.empty() ? recent_path() : recents) { auto theme = ui::Theme::load(engine / "assets/ui/dark.json"); theme.font_size = 15; theme.row_height = 30; ui.set_theme(theme); ui.apply_layout(read_json(engine / "assets/ui/project-launcher-layout.json")); ui.set_clipboard([this] { return renderer.clipboard(); }, [this](const std::string& value) { renderer.set_clipboard(value); }); ui.set_ime( [this](bool enabled) { renderer.set_text_input(enabled); }, [this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); }); const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial; ui.update_text("launcher-path", utf8(start)); create = initial.empty(); ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); }; ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); }; ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; }; ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; }; ui.find("launcher-submit")->selected = true; ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); }; ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; }; ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); }; ui.find("launcher-path")->on_preview = [this](Widget&) { error.clear(); }; ui.find("launcher-browse")->on_click = [this](Widget&) { show_browser(); }; build_browser(); load_recents(); refresh(); ui.layout(float(renderer.width()), float(renderer.height())); ui.focus(create ? "launcher-name" : "launcher-path"); } void set_mode(bool value) { ui.clear_focus(); create = value; error.clear(); refresh(); ui.focus(create ? "launcher-name" : "launcher-path"); } void load_recents() { auto& list = *ui.find("launcher-recents"); std::size_t i = 0; std::set paths; for (const auto& record : recent_records(recents_file)) { if (!record.is_string()) continue; try { const auto selection = open_project(normalize(record.get())); if (!paths.insert(selection.path).second) continue; const auto id = "launcher-recent-" + std::to_string(i++); auto& item = list.add(Kind::TreeRow, id, selection.name); item.layout.height = 40; item.tooltip = utf8(selection.path); item.on_click = [this, path = selection.path](Widget&) { ui.update_text("launcher-path", utf8(path), true); set_mode(false); }; } catch (...) { } } if (i == 0) list.add(Kind::Label, "launcher-recents-empty", "No recent projects"); } void submit() { ui.clear_focus(); if (ui.editing()) return; try { const auto path = normalize(ui.find("launcher-path")->text); if (create) { const auto name = trimmed(ui.find("launcher-name")->text); if (name.empty()) throw std::runtime_error("Enter a project name."); if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos) throw std::runtime_error("Use a short project name on one line."); if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path))) throw std::runtime_error("Create requires a new or empty directory."); auto parent = path.parent_path(); while (!parent.empty() && !fs::exists(parent)) parent = parent.parent_path(); if (parent.empty() || !fs::is_directory(parent)) throw std::runtime_error("Project parent directory is unavailable."); selected = ProjectSelection{path, name, dimension, true}; } else selected = open_project(path); error.clear(); } catch (const fs::filesystem_error&) { error = "Cannot access this directory."; } catch (const Json::exception&) { error = "Invalid project.faset.json metadata."; } catch (const std::exception& e) { error = e.what(); } } void build_browser() { auto& panel = ui.root().add(Kind::Panel, "launcher-browser"); panel.layout.absolute = true; panel.layout.padding = 16; panel.layout.gap = 10; panel.visible = false; auto& title = panel.add(Kind::Label, "browser-heading", "Choose project directory"); title.font_size = 20; title.layout.height = 35; auto& nav = panel.add(Kind::Row, "browser-navigation"); nav.layout.height = 36; button(nav, "browser-up", "Up", [this] { browse_to(browse_path.parent_path()); }).layout.width = 60; button(nav, "browser-home", "Home", [this] { browse_to(user_home()); }).layout.width = 76; auto& path = nav.add(Kind::TextField, "browser-path"); path.layout.flex = 1; path.on_preview = [this](Widget&) { browser_valid = false; }; path.on_commit = [this](Widget& w) { try { pending_browse = normalize(w.text); } catch (const std::exception& e) { error = e.what(); } }; auto& list = panel.add(Kind::Column, "browser-list"); list.layout.flex = 1; list.layout.scroll = true; list.layout.gap = 2; auto& actions = panel.add(Kind::Row, "browser-actions"); actions.layout.height = 36; button(actions, "browser-choose", "Choose directory", [this] { if (browser_valid) { ui.update_text("launcher-path", utf8(browse_path), true); close_browser(); } }).layout.width = 180; button(actions, "browser-cancel", "Cancel", [this] { close_browser(); }).layout.width = 90; panel.add(Kind::Label, "browser-error"); } void browse_to(const fs::path& path) { browser_valid = false; try { const auto normalized = fs::weakly_canonical(fs::absolute(path)); if (!fs::is_directory(normalized)) throw std::runtime_error("Directory does not exist."); std::vector children; for (const auto& entry : fs::directory_iterator( normalized, fs::directory_options::skip_permission_denied)) { std::error_code status; if (entry.is_directory(status)) children.push_back(entry.path()); } std::sort(children.begin(), children.end()); browse_path = normalized; error.clear(); browser_valid = true; auto& list = *ui.find("browser-list"); list.children.clear(); list.scroll_y = 0; ui.update_text("browser-path", utf8(browse_path), true); std::size_t index = 0; for (const auto& child : children) { auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++), "[Folder] " + utf8(child.filename())); row.layout.height = 34; row.on_click = [this, child](Widget&) { ui.clear_focus(); browse_to(child); }; } if (children.empty()) list.add(Kind::Label, "browser-empty", "No subdirectories"); } catch (const fs::filesystem_error&) { error = "Cannot access this directory."; } catch (const std::exception& e) { error = e.what(); } } void show_browser() { ui.clear_focus(); fs::path path = user_home(); try { path = normalize(ui.find("launcher-path")->text); while (!path.empty() && !fs::exists(path)) path = path.parent_path(); if (!fs::is_directory(path)) path = path.parent_path(); } catch (...) { } browsing = true; refresh(); browse_to(path); ui.focus("browser-path"); } void close_browser() { ui.clear_focus(false); browsing = false; error.clear(); refresh(); ui.focus("launcher-path"); } void navigate_pending() { if (pending_browse) { const auto path = *pending_browse; pending_browse.reset(); browse_to(path); } } void refresh() { ui.find("launcher-open")->selected = !create; ui.find("launcher-create")->selected = create; ui.find("launcher-2d")->selected = dimension == 2; ui.find("launcher-3d")->selected = dimension == 3; for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label", "launcher-dimensions"}) ui.find(id)->visible = create; ui.find("launcher-heading")->text = create ? "Create project" : "Open project"; ui.find("launcher-submit")->text = create ? "Create project" : "Open project"; ui.find("launcher-hint")->text = create ? "2D and 3D can share one project." : "Open a directory containing project.faset.json."; ui.find("launcher-error")->text = browsing ? "" : error; ui.find("browser-error")->text = error; ui.find("browser-choose")->enabled = browser_valid; ui.find("launcher-body")->enabled = !browsing; auto* dialog = ui.find("launcher-browser"); dialog->visible = browsing; dialog->layout.width = std::max(320.f, std::min(760.f, float(renderer.width()) - 40)); dialog->layout.height = std::max(300.f, std::min(540.f, float(renderer.height()) - 40)); dialog->layout.x = (float(renderer.width()) - dialog->layout.width) * .5f; dialog->layout.y = (float(renderer.height()) - dialog->layout.height) * .5f; ui.find("launcher-sidebar")->layout.width = std::clamp(float(renderer.width()) * .24f, 180.f, 235.f); ui.find("launcher-main")->layout.padding = renderer.width() < 850 ? 16 : 32; } void frame(const std::vector& events) { refresh(); ui.layout(float(renderer.width()), float(renderer.height())); for (const auto& event : events) { if (event.type == render::Event::Type::Quit) cancelled = true; if (event.type == render::Event::Type::KeyDown && event.key == "Escape") { if (browsing) close_browser(); else cancelled = true; continue; } if (event.type == render::Event::Type::KeyDown && event.control && event.key == "Return") { if (browsing) { ui.clear_focus(); navigate_pending(); if (!ui.editing() && browser_valid) { ui.update_text("launcher-path", utf8(browse_path), true); close_browser(); } } else submit(); continue; } if (browsing && event.type == render::Event::Type::MouseDown && !ui.find("launcher-browser")->rect.contains(event.x, event.y)) continue; ui.handle(event); navigate_pending(); refresh(); ui.layout(float(renderer.width()), float(renderer.height())); } refresh(); ui.layout(float(renderer.width()), float(renderer.height())); frame_data = {}; frame_data.clear_color = ui.theme().background; ui.draw(frame_data); } }; ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine, const fs::path& initial, const fs::path& recents) : impl_(std::make_unique(r, engine, initial, recents)) {} ProjectLauncher::~ProjectLauncher() = default; void ProjectLauncher::frame(const std::vector& events) { impl_->frame(events); } const render::Snapshot& ProjectLauncher::snapshot() const { return impl_->frame_data; } ui::Context& ProjectLauncher::widgets() { return impl_->ui; } const std::optional& ProjectLauncher::selection() const { return impl_->selected; } bool ProjectLauncher::cancelled() const { return impl_->cancelled; } std::optional run_project_launcher(const fs::path& engine, const fs::path& initial, std::uint64_t max_frames, const fs::path& capture) { render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true}); ProjectLauncher launcher(renderer, engine, initial); std::uint64_t frames = 0; while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() && (max_frames == 0 || frames < max_frames)) { launcher.frame(renderer.poll_events()); renderer.render(launcher.snapshot()); ++frames; std::this_thread::sleep_for(std::chrono::milliseconds(1)); } if (!capture.empty()) renderer.capture(capture); if (renderer.stats().validation_errors) throw std::runtime_error("Project launcher Vulkan validation failed"); return launcher.selection(); } } // namespace faset::editor