Add editor right-click context actions
Native and manual checks / native (windows-2025) (push) Waiting to run
Windows editor and software Vulkan / windows-graphics (push) Waiting to run
Native and manual checks / native (ubuntu-24.04) (push) Failing after 1m54s
Native and manual checks / manual (push) Successful in 32s

This commit is contained in:
Emil
2026-09-24 15:44:25 +03:00
parent 3eea1fe9f1
commit f07eb4091e
11 changed files with 733 additions and 14 deletions
+5
View File
@@ -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)
+10 -1
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+36
View File
@@ -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,
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+3
View File
@@ -128,6 +128,9 @@ struct Widget {
Json drag_payload;
std::string dock_area, dock_panel;
std::function<void(Widget&)> 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<void(Widget&, float, float)> on_context;
std::function<void(Widget&, const Json&)> on_drop;
std::vector<std::unique_ptr<Widget>> children;
Widget* parent = nullptr;
+261 -13
View File
@@ -1,4 +1,5 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <faset/core/io.hpp>
@@ -166,7 +167,7 @@ struct EditorUI::Impl {
std::string picks_key, resolved_stamp, last_document;
std::set<std::string> 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<void()> 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<ContextAction> 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<ContextAction> 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<std::string>();
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<ContextAction> 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<char>(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<ContextAction> 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<std::string>()),
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<std::string>();
selected_asset = id;
source_file = asset_source(asset);
assets_dirty = true;
std::vector<ContextAction> 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<Json> 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<char>(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<std::string> keep;
for (const auto& file : files) {
const auto path = file.get<std::string>();
@@ -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<std::string>()),
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"
+14
View File
@@ -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)
+331
View File
@@ -0,0 +1,331 @@
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <thread>
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<std::string>();
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<std::uint64_t>();
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<std::uint64_t>();
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<std::string>();
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<std::string>();
inherited_source_id = origin.at("object").get<std::string>();
}
}
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<std::string>();
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<std::string>();
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<std::string>();
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;
}
}
+60
View File
@@ -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");