From 06398a7a176e123506de6e8851866a8bec0b3427 Mon Sep 17 00:00:00 2001 From: hachem Date: Mon, 24 Aug 2026 17:07:54 +0200 Subject: [chore]: cleanup shaders --- src/core/settings_manager.cpp | 10 +- src/core/settings_manager.h | 4 - src/platform/opengl/opengl_device.cpp | 6 + src/rendering/scene_renderer.cpp | 6 +- src/ui/ui_layer.cpp | 691 +++++++++++++++++++++------------- src/ui/ui_layer.h | 60 ++- src/ui/workspace.h | 23 +- 7 files changed, 485 insertions(+), 315 deletions(-) (limited to 'src') diff --git a/src/core/settings_manager.cpp b/src/core/settings_manager.cpp index 6909c60..eb7f800 100644 --- a/src/core/settings_manager.cpp +++ b/src/core/settings_manager.cpp @@ -53,8 +53,6 @@ namespace Donut s_settings.simulation.disk_thickness = toml::find_or(sim, "disk_thickness", 0.1f); s_settings.simulation.disk_density = toml::find_or(sim, "disk_density", 0.1f); s_settings.simulation.rotation_speed = toml::find_or(sim, "rotation_speed", 1.0f); - s_settings.simulation.blur_strength = toml::find_or(sim, "blur_strength", 2.0f); - s_settings.simulation.glow_intensity = toml::find_or(sim, "glow_intensity", 0.1f); s_settings.simulation.target_fps = std::max(30, std::min(120, s_settings.simulation.target_fps)); s_settings.simulation.compute_height = std::max(64, std::min(2048, s_settings.simulation.compute_height)); @@ -64,8 +62,6 @@ namespace Donut s_settings.simulation.disk_thickness = std::max(0.01f, std::min(5.0f, s_settings.simulation.disk_thickness)); s_settings.simulation.disk_density = std::max(0.01f, std::min(5.0f, s_settings.simulation.disk_density)); s_settings.simulation.rotation_speed = std::max(0.0f, std::min(5.0f, s_settings.simulation.rotation_speed)); - s_settings.simulation.blur_strength = std::max(0.1f, std::min(10.0f, s_settings.simulation.blur_strength)); - s_settings.simulation.glow_intensity = std::max(0.01f, std::min(5.0f, s_settings.simulation.glow_intensity)); } if (config.contains("graphics")) @@ -123,9 +119,7 @@ namespace Donut {"gravity_enabled", s_settings.simulation.gravity_enabled }, {"disk_thickness", s_settings.simulation.disk_thickness }, {"disk_density", s_settings.simulation.disk_density }, - {"rotation_speed", s_settings.simulation.rotation_speed }, - {"blur_strength", s_settings.simulation.blur_strength }, - {"glow_intensity", s_settings.simulation.glow_intensity } + {"rotation_speed", s_settings.simulation.rotation_speed } }; toml::value graphics = toml::table @@ -185,8 +179,6 @@ namespace Donut s_settings.simulation.disk_thickness = 0.1f; s_settings.simulation.disk_density = 0.1f; s_settings.simulation.rotation_speed = 1.0f; - s_settings.simulation.blur_strength = 2.0f; - s_settings.simulation.glow_intensity = 0.1f; s_settings.graphics.render_api = "OpenGL"; s_settings.graphics.v_sync_enabled = true; diff --git a/src/core/settings_manager.h b/src/core/settings_manager.h index 95826c7..e28e0e1 100644 --- a/src/core/settings_manager.h +++ b/src/core/settings_manager.h @@ -16,8 +16,6 @@ namespace Donut float disk_thickness = 0.1f; float disk_density = 0.1f; float rotation_speed = 1.0f; - float blur_strength = 2.0f; - float glow_intensity = 0.1f; }; struct GraphicsSettings @@ -61,8 +59,6 @@ namespace Donut static auto get_disk_thickness() -> float { return s_settings.simulation.disk_thickness; } static auto get_disk_density() -> float { return s_settings.simulation.disk_density; } static auto get_rotation_speed() -> float { return s_settings.simulation.rotation_speed; } - static auto get_blur_strength() -> float { return s_settings.simulation.blur_strength; } - static auto get_glow_intensity() -> float { return s_settings.simulation.glow_intensity; } static auto get_render_api() -> std::string { return s_settings.graphics.render_api; } static auto get_v_sync_enabled() -> bool { return s_settings.graphics.v_sync_enabled; } static auto get_show_fps() -> bool { return s_settings.graphics.show_fps; } diff --git a/src/platform/opengl/opengl_device.cpp b/src/platform/opengl/opengl_device.cpp index 7ef0ba1..614e515 100644 --- a/src/platform/opengl/opengl_device.cpp +++ b/src/platform/opengl/opengl_device.cpp @@ -120,11 +120,17 @@ namespace Donut::RHI { GLuint bi = glGetUniformBlockIndex(m_prog, r.name.c_str()); if (bi != GL_INVALID_INDEX) glUniformBlockBinding(m_prog, bi, r.binding); + // Not found: the ConstantBuffer<> type name in the shader no longer + // matches this resource string, so the UBO silently won't bind. + else DONUT_WARN("GL RHI: shader '{}' has no uniform block '{}' (binding {})", + d.shader, r.name, r.binding); } else // Texture: point the sampler at texture unit == binding { GLint loc = glGetUniformLocation(m_prog, r.name.c_str()); if (loc >= 0) glUniform1i(loc, (GLint)r.binding); + else DONUT_WARN("GL RHI: shader '{}' has no sampler '{}' (unit {})", + d.shader, r.name, r.binding); } } glUseProgram(0); diff --git a/src/rendering/scene_renderer.cpp b/src/rendering/scene_renderer.cpp index 50b666d..bec609c 100644 --- a/src/rendering/scene_renderer.cpp +++ b/src/rendering/scene_renderer.cpp @@ -70,7 +70,7 @@ namespace Donut PipelineDesc d; d.shader = "grid"; d.vertex_layout = { sizeof(glm::vec3), { { 0, 3, 0 } } }; - d.resources = { { ResourceKind::UniformBuffer, 0, "GridU" } }; + d.resources = { { ResourceKind::UniformBuffer, 0, "Grid" } }; d.topology = Topology::Lines; d.blend = BlendMode::AlphaBlend; d.depth_test = true; d.depth_write = false; d.depth_op = CompareOp::LessEqual; @@ -107,7 +107,7 @@ namespace Donut PipelineDesc d; d.shader = "sphere"; d.vertex_layout = { 6 * sizeof(float), { { 0, 3, 0 }, { 1, 3, 3 * sizeof(float) } } }; - d.resources = { { ResourceKind::UniformBuffer, 0, "SphereU" }, + d.resources = { { ResourceKind::UniformBuffer, 0, "Sphere" }, { ResourceKind::Texture, 1, "u_HDRIEnvironment" } }; d.topology = Topology::Triangles; d.depth_test = true; d.depth_write = true; d.depth_op = CompareOp::Less; @@ -132,7 +132,7 @@ namespace Donut PipelineDesc d; d.shader = "skybox"; d.vertex_layout = { 3 * sizeof(float), { { 0, 3, 0 } } }; - d.resources = { { ResourceKind::UniformBuffer, 0, "SkyboxU" }, + d.resources = { { ResourceKind::UniformBuffer, 0, "Skybox" }, { ResourceKind::Texture, 1, "u_Skybox" } }; d.topology = Topology::Triangles; d.depth_test = false; d.depth_write = false; diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp index f1ec4d2..88e1b1d 100644 --- a/src/ui/ui_layer.cpp +++ b/src/ui/ui_layer.cpp @@ -6,16 +6,25 @@ #include "rendering/render_api.h" #include +#include // DockBuilder* #include #include #include #include -#include #include #include namespace Donut { + // Panel window titles. These are the identity DockBuilder docks against, so + // they must match the ImGui::Begin() titles exactly. + static const char* kOutliner = "Outliner"; + static const char* kProperties = "Properties"; + static const char* kBlackHole = "Black Hole"; + static const char* kSettings = "Settings"; + static const char* kExport = "Export"; + static const char* kStats = "Stats"; + // One-time ImGui theme: rounded, roomy, dark with a warm accretion-disk accent. static auto apply_donut_style() -> void { @@ -32,6 +41,7 @@ namespace Donut c[ImGuiCol_WindowBg] = ImVec4(0.07f, 0.08f, 0.10f, 0.97f); c[ImGuiCol_ChildBg] = ImVec4(0.10f, 0.11f, 0.13f, 0.55f); c[ImGuiCol_PopupBg] = ImVec4(0.09f, 0.10f, 0.12f, 0.98f); + c[ImGuiCol_MenuBarBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f); c[ImGuiCol_TitleBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f); c[ImGuiCol_TitleBgActive] = ImVec4(0.13f, 0.14f, 0.17f, 1.00f); c[ImGuiCol_Text] = ImVec4(0.90f, 0.91f, 0.93f, 1.00f); @@ -53,163 +63,260 @@ namespace Donut c[ImGuiCol_TabActive] = ImVec4(0.22f, 0.24f, 0.28f, 1.00f); c[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.26f, 1.00f); c[ImGuiCol_ScrollbarGrab] = ImVec4(0.24f, 0.26f, 0.30f, 1.00f); + c[ImGuiCol_DockingPreview] = ImVec4(0.98f, 0.62f, 0.20f, 0.55f); + c[ImGuiCol_DockingEmptyBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); // pass-through } - namespace + auto UILayer::draw(const UIContext& ctx) -> View { - // Setup: display / renderer / overlay / environment settings - class SetupWorkspace : public Workspace + static bool styled = false; + if (!styled) { apply_donut_style(); styled = true; } + + ImGuizmo::BeginFrame(); // the Properties panel draws the scene gizmo + + draw_menu_bar(ctx); + + // Full-viewport dock space with a pass-through centre: panels dock to the + // edges while the render shows through the middle. Sits under the menu bar. + // ImGui owns the id (stable per viewport) and persists the layout in imgui.ini. + const ImGuiID dockspace_id = ImGui::DockSpaceOverViewport( + 0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode); + + // On the first frame, seed a default layout unless imgui.ini restored one + // (a restored dock tree has splits/windows, so the root node isn't empty). + if (m_first_frame) { - public: - auto name() const -> const char* override { return "Setup"; } - auto view() const -> View override { return View::None; } - auto on_ui(const UIContext& ctx) -> void override - { - auto& s = SettingsManager::get_settings(); + m_first_frame = false; + ImGuiDockNode* node = ImGui::DockBuilderGetNode(dockspace_id); + if (node == nullptr || node->IsEmpty()) + m_rebuild_layout = true; + } + if (m_rebuild_layout) + { + build_layout(dockspace_id); + m_rebuild_layout = false; + } - if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen)) - { - const bool fs = ctx.actions.is_fullscreen(); - int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch); - - // Resolution presets (windowed only). - struct Res { const char* label; int w, h; }; - static const Res kRes[] = { - { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 }, - { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 }, - }; - char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch); - ImGui::BeginDisabled(fs); - if (ImGui::BeginCombo("Resolution", cur_label)) - { - for (const auto& r : kRes) - if (ImGui::Selectable(r.label, cw == r.w && ch == r.h)) - ctx.actions.set_resolution(r.w, r.h); - ImGui::EndCombo(); - } - ImGui::EndDisabled(); + if (m_show_outliner) panel_outliner(ctx); + if (m_show_properties) panel_properties(ctx); + if (m_show_black_hole) panel_black_hole(ctx); + if (m_show_settings) panel_settings(ctx); + if (m_show_export) panel_export(ctx); + if (m_show_stats) panel_stats(ctx); - bool fs_v = fs; - if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v); + return m_mode == Mode::Scene ? View::Scene : View::BlackHole; + } - if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled)) - { - ctx.actions.set_vsync(s.graphics.v_sync_enabled); - SettingsManager::save_settings(); - } + auto UILayer::draw_menu_bar(const UIContext& ctx) -> void + { + if (!ImGui::BeginMainMenuBar()) return; - ImGui::BeginDisabled(s.graphics.v_sync_enabled); - if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS")) - SettingsManager::save_settings(); - ImGui::EndDisabled(); - if (s.graphics.v_sync_enabled) - ImGui::TextDisabled("Frame cap applies only with V-Sync off."); + if (ImGui::BeginMenu("View")) + { + ImGui::TextDisabled("Centre viewport"); + ImGui::Separator(); + if (ImGui::MenuItem("Scene", nullptr, m_mode == Mode::Scene)) m_mode = Mode::Scene; + if (ImGui::MenuItem("Simulation", nullptr, m_mode == Mode::Simulation)) m_mode = Mode::Simulation; + ImGui::EndMenu(); + } - if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx")) - ctx.actions.set_ui_scale(m_ui_scale); - } + if (ImGui::BeginMenu("Window")) + { + ImGui::MenuItem(kOutliner, nullptr, &m_show_outliner); + ImGui::MenuItem(kProperties, nullptr, &m_show_properties); + ImGui::MenuItem(kBlackHole, nullptr, &m_show_black_hole); + ImGui::MenuItem(kSettings, nullptr, &m_show_settings); + ImGui::MenuItem(kExport, nullptr, &m_show_export); + ImGui::MenuItem(kStats, nullptr, &m_show_stats); + ImGui::EndMenu(); + } - if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen)) - { - const char* apis[] = { "OpenGL", "Vulkan" }; - int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0; - if (ImGui::Combo("Graphics API", &cur, apis, 2)) - { - s.graphics.render_api = apis[cur]; - SettingsManager::save_settings(); - } - const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL"; - if (s.graphics.render_api != running) - ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running); - else - ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str()); - } + if (ImGui::BeginMenu("Layout")) + { + ImGui::TextDisabled("Default layouts"); + ImGui::Separator(); + if (ImGui::MenuItem("Simulation", nullptr, m_layout == Layout::Simulation)) apply_layout(Layout::Simulation); + if (ImGui::MenuItem("Scene editing", nullptr, m_layout == Layout::SceneEditing)) apply_layout(Layout::SceneEditing); + if (ImGui::MenuItem("Export", nullptr, m_layout == Layout::Export)) apply_layout(Layout::Export); + ImGui::Separator(); + if (ImGui::MenuItem("Reset current layout")) m_rebuild_layout = true; + ImGui::EndMenu(); + } - if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen)) - { - bool dirty = false; - dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps); - dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics); - if (dirty) SettingsManager::save_settings(); - } + // Right-aligned mode + FPS readout. + char status[64]; + std::snprintf(status, sizeof(status), "%s %.0f FPS", + m_mode == Mode::Scene ? "Scene" : "Simulation", ImGui::GetIO().Framerate); + float tw = ImGui::CalcTextSize(status).x; + ImGui::SameLine(ImGui::GetWindowWidth() - tw - 16.0f); + ImGui::TextDisabled("%s", status); - if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen)) - { - auto& hdri = HDRIManager::get(); - std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path); - if (ImGui::BeginCombo("Starfield", preview.c_str())) - { - for (const auto& path : hdri.get_available_hdri()) - { - bool selected = (path == ctx.scene.hdri_path); - if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected)) - ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause)."); - } - } - private: - float m_ui_scale = 1.0f; - }; + ImGui::EndMainMenuBar(); + } - // Scene: place and edit objects - class SceneWorkspace : public Workspace + // Rebuild the dock tree for the current layout. Windows named but not currently + // visible still get a dock home here, so toggling them on lands them sensibly. + auto UILayer::build_layout(unsigned int dockspace_id) -> void + { + ImGui::DockBuilderRemoveNode(dockspace_id); + // DockSpace is a private flag, PassthruCentralNode a public one; combine as + // int to avoid the C++20 mixed-enum bitwise-or warning. + ImGui::DockBuilderAddNode(dockspace_id, + (int)ImGuiDockNodeFlags_DockSpace | (int)ImGuiDockNodeFlags_PassthruCentralNode); + ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->WorkSize); + + ImGuiID centre = dockspace_id; + + switch (m_layout) + { + case Layout::SceneEditing: + { + ImGuiID left = 0, left_bottom = 0, right = 0; + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Left, 0.24f, &left, ¢re); + ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.50f, &left_bottom, &left); + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, ¢re); + ImGui::DockBuilderDockWindow(kOutliner, left); + ImGui::DockBuilderDockWindow(kProperties, left_bottom); + ImGui::DockBuilderDockWindow(kBlackHole, right); + ImGui::DockBuilderDockWindow(kSettings, right); + ImGui::DockBuilderDockWindow(kExport, right); + ImGui::DockBuilderDockWindow(kStats, right); + break; + } + case Layout::Export: + { + ImGuiID right = 0, right_bottom = 0; + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.28f, &right, ¢re); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.50f, &right_bottom, &right); + ImGui::DockBuilderDockWindow(kExport, right); + ImGui::DockBuilderDockWindow(kOutliner, right); + ImGui::DockBuilderDockWindow(kProperties, right); + ImGui::DockBuilderDockWindow(kBlackHole, right_bottom); + ImGui::DockBuilderDockWindow(kSettings, right_bottom); + ImGui::DockBuilderDockWindow(kStats, right_bottom); + break; + } + case Layout::Simulation: + default: + { + ImGuiID right = 0, right_bottom = 0; + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, ¢re); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.30f, &right_bottom, &right); + ImGui::DockBuilderDockWindow(kBlackHole, right); + ImGui::DockBuilderDockWindow(kSettings, right); + ImGui::DockBuilderDockWindow(kExport, right); + ImGui::DockBuilderDockWindow(kOutliner, right); + ImGui::DockBuilderDockWindow(kProperties, right); + ImGui::DockBuilderDockWindow(kStats, right_bottom); + break; + } + } + + ImGui::DockBuilderFinish(dockspace_id); + } + + // Apply a preset: set the centre view, choose which panels are visible, and + // request a dock-tree rebuild next frame. + auto UILayer::apply_layout(Layout layout) -> void + { + m_layout = layout; + m_rebuild_layout = true; + switch (layout) + { + case Layout::SceneEditing: + m_mode = Mode::Scene; + m_show_outliner = true; m_show_properties = true; + m_show_black_hole = false; m_show_settings = false; m_show_export = false; + m_show_stats = true; + break; + case Layout::Export: + m_mode = Mode::Simulation; + m_show_export = true; m_show_black_hole = true; + m_show_outliner = false; m_show_properties = false; m_show_settings = false; + m_show_stats = true; + break; + case Layout::Simulation: + default: + m_mode = Mode::Simulation; + m_show_black_hole = true; + m_show_outliner = false; m_show_properties = false; + m_show_settings = false; m_show_export = false; + m_show_stats = true; + break; + } + } + + auto UILayer::panel_outliner(const UIContext& ctx) -> void + { + if (ImGui::Begin(kOutliner, &m_show_outliner)) { - public: - auto name() const -> const char* override { return "Scene"; } - auto view() const -> View override { return View::Scene; } - auto on_ui(const UIContext& ctx) -> void override + Scene& sc = ctx.scene; + auto& objs = sc.objects; + if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1; + + if (ImGui::Button("Add sphere") && objs.size() < 64) { - Scene& sc = ctx.scene; - ImGui::TextDisabled("Drag to orbit, scroll to zoom. HDRI skybox behind."); - auto& objs = sc.objects; + SceneObject o; + o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f); + o.radius = 1.5f; + o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4)); + objs.push_back(o); + sc.selected_object = (int)objs.size() - 1; + } + ImGui::SameLine(); + ImGui::BeginDisabled(objs.empty()); + if (ImGui::Button("Delete") && !objs.empty()) + { + objs.erase(objs.begin() + sc.selected_object); if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1; + } + ImGui::EndDisabled(); - ImGui::Text("Spheres (%d)", (int)objs.size()); - if (ImGui::Button("Add") && objs.size() < 64) - { - SceneObject o; - o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f); - o.radius = 1.5f; - o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4)); - objs.push_back(o); - sc.selected_object = (int)objs.size() - 1; - } - ImGui::SameLine(); - if (ImGui::Button("Delete") && !objs.empty()) - { - objs.erase(objs.begin() + sc.selected_object); - if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1; - } + ImGui::SameLine(); + ImGui::TextDisabled("%d / 64", (int)objs.size()); + ImGui::Separator(); - ImGui::BeginChild("obj_list", ImVec2(0, 120), true); - for (int i = 0; i < (int)objs.size(); ++i) - { - std::string label = "Sphere " + std::to_string(i); - if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i; - } - ImGui::EndChild(); + ImGui::BeginChild("obj_list"); + for (int i = 0; i < (int)objs.size(); ++i) + { + std::string label = "Sphere " + std::to_string(i); + if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i; + } + if (objs.empty()) ImGui::TextDisabled("No spheres. Add one above."); + ImGui::EndChild(); + } + ImGui::End(); + } + + auto UILayer::panel_properties(const UIContext& ctx) -> void + { + if (ImGui::Begin(kProperties, &m_show_properties)) + { + Scene& sc = ctx.scene; + auto& objs = sc.objects; + if (objs.empty() || sc.selected_object < 0 || sc.selected_object >= (int)objs.size()) + { + ImGui::TextDisabled("Select a sphere in the Outliner."); + } + else + { + SceneObject& o = objs[sc.selected_object]; + ImGui::Text("Sphere %d", sc.selected_object); + ImGui::Separator(); + ImGui::DragFloat3("Position", &o.position.x, 0.1f); + ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f); + ImGui::ColorEdit3("Color", &o.color.x); + + ImGui::Spacing(); + if (ImGui::RadioButton("Move", m_gizmo_op == 0)) m_gizmo_op = 0; + ImGui::SameLine(); + if (ImGui::RadioButton("Scale", m_gizmo_op == 1)) m_gizmo_op = 1; - if (sc.selected_object >= 0 && sc.selected_object < (int)objs.size()) + if (m_mode == Mode::Scene) { - SceneObject& o = objs[sc.selected_object]; - ImGui::DragFloat3("Position", &o.position.x, 0.1f); - ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f); - ImGui::ColorEdit3("Color", &o.color.x); - - ImGui::Spacing(); - if (ImGui::RadioButton("Move", m_op == ImGuizmo::TRANSLATE)) m_op = ImGuizmo::TRANSLATE; - ImGui::SameLine(); - if (ImGui::RadioButton("Scale", m_op == ImGuizmo::SCALE)) m_op = ImGuizmo::SCALE; - ImGui::SameLine(); ImGui::TextDisabled("(drag the handles in the viewport)"); - - // Viewport gizmo for the selected sphere. Uses the scene camera - // (its projection was set for this frame by the Application) and - // draws into the background draw list so it sits over the render - // but under the panels. + // Viewport gizmo for the selected sphere, using the scene camera + // (its projection was set for this frame by the Application). Drawn + // into the background list so it sits over the render, under panels. ImGuizmo::SetOrthographic(false); ImGuizmo::SetDrawlist(ImGui::GetBackgroundDrawList()); ImGuiIO& io = ImGui::GetIO(); @@ -218,175 +325,223 @@ namespace Donut glm::mat4 proj = sc.scene_camera.get_projection_matrix(); glm::mat4 xform = glm::translate(glm::mat4(1.0f), o.position) * glm::scale(glm::mat4(1.0f), glm::vec3(o.radius)); - if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], m_op, ImGuizmo::WORLD, &xform[0][0])) + ImGuizmo::OPERATION op = (m_gizmo_op == 1) ? ImGuizmo::SCALE : ImGuizmo::TRANSLATE; + if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], op, ImGuizmo::WORLD, &xform[0][0])) { float t[3], r[3], s[3]; ImGuizmo::DecomposeMatrixToComponents(&xform[0][0], t, r, s); o.position = glm::vec3(t[0], t[1], t[2]); o.radius = std::max(0.05f, (s[0] + s[1] + s[2]) / 3.0f); } + ImGui::TextDisabled("Drag the handles in the viewport."); + } + else + { + ImGui::TextDisabled("Switch View to Scene to drag the gizmo."); } } - private: - ImGuizmo::OPERATION m_op = ImGuizmo::TRANSLATE; - }; + } + ImGui::End(); + } - // Simulation: camera + accretion disk + quality - class SimulationWorkspace : public Workspace + auto UILayer::panel_black_hole(const UIContext& ctx) -> void + { + if (ImGui::Begin(kBlackHole, &m_show_black_hole)) { - public: - auto name() const -> const char* override { return "Simulation"; } - auto view() const -> View override { return View::BlackHole; } - auto on_ui(const UIContext& ctx) -> void override - { - Scene& sc = ctx.scene; - BlackHoleParams& bh = sc.black_hole; + Scene& sc = ctx.scene; + BlackHoleParams& bh = sc.black_hole; - if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen)) - { - bool ff = sc.is_free_fly(); - if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false); - ImGui::SameLine(); - if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true); - ImGui::SameLine(); - if (ImGui::Button("Reset view")) sc.reset_sim_camera(); - ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0"); - ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look" - : "Drag to orbit | scroll to zoom"); - } + if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen)) + { + bool ff = sc.is_free_fly(); + if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false); + ImGui::SameLine(); + if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true); + ImGui::SameLine(); + if (ImGui::Button("Reset view")) sc.reset_sim_camera(); + ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0"); + ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look" + : "Drag to orbit | scroll to zoom"); + } - if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K"); - ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f"); - ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s"); - if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f; - ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s"); - ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f"); - ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile."); - } + if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K"); + ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f"); + ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s"); + if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f; + ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s"); + ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f"); + ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile."); + } - if (ImGui::CollapsingHeader("Quality")) - { - ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000); - ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled."); - } + if (ImGui::CollapsingHeader("Quality")) + { + ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000); + ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled."); } - }; + } + ImGui::End(); + } - // Export: pick what to render out (not wired up yet) - class ExportWorkspace : public Workspace + auto UILayer::panel_settings(const UIContext& ctx) -> void + { + if (ImGui::Begin(kSettings, &m_show_settings)) { - public: - auto name() const -> const char* override { return "Export"; } - auto view() const -> View override { return View::None; } - auto on_ui(const UIContext& ctx) -> void override + auto& s = SettingsManager::get_settings(); + + if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen)) { - ImGui::TextDisabled("Render the black hole + observables to disk for analysis."); - ImGui::Spacing(); - if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen)) + const bool fs = ctx.actions.is_fullscreen(); + int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch); + + struct Res { const char* label; int w, h; }; + static const Res kRes[] = { + { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 }, + { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 }, + }; + char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch); + ImGui::BeginDisabled(fs); + if (ImGui::BeginCombo("Resolution", cur_label)) { - ImGui::Checkbox("Colour (tone-mapped)", &m_color); - ImGui::Checkbox("Redshift (g)", &m_redshift); - ImGui::Checkbox("Emission temperature", &m_temperature); - ImGui::Checkbox("Impact parameter", &m_impact); - ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame (false-colour)."); - } - if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Combo("Resolution", &m_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0"); - ImGui::Combo("Format", &m_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0"); - if (m_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables."); - else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s)."); + for (const auto& r : kRes) + if (ImGui::Selectable(r.label, cw == r.w && ch == r.h)) + ctx.actions.set_resolution(r.w, r.h); + ImGui::EndCombo(); } + ImGui::EndDisabled(); - ImGui::Spacing(); - const bool any = m_color || m_redshift || m_temperature || m_impact; - ImGui::BeginDisabled(!any); - if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f))) + bool fs_v = fs; + if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v); + + if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled)) { - static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} }; - ExportConfig cfg; - cfg.color = m_color; cfg.redshift = m_redshift; - cfg.temperature = m_temperature; cfg.impact = m_impact; - cfg.width = dims[m_resolution][0]; cfg.height = dims[m_resolution][1]; - cfg.format = static_cast(m_format); - m_written = ctx.actions.export_frame(cfg); - m_reported = true; + ctx.actions.set_vsync(s.graphics.v_sync_enabled); + SettingsManager::save_settings(); } + + ImGui::BeginDisabled(s.graphics.v_sync_enabled); + if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS")) + SettingsManager::save_settings(); ImGui::EndDisabled(); + if (s.graphics.v_sync_enabled) + ImGui::TextDisabled("Frame cap applies only with V-Sync off."); + + if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx")) + ctx.actions.set_ui_scale(m_ui_scale); + } - if (m_reported) + if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen)) + { + const char* apis[] = { "OpenGL", "Vulkan" }; + int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0; + if (ImGui::Combo("Graphics API", &cur, apis, 2)) { - if (m_written > 0) - ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d PNG(s) to exports/", m_written); - else - ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)"); + s.graphics.render_api = apis[cur]; + SettingsManager::save_settings(); } + const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL"; + if (s.graphics.render_api != running) + ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running); + else + ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str()); } - private: - bool m_color = true, m_redshift = false, m_temperature = false, m_impact = false; - int m_resolution = 1, m_format = 0; - int m_written = 0; bool m_reported = false; - }; - } - UILayer::UILayer() - { - m_workspaces.push_back(create_scope()); - m_workspaces.push_back(create_scope()); - m_workspaces.push_back(create_scope()); - m_workspaces.push_back(create_scope()); - - // Open on Simulation so the black hole greets the user on launch. - for (int i = 0; i < (int)m_workspaces.size(); ++i) - if (std::string(m_workspaces[i]->name()) == "Simulation") { m_landing = i; m_active = i; } + if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen)) + { + bool dirty = false; + dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps); + dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics); + if (dirty) SettingsManager::save_settings(); + } + + if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen)) + { + auto& hdri = HDRIManager::get(); + std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path); + if (ImGui::BeginCombo("Starfield", preview.c_str())) + { + for (const auto& path : hdri.get_available_hdri()) + { + bool selected = (path == ctx.scene.hdri_path); + if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected)) + ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause)."); + } + } + ImGui::End(); } - auto UILayer::draw(const UIContext& ctx) -> View + auto UILayer::panel_export(const UIContext& ctx) -> void { - static bool styled = false; - if (!styled) { apply_donut_style(); styled = true; } - - ImGuizmo::BeginFrame(); // gizmos are drawn by the Scene workspace - - // Full-viewport dock space with a pass-through centre, so the panel docks to - // any edge while the render shows through the middle. - ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode); + if (ImGui::Begin(kExport, &m_show_export)) + { + ImGui::TextDisabled("Render the black hole + observables to disk."); + ImGui::Spacing(); + if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Checkbox("Colour (tone-mapped)", &m_exp_color); + ImGui::Checkbox("Redshift (g)", &m_exp_redshift); + ImGui::Checkbox("Emission temperature", &m_exp_temperature); + ImGui::Checkbox("Impact parameter", &m_exp_impact); + ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame."); + } + if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Combo("Resolution", &m_exp_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0"); + ImGui::Combo("Format", &m_exp_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0"); + if (m_exp_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables."); + else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s)."); + } - ImGui::SetNextWindowSize(ImVec2(360, 620), ImGuiCond_FirstUseEver); - ImGui::Begin("Donut"); + ImGui::Spacing(); + const bool any = m_exp_color || m_exp_redshift || m_exp_temperature || m_exp_impact; + ImGui::BeginDisabled(!any); + if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f))) + { + static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} }; + ExportConfig cfg; + cfg.color = m_exp_color; cfg.redshift = m_exp_redshift; + cfg.temperature = m_exp_temperature; cfg.impact = m_exp_impact; + cfg.width = dims[m_exp_resolution][0]; cfg.height = dims[m_exp_resolution][1]; + cfg.format = static_cast(m_exp_format); + m_exp_written = ctx.actions.export_frame(cfg); + m_exp_reported = true; + } + ImGui::EndDisabled(); - ImGuiIO& io = ImGui::GetIO(); - ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str()); - const bool show_fps = SettingsManager::get_show_fps(); - const bool show_ms = SettingsManager::get_show_performance_metrics(); - if (show_fps) ImGui::Text("%.0f FPS", io.Framerate); - if (show_ms) - { - if (show_fps) ImGui::SameLine(); - ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f); + if (m_exp_reported) + { + if (m_exp_written > 0) + ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d file(s) to exports/", m_exp_written); + else + ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)"); + } } - ImGui::Spacing(); + ImGui::End(); + } - if (ImGui::BeginTabBar("workspaces")) + auto UILayer::panel_stats(const UIContext& ctx) -> void + { + if (ImGui::Begin(kStats, &m_show_stats)) { - for (int i = 0; i < (int)m_workspaces.size(); ++i) + ImGuiIO& io = ImGui::GetIO(); + ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str()); + const bool show_fps = SettingsManager::get_show_fps(); + const bool show_ms = SettingsManager::get_show_performance_metrics(); + if (show_fps) ImGui::Text("%.0f FPS", io.Framerate); + if (show_ms) { - ImGuiTabItemFlags flags = (!m_landed && i == m_landing) ? ImGuiTabItemFlags_SetSelected : 0; - if (ImGui::BeginTabItem(m_workspaces[i]->name(), nullptr, flags)) - { - m_active = i; - ImGui::Spacing(); - m_workspaces[i]->on_ui(ctx); - ImGui::EndTabItem(); - } + if (show_fps) ImGui::SameLine(); + ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f); } - ImGui::EndTabBar(); - m_landed = true; + if (!show_fps && !show_ms) + ImGui::TextDisabled("Enable FPS / frame time in Settings > Overlay."); } ImGui::End(); - - return m_workspaces[m_active]->view(); } } diff --git a/src/ui/ui_layer.h b/src/ui/ui_layer.h index 67c9d8f..15b5ff8 100644 --- a/src/ui/ui_layer.h +++ b/src/ui/ui_layer.h @@ -1,27 +1,61 @@ #pragma once -#include "workspace.h" +#include "workspace.h" // AppActions, UIContext #include "rendering/view.h" -#include "core/memory.h" - -#include namespace Donut { - // The Dorico-style tab strip. Owns the workspaces (Setup / Scene / Simulation - // / Export); each frame it draws the dock space, the shared header, the tab - // bar, and delegates the active tab's panels. Returns the active workspace's - // View so the Application knows what the RenderPath should draw. + // The application's docking shell. Draws a main menu bar and a full-viewport + // dock space with a pass-through centre (the live 3D render shows through the + // middle), then a set of dockable tool panels. Ships a few default layouts + // built with the DockBuilder API and lets the user rearrange, hide, or restore + // panels like a normal desktop app. draw() returns the View the RenderPath + // should draw this frame. class UILayer { public: - UILayer(); + UILayer() = default; auto draw(const UIContext& ctx) -> View; private: - std::vector> m_workspaces; - int m_active = 0; - int m_landing = 0; // tab selected on the first frame - bool m_landed = false; + // Which 3D view fills the pass-through centre. + enum class Mode { Scene, Simulation }; + // A named default arrangement of the panels. + enum class Layout { Simulation, SceneEditing, Export }; + + auto draw_menu_bar(const UIContext& ctx) -> void; + auto build_layout(unsigned int dockspace_id) -> void; // DockBuilder tree + auto apply_layout(Layout layout) -> void; // preset: view + panels + + auto panel_outliner(const UIContext& ctx) -> void; + auto panel_properties(const UIContext& ctx) -> void; + auto panel_black_hole(const UIContext& ctx) -> void; + auto panel_settings(const UIContext& ctx) -> void; + auto panel_export(const UIContext& ctx) -> void; + auto panel_stats(const UIContext& ctx) -> void; + + Mode m_mode = Mode::Simulation; + Layout m_layout = Layout::Simulation; + bool m_rebuild_layout = false; // rebuild the dock tree next frame + bool m_first_frame = true; // seed the default layout once, if no saved one + + // Panel visibility (defaults match the Simulation layout the app opens in). + bool m_show_outliner = false; + bool m_show_properties = false; + bool m_show_black_hole = true; + bool m_show_settings = false; + bool m_show_export = false; + bool m_show_stats = true; + + // Properties-panel gizmo op (0 = translate, 1 = scale); cast in the .cpp. + int m_gizmo_op = 0; + + // Settings panel. + float m_ui_scale = 1.0f; + + // Export panel. + bool m_exp_color = true, m_exp_redshift = false, m_exp_temperature = false, m_exp_impact = false; + int m_exp_resolution = 1, m_exp_format = 0; + int m_exp_written = 0; bool m_exp_reported = false; }; } diff --git a/src/ui/workspace.h b/src/ui/workspace.h index d86614a..16dff2a 100644 --- a/src/ui/workspace.h +++ b/src/ui/workspace.h @@ -1,6 +1,5 @@ #pragma once -#include "rendering/view.h" #include "rendering/export_config.h" #include @@ -8,7 +7,7 @@ namespace Donut { class Scene; - // App-level actions a workspace can request (window/device changes it can't do + // App-level actions the UI can request (window/device changes it can't do // itself). The Application implements this — it owns the window and device. struct AppActions { @@ -19,29 +18,17 @@ namespace Donut virtual auto set_ui_scale(float scale) -> void = 0; virtual auto get_window_size(int& w, int& h) const -> void = 0; virtual auto is_fullscreen() const -> bool = 0; - // Renders the requested channels off-screen and writes PNGs; returns the - // number of files written (0 on failure). + // Renders the requested channels off-screen and writes files; returns the + // number written (0 on failure). virtual auto export_frame(const ExportConfig& cfg) -> int = 0; }; - // What a workspace gets each frame to build its panels. `scene` is mutable — - // workspaces edit the document through it; `actions` performs app-level changes. + // What the UI panels get each frame. `scene` is mutable — panels edit the + // document through it; `actions` performs app-level changes. struct UIContext { Scene& scene; std::string device_name; AppActions& actions; }; - - // A Dorico-style workspace: one tab that owns its side panels and declares - // which viewport View it shows. Switching tabs switches both. Concrete - // workspaces (Setup / Scene / Simulation / Export) live in ui_layer.cpp. - class Workspace - { - public: - virtual ~Workspace() = default; - virtual auto name() const -> const char* = 0; - virtual auto view() const -> View = 0; - virtual auto on_ui(const UIContext& ctx) -> void = 0; - }; } -- cgit v1.3