diff --git a/cmake/EditorUI.cmake b/cmake/EditorUI.cmake index d53698c..14cb75b 100644 --- a/cmake/EditorUI.cmake +++ b/cmake/EditorUI.cmake @@ -45,6 +45,11 @@ if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view) target_compile_definitions(faset_editor_gizmo_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") add_test(NAME editor_ui_gizmos COMMAND faset_editor_gizmo_ui_tests) set_tests_properties(editor_ui_gizmos PROPERTIES LABELS "gpu") + add_executable(faset_editor_context_menu_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_context_menu.cpp) + target_link_libraries(faset_editor_context_menu_ui_tests PRIVATE faset_editor_ui) + target_compile_definitions(faset_editor_context_menu_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_test(NAME editor_ui_context_menu COMMAND faset_editor_context_menu_ui_tests) + set_tests_properties(editor_ui_context_menu PROPERTIES LABELS "gpu") add_test(NAME editor_ui_authoring COMMAND faset_editor_ui_tests) set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu") add_executable(faset_editor_ui_latency ${PROJECT_SOURCE_DIR}/tests/editor_ui_latency.cpp) diff --git a/docs/design/README.md b/docs/design/README.md index dbc2b07..2ecceeb 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,4 +1,4 @@ -# P1 editor visual references +# Editor visual references These image prototypes were generated from the recorded native editor screenshot in `docs/validation/checkpoint5-linux-2026-09-18/editor-blender.png` before changing the @@ -15,3 +15,12 @@ Both were created with the built-in image generation tool using the cited screen as the edit reference. The accepted behavior and validation criteria are in the [P1 design](../superpowers/specs/2026-09-24-p1-iteration-design.md) and [`PLAN.md`](../../PLAN.md). + +The [editor context-menu reference](editor-context-menu-reference.png) explores +the hierarchy, asset-browser, and viewport menus in the same dark editor style. +It is a **visual reference**, not the authority for enabled actions, wording, +positions, or behavior. The [Editor workspace manual](../manual/editor/workspace.md#right-click-context-menus) +describes the intended interaction; source code and tests determine the implemented +behavior. +The [Linux implementation capture](../validation/context-menus-2026-09-24/scene-context.png) +shows the Scene-root menu rendered by the acceptance test. diff --git a/docs/design/editor-context-menu-reference.png b/docs/design/editor-context-menu-reference.png new file mode 100644 index 0000000..bf39984 Binary files /dev/null and b/docs/design/editor-context-menu-reference.png differ diff --git a/docs/manual/editor/workspace.md b/docs/manual/editor/workspace.md index 66b4c79..2ac331c 100644 --- a/docs/manual/editor/workspace.md +++ b/docs/manual/editor/workspace.md @@ -94,6 +94,42 @@ These shortcuts apply when a text or numeric editor is not consuming the key. The viewport camera is an editing camera; navigating it does not rewrite a scene camera component. +## Right-click context menus + +Right-click a Scene-tree row to act on that row. Right-click the scene root or +empty space in the tree to **Create object**, **Create cube**, **Create plane**, +or **Create sprite**. An ordinary local object offers **Frame selection**, +**Rename**, **Add child object**, **Duplicate**, and **Delete**. Duplicate and +Delete are single authoring actions; use **Undo** to reverse either one. +Add child creates an object parented to the +selected row. Right-clicking also selects the target, so an action applies to the +row under the pointer rather than a previously selected object. + +Template content keeps its ownership rules. An inherited object offers +**Frame selection**, **Open template source**, **Add local child**, and +**Hide in this scene**. Locally added objects have no corresponding source +object, so they omit **Open template source**. Open an inherited object's source +scene to rename or change its inherited structure; suppression hides it only in +the containing instance. A top-level instance group offers **Open template source**, +**Add local object**, and **Remove instance**. Nested instance groups direct +structural changes to their source. See [Scene templates](templates.md) for +restoring suppressed objects and the difference between source and local edits. + +In **Assets**, right-click a project file to select it and show actions appropriate +to its type: **Open script** or **Open scene**, **Import / Reimport** for a +supported source asset, and **Copy project path**. An imported-asset row offers +**Copy asset ID**, **Place in scene** when the cooked asset is available, and +**Reimport source** when it still has a source file. Import and Reimport start jobs; +check **Jobs** or **Console** for the result. +See [Assets and Blender](assets.md) for supported formats and reimport behavior. + +A short right-click in the viewport opens the menu for the object under the +pointer, or creation actions when the pointer is over empty space. Moving while +holding the right button past the drag threshold keeps the existing camera +gesture: orbit in 3D or pan in 2D, with no menu on release. Press **Escape** or +click outside a menu to close it. These menus edit the scene through the same +authoring commands as the toolbar and MCP; they do not affect the running Player. + ## Edit components and undo The Inspector uses the registered component schema to show numbers, vectors, diff --git a/docs/validation/context-menus-2026-09-24/README.md b/docs/validation/context-menus-2026-09-24/README.md new file mode 100644 index 0000000..1c658ae --- /dev/null +++ b/docs/validation/context-menus-2026-09-24/README.md @@ -0,0 +1,13 @@ +# Editor context menus — Linux, 2026-09-24 + +`scene-context.png` is a GPU capture of the implemented Editor at 1280 × 900 with +the Scene-root context menu open. It was produced by the headless +`editor_ui_context_menu` test using `FASET_CONTEXT_CAPTURE`. + +The test also exercises entity Duplicate/Delete with Undo, inherited-instance +parenting, contextual file actions, menu dismissal, and the viewport's click vs. +right-drag camera gesture. It checks that selecting an unavailable imported asset +cannot use toolbar actions on a previously selected file. See +`tests/editor_ui_context_menu.cpp` for the exact acceptance assertions. The +generated visual exploration lives separately in +`docs/design/editor-context-menu-reference.png`. diff --git a/docs/validation/context-menus-2026-09-24/scene-context.png b/docs/validation/context-menus-2026-09-24/scene-context.png new file mode 100644 index 0000000..7263f30 Binary files /dev/null and b/docs/validation/context-menus-2026-09-24/scene-context.png differ diff --git a/include/faset/ui/ui.hpp b/include/faset/ui/ui.hpp index fe0d615..4b60c40 100644 --- a/include/faset/ui/ui.hpp +++ b/include/faset/ui/ui.hpp @@ -128,6 +128,9 @@ struct Widget { Json drag_payload; std::string dock_area, dock_panel; std::function on_click, on_preview, on_commit, on_cancel; + // Called on right-button down for the nearest enabled hit widget or ancestor. + // Coordinates are drawable pixels, matching render::Event. + std::function on_context; std::function on_drop; std::vector> children; Widget* parent = nullptr; diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp index b07595e..9831f32 100644 --- a/src/editor/editor_ui.cpp +++ b/src/editor/editor_ui.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -166,7 +167,7 @@ struct EditorUI::Impl { std::string picks_key, resolved_stamp, last_document; std::set seen_view_diagnostics; Json instance_selection = Json::array(), template_conflicts = Json::array(); - std::string document, selected, source_file, asset_filter, + std::string document, selected, source_file, selected_asset, asset_filter, active_bottom = "assets", menu, command_name = "faset_documents", status = "Ready", gizmo_mode = "Move", expanded_job_log; Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(), @@ -195,6 +196,8 @@ struct EditorUI::Impl { last_y = 0; Vec3 target{}; int camera_drag = 0, gizmo_axis = -1; + float right_down_x = 0, right_down_y = 0; + bool right_dragged = false; float gizmo_down_x = 0, gizmo_down_y = 0; Json gizmo_original; std::uint64_t gizmo_revision = 0; @@ -382,9 +385,9 @@ struct EditorUI::Impl { b.on_click = [action = std::move(action)](Widget&) { action(); }; return b; } - void create_object(const std::string& type) { + void create_object(const std::string& type, const std::string& parent = {}) { const auto id = new_id(); - Json object = authoring::make_entity(session.authoring().schemas(), type); + Json object = authoring::make_entity(session.authoring().schemas(), type, parent); object["id"] = id; if (type == "Cube" || type == "Plane") object["components"].push_back({{"id", new_id()}, @@ -495,6 +498,10 @@ struct EditorUI::Impl { if (payload.value("kind", std::string()) == "entity") reparent(payload.at("id"), ""); }; + tree.on_context = [this](Widget&, float x, float y) { + select(""); + open_scene_context(x, y); + }; auto& inspector = *ui.find("inspector_panel"); inspector.add(Kind::Tab, "inspector-tab", "Inspector").selected = true; auto& body = inspector.add(Kind::Column, "properties"); @@ -510,6 +517,7 @@ struct EditorUI::Impl { tool_surface.layout.width = 352; tool_surface.layout.height = 36; tool_surface.layout.padding = 4; + tool_surface.on_context = [](Widget&, float, float) {}; auto& vptools = tool_surface.add(Kind::Row, "viewport-tools"); vptools.layout.height = 28; vptools.layout.gap = 4; @@ -582,6 +590,14 @@ struct EditorUI::Impl { items.layout.flex = 1; items.layout.scroll = true; items.layout.gap = 0; + items.on_context = [this](Widget&, float x, float y) { + show_context("Assets", x, y, + {{"context-refresh", "Refresh files", [this] { + assets_dirty = true; + view.clearCache(); + picks_key.clear(); + }}}); + }; auto& console = bottom.add(Kind::Column, "console-items"); console.layout.flex = 1; console.layout.scroll = true; @@ -839,6 +855,177 @@ struct EditorUI::Impl { 85); label(project, "project-settings-error", ""); label(project, "project-settings-reload-note", "Reload saved discards this form's edits."); + auto& context = ui.root().add(Kind::Panel, "context-popup"); + context.layout.absolute = true; + context.layout.width = 248; + context.layout.padding = 6; + context.layout.gap = 2; + context.visible = false; + context.on_context = [](Widget&, float, float) {}; + } + struct ContextAction { + std::string id; + std::string text; + std::function run; + }; + void close_context() { + if (ui.focused_id().starts_with("context-")) + ui.clear_focus(false); + ui.find("context-popup")->visible = false; + } + void show_context(const std::string& title, float x, float y, + std::vector actions) { + auto& popup = *ui.find("context-popup"); + popup.children.clear(); + if (actions.empty()) { + close_context(); + return; + } + auto& heading = popup.add(Kind::Label, "context-title", title); + heading.layout.height = 23; + heading.font_size = 12; + heading.enabled = false; + for (auto& action : actions) { + auto& row = button(popup, action.id, action.text, + [this, run = std::move(action.run)] { + close_context(); + run(); + }); + row.layout.height = 27; + row.appearance = ui::Appearance::Quiet; + } + popup.layout.height = std::min(12 + 23 + float(actions.size()) * 27 + + float(actions.size()) * popup.layout.gap, + std::max(0.f, logical_height() - 8)); + popup.layout.scroll = true; + popup.layout.x = std::clamp(x / ui_scale, 0.f, + std::max(0.f, logical_width() - popup.layout.width - 4)); + popup.layout.y = std::clamp(y / ui_scale, 0.f, + std::max(0.f, logical_height() - popup.layout.height - 4)); + popup.visible = true; + menu.clear(); + } + void open_scene_context(float x, float y) { + show_context("Scene", x, y, + {{"context-create-object", "Create object", [this] { create_object("Object"); }}, + {"context-create-cube", "Create cube", [this] { create_object("Cube"); }}, + {"context-create-plane", "Create plane", [this] { create_object("Plane"); }}, + {"context-create-sprite", "Create sprite", [this] { create_object("Sprite"); }}}); + } + void open_entity_context(const std::string& id, float x, float y) { + const auto* object = entity(resolved, id); + if (!object) + return; + select(id); + std::vector actions{ + {"context-frame", "Frame selection", [this] { frame_selection(); }}}; + if (inherited(*object)) { + const auto path = object->at("origin").at("path"); + const auto source_id = object->at("origin").at("object").get(); + if (!owned_addition(*object)) + actions.push_back({"context-open-source", "Open template source", + [this, path, source_id] { + open_template_source(path, source_id); + }}); + if (path.size() == 1) + actions.push_back({"context-add-child", "Add local child", + [this, path, source_id] { + add_instance_child(path, source_id); + }}); + actions.push_back({"context-delete", "Hide in this scene", + [this] { delete_selected(); }}); + } else { + actions.push_back({"context-rename", "Rename", [this] { + ui.focus("object-name"); + }}); + actions.push_back({"context-add-child", "Add child object", + [this, id] { create_object("Object", id); }}); + actions.push_back({"context-duplicate", "Duplicate", [this, id] { + transaction(Json::array( + {{{"op", "entity.duplicate"}, {"entity", id}}})); + }}); + actions.push_back({"context-delete", "Delete", [this] { delete_selected(); }}); + } + show_context("Object", x, y, std::move(actions)); + } + void open_instance_context(const Json& path, float x, float y) { + select_instance(path); + std::vector actions{ + {"context-open-source", "Open template source", + [this, path] { open_template_source(path); }}}; + if (path.size() == 1) { + actions.push_back({"context-add-child", "Add local object", + [this, path] { add_instance_child(path); }}); + actions.push_back({"context-delete", "Remove instance", + [this] { delete_selected(); }}); + } + show_context("Scene instance", x, y, std::move(actions)); + } + void open_file_context(const std::string& path, float x, float y) { + source_file = path; + selected_asset.clear(); + assets_dirty = true; + auto lower_path = path; + std::transform(lower_path.begin(), lower_path.end(), lower_path.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + const auto extension = path_to_utf8(path_from_utf8(lower_path).extension()); + const bool scene = lower_path.ends_with(".scene.json") || extension == ".fscene"; + const bool script = extension == ".lua"; + const bool importable = extension == ".gltf" || extension == ".glb" || + extension == ".png" || extension == ".jpg" || + extension == ".jpeg" || lower_path.ends_with("/manifest.json"); + std::vector actions; + if (script || scene) + actions.push_back({"context-open-file", script ? "Open script" : "Open scene", + [this, path] { + source_file = path; + open_source(); + }}); + if (importable) + actions.push_back({"context-import", "Import / Reimport", + [this, path] { call("faset_import", {{"path", path}}); }}); + actions.push_back({"context-copy-path", "Copy project path", + [this, path] { renderer.set_clipboard(path); }}); + show_context(path_to_utf8(path_from_utf8(path).filename()), x, y, std::move(actions)); + } + std::string asset_source(const Json& asset) const { + if (!asset.contains("manifest")) + return {}; + try { + const auto source = generic_path_to_utf8(std::filesystem::relative( + path_from_utf8(asset.at("manifest").at("source").get()), + session.config().project_root)); + if (source.empty() || + !std::filesystem::is_regular_file( + project_path(session.config().project_root, path_from_utf8(source)))) + return {}; + return source; + } catch (const std::exception&) { + return {}; + } + } + void open_asset_context(const Json& asset, float x, float y) { + const auto id = asset.at("id").get(); + selected_asset = id; + source_file = asset_source(asset); + assets_dirty = true; + std::vector actions{ + {"context-copy-id", "Copy asset ID", + [this, id] { renderer.set_clipboard(id); }}}; + if (asset.contains("manifest") && + asset.value("freshness", Json::object()).value("state", std::string("unavailable")) != + "unavailable") + actions.push_back({"context-instantiate", "Place in scene", + [this, id] { instantiate_asset(id); }}); + if (!source_file.empty()) + actions.push_back({"context-import", "Reimport source", + [this, source = source_file] { + call("faset_import", {{"path", source}}); + }}); + const auto state = asset.value("freshness", Json::object()) + .value("state", std::string("unavailable")); + show_context(state == "unavailable" ? "Unavailable asset" : "Imported asset", + x, y, std::move(actions)); } void new_scene(int dimension) { auto result = @@ -850,6 +1037,7 @@ struct EditorUI::Impl { void choose_document(std::string id) { if (document != id) last_document = document; + close_context(); instance_selection = Json::array(); ui.clear_focus(false); document = id; @@ -1341,6 +1529,10 @@ struct EditorUI::Impl { root.tooltip = full_scene_name; root.selected = selected.empty() && instance_selection.empty(); root.on_click = [this](Widget&) { select(""); }; + root.on_context = [this](Widget&, float x, float y) { + select(""); + open_scene_context(x, y); + }; touch(root); std::vector groups; for (const auto& instance : current.at("scene").value("instances", Json::array())) @@ -1382,6 +1574,9 @@ struct EditorUI::Impl { row.selected = selected == id; row.drag_payload = {{"kind", "entity"}, {"id", id}, {"label", object.at("name")}}; row.on_click = [this, id](Widget&) { select(id); }; + row.on_context = [this, id](Widget&, float x, float y) { + open_entity_context(id, x, y); + }; row.on_drop = [this, id](Widget&, const Json& payload) { if (payload.value("kind", std::string()) == "entity") reparent(payload.at("id"), id); @@ -1398,6 +1593,9 @@ struct EditorUI::Impl { row.indent = depth; row.selected = instance_selection == path; row.on_click = [this, path](Widget&) { select_instance(path); }; + row.on_context = [this, path](Widget&, float x, float y) { + open_instance_context(path, x, y); + }; touch(row); objects(path, "", depth + 1); for (const auto& candidate : groups) { @@ -1826,11 +2024,20 @@ struct EditorUI::Impl { assets = result.at("assets"); } auto& list = *ui.find("asset-items"); - const bool script = path_from_utf8(source_file).extension() == ".lua"; + auto lower_source = source_file; + std::transform(lower_source.begin(), lower_source.end(), lower_source.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + const auto extension = path_to_utf8(path_from_utf8(lower_source).extension()); + const bool script = extension == ".lua"; + const bool scene = lower_source.ends_with(".scene.json") || extension == ".fscene"; + const bool importable = extension == ".gltf" || extension == ".glb" || + extension == ".png" || extension == ".jpg" || + extension == ".jpeg" || lower_source.ends_with("/manifest.json"); ui.find("asset-open")->text = script ? "Open Script" : "Open Scene"; ui.find("asset-open")->tooltip = script ? "Open Lua source in your external editor" : "Open a project scene"; - ui.find("asset-import")->enabled = !script; + ui.find("asset-open")->enabled = script || scene; + ui.find("asset-import")->enabled = importable; std::set keep; for (const auto& file : files) { const auto path = file.get(); @@ -1849,11 +2056,15 @@ struct EditorUI::Impl { row.layout.height = 25; row.indent = 1; row.tooltip = path; - row.selected = source_file == path; + row.selected = selected_asset.empty() && source_file == path; row.on_click = [this, path](Widget&) { source_file = path; + selected_asset.clear(); assets_dirty = true; }; + row.on_context = [this, path](Widget&, float x, float y) { + open_file_context(path, x, y); + }; keep.insert(row.id); } for (const auto& asset : assets) { @@ -1871,19 +2082,22 @@ struct EditorUI::Impl { list.add(Kind::TreeRow, "asset-" + id, std::string(prefix) + " / " + name); row.layout.height = 25; row.indent = 1; + row.selected = selected_asset == id; row.tooltip = "Asset " + id + " — " + state; row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}}; + row.on_context = [this, asset](Widget&, float x, float y) { + open_asset_context(asset, x, y); + }; row.on_click = [this, id, asset, freshness, state](Widget&) { + selected_asset = id; + source_file = asset_source(asset); + assets_dirty = true; renderer.set_clipboard(id); - if (asset.contains("manifest")) { - source_file = generic_path_to_utf8(std::filesystem::relative( - path_from_utf8(asset.at("manifest").at("source").get()), - session.config().project_root)); - assets_dirty = true; - } status = state == "current" ? "Asset ID copied; drag to viewport or an asset field" - : "Reimport required; select Import to refresh the selected source"; + : source_file.empty() + ? "Asset source unavailable; restore it, then Refresh" + : "Reimport required; select Import to refresh the selected source"; for (const auto& reason : freshness.value("reasons", Json::array())) report(reason.value("message", "Input changed") + ": " + reason.value("path", "")); @@ -2238,6 +2452,8 @@ struct EditorUI::Impl { refresh_project_settings(); const bool modal = palette || !recovery.empty() || simulation_open || project_switch_warning || project_settings_open; + if (modal) + close_context(); for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"}) ui.find(id)->enabled = !modal; const bool file = menu == "File" || menu == "Faset", @@ -2739,6 +2955,8 @@ struct EditorUI::Impl { using Type = render::Event::Type; if (event.type == Type::FocusLost) { camera_drag = 0; + right_dragged = false; + close_context(); gizmo_axis = -1; preview_fields.clear(); return false; @@ -2754,6 +2972,12 @@ struct EditorUI::Impl { return true; } if (camera_drag) { + if (camera_drag == 3 && !right_dragged) { + right_dragged = std::hypot(event.x - right_down_x, + event.y - right_down_y) >= 5 * ui_scale; + if (!right_dragged) + return true; + } const bool is2d = current.at("scene").value("dimension", 3) == 2; if (camera_drag == 3 && !is2d) { yaw -= dx / ui_scale * .008f; @@ -2774,6 +2998,11 @@ struct EditorUI::Impl { last_y = event.y; if (event.button == 2 || event.button == 3) { camera_drag = event.button; + if (event.button == 3) { + right_down_x = event.x; + right_down_y = event.y; + right_dragged = false; + } return true; } if (event.button == 1) { @@ -2785,6 +3014,15 @@ struct EditorUI::Impl { if (event.type == Type::MouseUp) { if (event.button == camera_drag) { camera_drag = 0; + if (event.button == 3 && !right_dragged && + viewport.contains(event.x, event.y)) { + pick(event.x, event.y); + if (selected.empty()) + open_scene_context(event.x, event.y); + else + open_entity_context(selected, event.x, event.y); + } + right_dragged = false; return true; } if (event.button == 1 && gizmo_axis >= 0) { @@ -2813,6 +3051,10 @@ struct EditorUI::Impl { using Type = render::Event::Type; for (const auto& event : input) { if (event.type == Type::KeyDown && event.key == "Escape") { + if (ui.find("context-popup")->visible) { + close_context(); + continue; + } if (project_settings_open) { project_settings_open = false; ui.clear_focus(false); @@ -2843,6 +3085,9 @@ struct EditorUI::Impl { if (event.type == Type::MouseDown && !menu.empty() && !ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36 * ui_scale) menu.clear(); + if (event.type == Type::MouseDown && ui.find("context-popup")->visible && + !ui.find("context-popup")->rect.contains(event.x, event.y)) + close_context(); if (event.type == Type::MouseMove || event.type == Type::MouseDown) { mouse_x = event.x; mouse_y = event.y; @@ -2904,8 +3149,11 @@ struct EditorUI::Impl { const auto next_scale = std::clamp(renderer.display_scale(), .5f, 4.f); const bool geometry_changed = next_scale != ui_scale || layout_width != renderer.width() || layout_height != renderer.height(); + if (geometry_changed) + close_context(); if (next_scale != ui_scale) { camera_drag = 0; + right_dragged = false; if (gizmo_axis >= 0) { const auto field = gizmo_mode == "Move" ? "position" : gizmo_mode == "Rotate" ? "rotation" diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp index 854c03c..331e9ee 100644 --- a/src/ui/ui.cpp +++ b/src/ui/ui.cpp @@ -989,6 +989,20 @@ bool Context::handle(const render::Event& event) { p.hovered = hit ? hit->id : ""; p.hover_started = std::chrono::steady_clock::now(); } + if (event.type == Type::MouseDown && event.button == 3) { + auto* target = p.hit(p.root, event.x, event.y); + if (!target) + return false; + if (!target->enabled || !p.captured.empty()) + return true; + for (auto* widget = target; widget; widget = widget->parent) + if (widget->on_context) { + auto callback = widget->on_context; + callback(*widget, event.x, event.y); + return true; + } + return false; + } if (event.type == Type::Wheel) { auto* w = p.hit(p.root, p.mouse_x, p.mouse_y); for (; w; w = w->parent) diff --git a/tests/editor_ui_context_menu.cpp b/tests/editor_ui_context_menu.cpp new file mode 100644 index 0000000..ebc6479 --- /dev/null +++ b/tests/editor_ui_context_menu.cpp @@ -0,0 +1,331 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace faset; + +namespace { +void check(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} + +render::Event mouse(render::Event::Type type, float x, float y, int button) { + render::Event event; + event.type = type; + event.x = x; + event.y = y; + event.button = button; + return event; +} + +render::Event key(std::string name) { + render::Event event; + event.type = render::Event::Type::KeyDown; + event.key = std::move(name); + return event; +} + +ui::Rect visible_rect(editor::EditorUI& editor, const std::string& id) { + const auto* widget = editor.widgets().find(id); + check(widget && widget->visible, "Missing visible widget: " + id); + const auto visible = widget->rect.intersection(widget->clip); + check(visible.width > 2 && visible.height > 2, "Clipped widget: " + id); + return visible; +} + +void click(editor::EditorUI& editor, const std::string& id) { + const auto rect = visible_rect(editor, id); + const auto x = rect.x + rect.width * .5f; + const auto y = rect.y + rect.height * .5f; + editor.frame({mouse(render::Event::Type::MouseDown, x, y, 1), + mouse(render::Event::Type::MouseUp, x, y, 1)}); +} + +void right_click(editor::EditorUI& editor, float x, float y) { + editor.frame({mouse(render::Event::Type::MouseDown, x, y, 3), + mouse(render::Event::Type::MouseUp, x, y, 3)}); +} + +void right_click(editor::EditorUI& editor, const std::string& id) { + const auto rect = visible_rect(editor, id); + right_click(editor, rect.x + rect.width * .5f, rect.y + rect.height * .5f); +} + +void escape(editor::EditorUI& editor) { + render::Event event; + event.type = render::Event::Type::KeyDown; + event.key = "Escape"; + editor.frame({event}); +} + +bool popup_open(editor::EditorUI& editor) { + const auto* popup = editor.widgets().find("context-popup"); + return popup && popup->visible; +} + +bool action_available(editor::EditorUI& editor, const std::string& id) { + const auto* action = editor.widgets().find(id); + return action && action->visible && action->enabled; +} + +void object_actions(editor::EditorUI& editor, editor::Session& session) { + click(editor, "add-cube"); + auto state = session.authoring().query(editor.current_document()); + check(state["scene"]["entities"].size() == 1, "Fixture cube must exist"); + const auto original = state["scene"]["entities"][0]["id"].get(); + click(editor, "scene-root"); + check(editor.selected_entity().empty(), "Fixture scene root deselects cube"); + + const auto entity_rect = visible_rect(editor, "entity-" + original); + const float entity_x = entity_rect.x + entity_rect.width * .5f; + const float entity_y = entity_rect.y + entity_rect.height * .5f; + editor.frame({mouse(render::Event::Type::MouseDown, entity_x, entity_y, 3)}); + check(editor.selected_entity() == original, "Right MouseDown selects target entity"); + check(popup_open(editor), "Right MouseDown on entity opens context popup"); + editor.frame({mouse(render::Event::Type::MouseUp, entity_x, entity_y, 3)}); + check(action_available(editor, "context-duplicate") && + action_available(editor, "context-delete"), + "Entity menu offers Duplicate and Delete"); + const auto before_duplicate = state.at("revision").get(); + click(editor, "context-duplicate"); + state = session.authoring().query(editor.current_document()); + check(state["scene"]["entities"].size() == 2 && + state.at("revision") == before_duplicate + 1, + "Duplicate authors exactly one new object in one transaction"); + check(!popup_open(editor), "Choosing an action closes context popup"); + click(editor, "undo"); + state = session.authoring().query(editor.current_document()); + check(state["scene"]["entities"].size() == 1 && + state["scene"]["entities"][0]["id"] == original, + "One Undo reverses context Duplicate"); + + right_click(editor, "entity-" + original); + click(editor, "context-delete"); + check(session.authoring().query(editor.current_document())["scene"]["entities"].empty(), + "Context Delete removes the selected object"); + click(editor, "undo"); + check(session.authoring().query(editor.current_document())["scene"]["entities"].size() == + 1, + "One Undo reverses context Delete"); + + right_click(editor, "entity-" + original); + escape(editor); + check(!popup_open(editor), "Escape closes context popup"); + right_click(editor, "entity-" + original); + const auto outside = visible_rect(editor, "statusbar"); + const float x = outside.x + outside.width * .5f; + const float y = outside.y + outside.height * .5f; + editor.frame({mouse(render::Event::Type::MouseDown, x, y, 1), + mouse(render::Event::Type::MouseUp, x, y, 1)}); + check(!popup_open(editor), "Clicking outside closes context popup"); + + right_click(editor, "scene-root"); + check(popup_open(editor) && action_available(editor, "context-create-cube"), + "Scene root offers creation actions"); + click(editor, "context-create-cube"); + state = session.authoring().query(editor.current_document()); + check(state["scene"]["entities"].size() == 2, + "Scene root context action creates a cube"); + const auto created_revision = state.at("revision").get(); + editor.frame({key("Return"), key("Space")}); + state = session.authoring().query(editor.current_document()); + check(!popup_open(editor) && state.at("revision") == created_revision && + state["scene"]["entities"].size() == 2, + "Return and Space cannot reactivate a hidden context action"); + click(editor, "undo"); + check(session.authoring().query(editor.current_document())["scene"]["entities"].size() == + 1, + "Scene root creation is undoable"); + + const auto tree = visible_rect(editor, "scene-tree"); + const auto row = visible_rect(editor, "entity-" + original); + const float blank_y = tree.y + tree.height - 18; + check(blank_y > row.y + row.height + 4, "Fixture scene tree needs blank space"); + right_click(editor, tree.x + tree.width * .5f, blank_y); + check(popup_open(editor) && action_available(editor, "context-create-cube"), + "Blank scene tree offers creation actions"); + escape(editor); +} + +void inherited_child_actions(editor::EditorUI& editor, editor::Session& session, + const std::filesystem::path& root) { + const auto source = session.authoring().query(editor.current_document()); + check(source["scene"]["entities"].size() == 1, + "Template fixture starts with one local source object"); + const auto source_object = source["scene"]["entities"][0]["id"].get(); + click(editor, "entity-" + source_object); + click(editor, "menu-Scene"); + click(editor, "save-template"); + check(std::filesystem::exists(root / "Assets/Templates/Template.scene.json"), + "Template fixture saves the selected object through the Scene menu"); + click(editor, "menu-File"); + click(editor, "new-3d"); + click(editor, "menu-Scene"); + click(editor, "instance-template"); + const auto document = editor.current_document(); + auto authored = session.authoring().query(document); + check(authored["scene"]["instances"].size() == 1, + "Template fixture instances the source into a new scene"); + const auto before = session.commands().resolved_scene(document); + std::string inherited_id, inherited_source_id; + for (const auto& object : before.at("scene").at("entities")) { + const auto& origin = object.at("origin"); + if (origin.at("path").size() == 1 && !origin.value("local", false)) { + inherited_id = object.at("id").get(); + inherited_source_id = origin.at("object").get(); + } + } + check(!inherited_id.empty() && inherited_source_id == source_object && + inherited_id != inherited_source_id, + "Template fixture has an inherited entity with a scoped resolved ID"); + + right_click(editor, "entity-" + inherited_id); + check(action_available(editor, "context-add-child"), + "Inherited object menu offers Add local child"); + click(editor, "context-add-child"); + authored = session.authoring().query(document); + const auto& additions = authored["scene"]["instances"][0]["additions"]; + check(additions.size() == 1 && additions[0]["parent"] == inherited_source_id, + "Local child stores its inherited parent's source ID"); + + const auto after = session.commands().resolved_scene(document); + for (const auto& conflict : after.at("conflicts")) + check(conflict.at("code") != "addition.parent_missing", + "Local child must not produce addition.parent_missing"); + const auto addition_source_id = additions[0]["id"].get(); + bool resolved_child = false; + for (const auto& object : after.at("scene").at("entities")) { + const auto& origin = object.at("origin"); + if (origin.value("local", false) && origin.at("path").size() == 1 && + origin.at("object") == addition_source_id) { + resolved_child = object.at("parent") == inherited_id; + break; + } + } + check(resolved_child, "Local child resolves under the clicked inherited entity"); +} + +void file_actions(editor::EditorUI& editor) { + click(editor, "tab-assets"); + click(editor, "asset-refresh"); + right_click(editor, "file-Scripts/example.lua"); + check(popup_open(editor) && action_available(editor, "context-open-file"), + "Lua file context menu offers Open"); + check(editor.widgets().find("file-Scripts/example.lua")->selected, + "Right-click selects Lua file"); + escape(editor); + right_click(editor, "file-Assets/model.gltf"); + check(popup_open(editor) && action_available(editor, "context-import"), + "Importable source context menu offers Import"); + check(editor.widgets().find("file-Assets/model.gltf")->selected, + "Right-click selects importable source"); + escape(editor); +} + +void unavailable_asset_actions(editor::EditorUI& editor, editor::Session& session, + const std::filesystem::path& root) { + atomic_write_json(root / "Assets/unavailable.gltf", + {{"asset", {{"version", "2.0"}}}, + {"scene", 0}, + {"scenes", Json::array({{{"nodes", {0}}}})}, + {"nodes", Json::array({{{"name", "Imported fixture"}}})}}); + const auto job = session.commands() + .call("faset_import", {{"path", "Assets/unavailable.gltf"}}) + .at("job") + .get(); + Json imported; + for (int attempt = 0; attempt < 500; ++attempt) { + session.poll(); + imported = session.commands().call("faset_job", {{"id", job}}); + if (imported.at("state") != "queued" && imported.at("state") != "running") + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + check(imported.at("state") == "succeeded", "Unavailable asset fixture first imports"); + const auto asset_id = imported.at("result").at("asset_id").get(); + check(std::filesystem::remove(root / ".faset/cache/assets" / asset_id / "current.json"), + "Unavailable asset fixture removes the active manifest pointer"); + click(editor, "asset-refresh"); + editor.frame({}); + const auto* unavailable = editor.widgets().find("asset-" + asset_id); + check(unavailable && unavailable->text.starts_with("Unavailable"), + "Asset browser retains an unavailable imported asset row"); + + right_click(editor, "file-Assets/model.gltf"); + escape(editor); + check(editor.widgets().find("file-Assets/model.gltf")->selected && + editor.widgets().find("asset-import")->enabled, + "Previous importable file is selected before switching to unavailable asset"); + const auto previous_jobs = + session.commands().call("faset_jobs", Json::object()).at("jobs").size(); + const auto document = editor.current_document(); + right_click(editor, "asset-" + asset_id); + check(popup_open(editor) && action_available(editor, "context-copy-id") && + !action_available(editor, "context-import"), + "Unavailable asset offers only safe context actions"); + check(editor.widgets().find("asset-" + asset_id)->selected && + !editor.widgets().find("file-Assets/model.gltf")->selected && + !editor.widgets().find("asset-import")->enabled && + !editor.widgets().find("asset-open")->enabled, + "Right-click selects unavailable asset and clears the prior file toolbar actions"); + escape(editor); + click(editor, "asset-import"); + click(editor, "asset-open"); + check(session.commands().call("faset_jobs", Json::object()).at("jobs").size() == + previous_jobs && + editor.current_document() == document, + "Disabled toolbar cannot act on the previously selected source file"); +} + +void viewport_actions(editor::EditorUI& editor) { + const auto rect = editor.snapshot().scene_rect; + const float x = rect[0] + rect[2] * .72f; + const float y = rect[1] + rect[3] * .72f; + right_click(editor, x, y); + check(popup_open(editor), "Short viewport right-click opens context popup"); + escape(editor); + const auto camera_before = editor.snapshot().view_projection; + editor.frame({mouse(render::Event::Type::MouseDown, x, y, 3)}); + editor.frame({mouse(render::Event::Type::MouseMove, x + 48, y + 24, 0)}); + editor.frame({mouse(render::Event::Type::MouseUp, x + 48, y + 24, 3)}); + check(!popup_open(editor), "Right drag does not open context popup"); + check(editor.snapshot().view_projection != camera_before, + "Right drag continues to orbit the viewport camera"); +} +} // namespace + +int main() { + const auto root = std::filesystem::temp_directory_path() / ("faset-context-ui-" + new_id()); + try { + atomic_write(root / "Scripts/example.lua", "function Update(dt) end\n"); + atomic_write_json(root / "Assets/model.gltf", {{"asset", {{"version", "2.0"}}}}); + editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root}); + render::Renderer renderer({1280, 900, "Context menu acceptance", true, true}); + editor::EditorUI editor(session, renderer, + path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + editor.frame({}); + object_actions(editor, session); + inherited_child_actions(editor, session, root); + file_actions(editor); + unavailable_asset_actions(editor, session, root); + viewport_actions(editor); + if (std::getenv("FASET_CONTEXT_CAPTURE")) + right_click(editor, "scene-root"); + renderer.render(editor.snapshot()); + if (const auto* capture = std::getenv("FASET_CONTEXT_CAPTURE")) + renderer.capture(path_from_utf8(capture)); + check(renderer.stats().validation_errors == 0, "Vulkan validation errors"); + std::cout << "Scene, asset and viewport right-click workflows passed\n"; + std::filesystem::remove_all(root); + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n'; + return 1; + } +} diff --git a/tests/ui_tests.cpp b/tests/ui_tests.cpp index e7913b0..2da847d 100644 --- a/tests/ui_tests.cpp +++ b/tests/ui_tests.cpp @@ -215,11 +215,71 @@ void keyboard_scroll_contract() { outer.scroll_y == old_outer && inner.scroll_y == old_inner, "Rejected numeric commit retains focus and scroll instead of hiding the error"); } +void right_click_context_contract() { + ui::Context context(path_from_utf8(FASET_TEST_FONT)); + auto& surface = context.root().add(ui::Kind::Column, "context-surface"); + surface.layout.width = 180; + surface.layout.height = 96; + surface.layout.gap = 0; + auto& direct = surface.add(ui::Kind::Button, "context-direct", "Direct"); + direct.layout.height = 24; + auto& nested = surface.add(ui::Kind::Label, "context-nested", "Nested"); + nested.layout.height = 24; + auto& disabled = surface.add(ui::Kind::Button, "context-disabled", "Disabled"); + disabled.layout.height = 24; + disabled.enabled = false; + auto& clipped = surface.add(ui::Kind::Button, "context-clipped", "Clipped"); + clipped.layout.absolute = true; + clipped.layout.y = 120; + clipped.layout.height = 24; + + int direct_context = 0, parent_context = 0, disabled_context = 0, clipped_context = 0; + int ordinary_clicks = 0; + float delivered_x = -1, delivered_y = -1; + surface.on_context = [&](ui::Widget& target, float, float) { + check(&target == &surface, "Context action must identify the ancestor that handles it"); + ++parent_context; + }; + direct.on_context = [&](ui::Widget& target, float x, float y) { + check(&target == &direct, "Context action must identify the nearest handler"); + ++direct_context; + delivered_x = x; + delivered_y = y; + }; + direct.on_click = [&](ui::Widget&) { ++ordinary_clicks; }; + disabled.on_context = [&](ui::Widget&, float, float) { ++disabled_context; }; + disabled.on_click = [&](ui::Widget&) { ++ordinary_clicks; }; + clipped.on_context = [&](ui::Widget&, float, float) { ++clipped_context; }; + context.layout(240, 180); + + auto right_down = [&](const ui::Widget& widget) { + auto event = mouse(render::Event::Type::MouseDown, widget.rect.x + 7, + widget.rect.y + 9); + event.button = 3; + return context.handle(event); + }; + check(right_down(direct) && direct_context == 1 && parent_context == 0 && + delivered_x == direct.rect.x + 7 && delivered_y == direct.rect.y + 9, + "Right click dispatches nearest handler with drawable coordinates"); + context.handle(mouse(render::Event::Type::MouseUp, direct.rect.x + 7, direct.rect.y + 9)); + check(ordinary_clicks == 0, "Right click must not start a left-button capture or click"); + + check(right_down(nested) && parent_context == 1 && direct_context == 1, + "Right click bubbles to the nearest ancestor with a context handler"); + right_down(disabled); + check(disabled_context == 0 && parent_context == 1 && ordinary_clicks == 0, + "Disabled widgets must not dispatch context actions"); + check(clipped.clip.height == 0, "Clipped context fixture must be outside its parent"); + right_down(clipped); + check(clipped_context == 0 && parent_context == 1, + "Clipped widgets must not receive context actions"); +} } // namespace int main() { try { display_scale_contract(); keyboard_scroll_contract(); + right_click_context_contract(); ui::TextBuffer buffer("Привет"); check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint"); check(buffer.undo() && buffer.text() == "Привет", "text undo");