Add editor right-click context actions
Native and manual checks / native (ubuntu-24.04) (push) Failing after 1m54s
Native and manual checks / manual (push) Successful in 32s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (ubuntu-24.04) (push) Failing after 1m54s
Native and manual checks / manual (push) Successful in 32s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
This commit is contained in:
@@ -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}")
|
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)
|
add_test(NAME editor_ui_gizmos COMMAND faset_editor_gizmo_ui_tests)
|
||||||
set_tests_properties(editor_ui_gizmos PROPERTIES LABELS "gpu")
|
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)
|
add_test(NAME editor_ui_authoring COMMAND faset_editor_ui_tests)
|
||||||
set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu")
|
set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu")
|
||||||
add_executable(faset_editor_ui_latency ${PROJECT_SOURCE_DIR}/tests/editor_ui_latency.cpp)
|
add_executable(faset_editor_ui_latency ${PROJECT_SOURCE_DIR}/tests/editor_ui_latency.cpp)
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,4 @@
|
|||||||
# P1 editor visual references
|
# Editor visual references
|
||||||
|
|
||||||
These image prototypes were generated from the recorded native editor screenshot in
|
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
|
`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
|
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
|
[P1 design](../superpowers/specs/2026-09-24-p1-iteration-design.md) and
|
||||||
[`PLAN.md`](../../PLAN.md).
|
[`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 |
@@ -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
|
The viewport camera is an editing camera; navigating it does not rewrite a scene
|
||||||
camera component.
|
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
|
## Edit components and undo
|
||||||
|
|
||||||
The Inspector uses the registered component schema to show numbers, vectors,
|
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 |
@@ -128,6 +128,9 @@ struct Widget {
|
|||||||
Json drag_payload;
|
Json drag_payload;
|
||||||
std::string dock_area, dock_panel;
|
std::string dock_area, dock_panel;
|
||||||
std::function<void(Widget&)> on_click, on_preview, on_commit, on_cancel;
|
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::function<void(Widget&, const Json&)> on_drop;
|
||||||
std::vector<std::unique_ptr<Widget>> children;
|
std::vector<std::unique_ptr<Widget>> children;
|
||||||
Widget* parent = nullptr;
|
Widget* parent = nullptr;
|
||||||
|
|||||||
+261
-13
@@ -1,4 +1,5 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <faset/core/io.hpp>
|
#include <faset/core/io.hpp>
|
||||||
@@ -166,7 +167,7 @@ struct EditorUI::Impl {
|
|||||||
std::string picks_key, resolved_stamp, last_document;
|
std::string picks_key, resolved_stamp, last_document;
|
||||||
std::set<std::string> seen_view_diagnostics;
|
std::set<std::string> seen_view_diagnostics;
|
||||||
Json instance_selection = Json::array(), template_conflicts = Json::array();
|
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",
|
active_bottom = "assets", menu, command_name = "faset_documents", status = "Ready",
|
||||||
gizmo_mode = "Move", expanded_job_log;
|
gizmo_mode = "Move", expanded_job_log;
|
||||||
Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(),
|
Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(),
|
||||||
@@ -195,6 +196,8 @@ struct EditorUI::Impl {
|
|||||||
last_y = 0;
|
last_y = 0;
|
||||||
Vec3 target{};
|
Vec3 target{};
|
||||||
int camera_drag = 0, gizmo_axis = -1;
|
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;
|
float gizmo_down_x = 0, gizmo_down_y = 0;
|
||||||
Json gizmo_original;
|
Json gizmo_original;
|
||||||
std::uint64_t gizmo_revision = 0;
|
std::uint64_t gizmo_revision = 0;
|
||||||
@@ -382,9 +385,9 @@ struct EditorUI::Impl {
|
|||||||
b.on_click = [action = std::move(action)](Widget&) { action(); };
|
b.on_click = [action = std::move(action)](Widget&) { action(); };
|
||||||
return b;
|
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();
|
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;
|
object["id"] = id;
|
||||||
if (type == "Cube" || type == "Plane")
|
if (type == "Cube" || type == "Plane")
|
||||||
object["components"].push_back({{"id", new_id()},
|
object["components"].push_back({{"id", new_id()},
|
||||||
@@ -495,6 +498,10 @@ struct EditorUI::Impl {
|
|||||||
if (payload.value("kind", std::string()) == "entity")
|
if (payload.value("kind", std::string()) == "entity")
|
||||||
reparent(payload.at("id"), "");
|
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");
|
auto& inspector = *ui.find("inspector_panel");
|
||||||
inspector.add(Kind::Tab, "inspector-tab", "Inspector").selected = true;
|
inspector.add(Kind::Tab, "inspector-tab", "Inspector").selected = true;
|
||||||
auto& body = inspector.add(Kind::Column, "properties");
|
auto& body = inspector.add(Kind::Column, "properties");
|
||||||
@@ -510,6 +517,7 @@ struct EditorUI::Impl {
|
|||||||
tool_surface.layout.width = 352;
|
tool_surface.layout.width = 352;
|
||||||
tool_surface.layout.height = 36;
|
tool_surface.layout.height = 36;
|
||||||
tool_surface.layout.padding = 4;
|
tool_surface.layout.padding = 4;
|
||||||
|
tool_surface.on_context = [](Widget&, float, float) {};
|
||||||
auto& vptools = tool_surface.add(Kind::Row, "viewport-tools");
|
auto& vptools = tool_surface.add(Kind::Row, "viewport-tools");
|
||||||
vptools.layout.height = 28;
|
vptools.layout.height = 28;
|
||||||
vptools.layout.gap = 4;
|
vptools.layout.gap = 4;
|
||||||
@@ -582,6 +590,14 @@ struct EditorUI::Impl {
|
|||||||
items.layout.flex = 1;
|
items.layout.flex = 1;
|
||||||
items.layout.scroll = true;
|
items.layout.scroll = true;
|
||||||
items.layout.gap = 0;
|
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");
|
auto& console = bottom.add(Kind::Column, "console-items");
|
||||||
console.layout.flex = 1;
|
console.layout.flex = 1;
|
||||||
console.layout.scroll = true;
|
console.layout.scroll = true;
|
||||||
@@ -839,6 +855,177 @@ struct EditorUI::Impl {
|
|||||||
85);
|
85);
|
||||||
label(project, "project-settings-error", "");
|
label(project, "project-settings-error", "");
|
||||||
label(project, "project-settings-reload-note", "Reload saved discards this form's edits.");
|
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) {
|
void new_scene(int dimension) {
|
||||||
auto result =
|
auto result =
|
||||||
@@ -850,6 +1037,7 @@ struct EditorUI::Impl {
|
|||||||
void choose_document(std::string id) {
|
void choose_document(std::string id) {
|
||||||
if (document != id)
|
if (document != id)
|
||||||
last_document = document;
|
last_document = document;
|
||||||
|
close_context();
|
||||||
instance_selection = Json::array();
|
instance_selection = Json::array();
|
||||||
ui.clear_focus(false);
|
ui.clear_focus(false);
|
||||||
document = id;
|
document = id;
|
||||||
@@ -1341,6 +1529,10 @@ struct EditorUI::Impl {
|
|||||||
root.tooltip = full_scene_name;
|
root.tooltip = full_scene_name;
|
||||||
root.selected = selected.empty() && instance_selection.empty();
|
root.selected = selected.empty() && instance_selection.empty();
|
||||||
root.on_click = [this](Widget&) { select(""); };
|
root.on_click = [this](Widget&) { select(""); };
|
||||||
|
root.on_context = [this](Widget&, float x, float y) {
|
||||||
|
select("");
|
||||||
|
open_scene_context(x, y);
|
||||||
|
};
|
||||||
touch(root);
|
touch(root);
|
||||||
std::vector<Json> groups;
|
std::vector<Json> groups;
|
||||||
for (const auto& instance : current.at("scene").value("instances", Json::array()))
|
for (const auto& instance : current.at("scene").value("instances", Json::array()))
|
||||||
@@ -1382,6 +1574,9 @@ struct EditorUI::Impl {
|
|||||||
row.selected = selected == id;
|
row.selected = selected == id;
|
||||||
row.drag_payload = {{"kind", "entity"}, {"id", id}, {"label", object.at("name")}};
|
row.drag_payload = {{"kind", "entity"}, {"id", id}, {"label", object.at("name")}};
|
||||||
row.on_click = [this, id](Widget&) { select(id); };
|
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) {
|
row.on_drop = [this, id](Widget&, const Json& payload) {
|
||||||
if (payload.value("kind", std::string()) == "entity")
|
if (payload.value("kind", std::string()) == "entity")
|
||||||
reparent(payload.at("id"), id);
|
reparent(payload.at("id"), id);
|
||||||
@@ -1398,6 +1593,9 @@ struct EditorUI::Impl {
|
|||||||
row.indent = depth;
|
row.indent = depth;
|
||||||
row.selected = instance_selection == path;
|
row.selected = instance_selection == path;
|
||||||
row.on_click = [this, path](Widget&) { select_instance(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);
|
touch(row);
|
||||||
objects(path, "", depth + 1);
|
objects(path, "", depth + 1);
|
||||||
for (const auto& candidate : groups) {
|
for (const auto& candidate : groups) {
|
||||||
@@ -1826,11 +2024,20 @@ struct EditorUI::Impl {
|
|||||||
assets = result.at("assets");
|
assets = result.at("assets");
|
||||||
}
|
}
|
||||||
auto& list = *ui.find("asset-items");
|
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")->text = script ? "Open Script" : "Open Scene";
|
||||||
ui.find("asset-open")->tooltip =
|
ui.find("asset-open")->tooltip =
|
||||||
script ? "Open Lua source in your external editor" : "Open a project scene";
|
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;
|
std::set<std::string> keep;
|
||||||
for (const auto& file : files) {
|
for (const auto& file : files) {
|
||||||
const auto path = file.get<std::string>();
|
const auto path = file.get<std::string>();
|
||||||
@@ -1849,11 +2056,15 @@ struct EditorUI::Impl {
|
|||||||
row.layout.height = 25;
|
row.layout.height = 25;
|
||||||
row.indent = 1;
|
row.indent = 1;
|
||||||
row.tooltip = path;
|
row.tooltip = path;
|
||||||
row.selected = source_file == path;
|
row.selected = selected_asset.empty() && source_file == path;
|
||||||
row.on_click = [this, path](Widget&) {
|
row.on_click = [this, path](Widget&) {
|
||||||
source_file = path;
|
source_file = path;
|
||||||
|
selected_asset.clear();
|
||||||
assets_dirty = true;
|
assets_dirty = true;
|
||||||
};
|
};
|
||||||
|
row.on_context = [this, path](Widget&, float x, float y) {
|
||||||
|
open_file_context(path, x, y);
|
||||||
|
};
|
||||||
keep.insert(row.id);
|
keep.insert(row.id);
|
||||||
}
|
}
|
||||||
for (const auto& asset : assets) {
|
for (const auto& asset : assets) {
|
||||||
@@ -1871,19 +2082,22 @@ struct EditorUI::Impl {
|
|||||||
list.add(Kind::TreeRow, "asset-" + id, std::string(prefix) + " / " + name);
|
list.add(Kind::TreeRow, "asset-" + id, std::string(prefix) + " / " + name);
|
||||||
row.layout.height = 25;
|
row.layout.height = 25;
|
||||||
row.indent = 1;
|
row.indent = 1;
|
||||||
|
row.selected = selected_asset == id;
|
||||||
row.tooltip = "Asset " + id + " — " + state;
|
row.tooltip = "Asset " + id + " — " + state;
|
||||||
row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}};
|
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&) {
|
row.on_click = [this, id, asset, freshness, state](Widget&) {
|
||||||
|
selected_asset = id;
|
||||||
|
source_file = asset_source(asset);
|
||||||
|
assets_dirty = true;
|
||||||
renderer.set_clipboard(id);
|
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"
|
status = state == "current"
|
||||||
? "Asset ID copied; drag to viewport or an asset field"
|
? "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()))
|
for (const auto& reason : freshness.value("reasons", Json::array()))
|
||||||
report(reason.value("message", "Input changed") + ": " +
|
report(reason.value("message", "Input changed") + ": " +
|
||||||
reason.value("path", ""));
|
reason.value("path", ""));
|
||||||
@@ -2238,6 +2452,8 @@ struct EditorUI::Impl {
|
|||||||
refresh_project_settings();
|
refresh_project_settings();
|
||||||
const bool modal = palette || !recovery.empty() || simulation_open ||
|
const bool modal = palette || !recovery.empty() || simulation_open ||
|
||||||
project_switch_warning || project_settings_open;
|
project_switch_warning || project_settings_open;
|
||||||
|
if (modal)
|
||||||
|
close_context();
|
||||||
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"})
|
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"})
|
||||||
ui.find(id)->enabled = !modal;
|
ui.find(id)->enabled = !modal;
|
||||||
const bool file = menu == "File" || menu == "Faset",
|
const bool file = menu == "File" || menu == "Faset",
|
||||||
@@ -2739,6 +2955,8 @@ struct EditorUI::Impl {
|
|||||||
using Type = render::Event::Type;
|
using Type = render::Event::Type;
|
||||||
if (event.type == Type::FocusLost) {
|
if (event.type == Type::FocusLost) {
|
||||||
camera_drag = 0;
|
camera_drag = 0;
|
||||||
|
right_dragged = false;
|
||||||
|
close_context();
|
||||||
gizmo_axis = -1;
|
gizmo_axis = -1;
|
||||||
preview_fields.clear();
|
preview_fields.clear();
|
||||||
return false;
|
return false;
|
||||||
@@ -2754,6 +2972,12 @@ struct EditorUI::Impl {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (camera_drag) {
|
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;
|
const bool is2d = current.at("scene").value("dimension", 3) == 2;
|
||||||
if (camera_drag == 3 && !is2d) {
|
if (camera_drag == 3 && !is2d) {
|
||||||
yaw -= dx / ui_scale * .008f;
|
yaw -= dx / ui_scale * .008f;
|
||||||
@@ -2774,6 +2998,11 @@ struct EditorUI::Impl {
|
|||||||
last_y = event.y;
|
last_y = event.y;
|
||||||
if (event.button == 2 || event.button == 3) {
|
if (event.button == 2 || event.button == 3) {
|
||||||
camera_drag = event.button;
|
camera_drag = event.button;
|
||||||
|
if (event.button == 3) {
|
||||||
|
right_down_x = event.x;
|
||||||
|
right_down_y = event.y;
|
||||||
|
right_dragged = false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.button == 1) {
|
if (event.button == 1) {
|
||||||
@@ -2785,6 +3014,15 @@ struct EditorUI::Impl {
|
|||||||
if (event.type == Type::MouseUp) {
|
if (event.type == Type::MouseUp) {
|
||||||
if (event.button == camera_drag) {
|
if (event.button == camera_drag) {
|
||||||
camera_drag = 0;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.button == 1 && gizmo_axis >= 0) {
|
if (event.button == 1 && gizmo_axis >= 0) {
|
||||||
@@ -2813,6 +3051,10 @@ struct EditorUI::Impl {
|
|||||||
using Type = render::Event::Type;
|
using Type = render::Event::Type;
|
||||||
for (const auto& event : input) {
|
for (const auto& event : input) {
|
||||||
if (event.type == Type::KeyDown && event.key == "Escape") {
|
if (event.type == Type::KeyDown && event.key == "Escape") {
|
||||||
|
if (ui.find("context-popup")->visible) {
|
||||||
|
close_context();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (project_settings_open) {
|
if (project_settings_open) {
|
||||||
project_settings_open = false;
|
project_settings_open = false;
|
||||||
ui.clear_focus(false);
|
ui.clear_focus(false);
|
||||||
@@ -2843,6 +3085,9 @@ struct EditorUI::Impl {
|
|||||||
if (event.type == Type::MouseDown && !menu.empty() &&
|
if (event.type == Type::MouseDown && !menu.empty() &&
|
||||||
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36 * ui_scale)
|
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36 * ui_scale)
|
||||||
menu.clear();
|
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) {
|
if (event.type == Type::MouseMove || event.type == Type::MouseDown) {
|
||||||
mouse_x = event.x;
|
mouse_x = event.x;
|
||||||
mouse_y = event.y;
|
mouse_y = event.y;
|
||||||
@@ -2904,8 +3149,11 @@ struct EditorUI::Impl {
|
|||||||
const auto next_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
const auto next_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||||
const bool geometry_changed = next_scale != ui_scale || layout_width != renderer.width() ||
|
const bool geometry_changed = next_scale != ui_scale || layout_width != renderer.width() ||
|
||||||
layout_height != renderer.height();
|
layout_height != renderer.height();
|
||||||
|
if (geometry_changed)
|
||||||
|
close_context();
|
||||||
if (next_scale != ui_scale) {
|
if (next_scale != ui_scale) {
|
||||||
camera_drag = 0;
|
camera_drag = 0;
|
||||||
|
right_dragged = false;
|
||||||
if (gizmo_axis >= 0) {
|
if (gizmo_axis >= 0) {
|
||||||
const auto field = gizmo_mode == "Move" ? "position"
|
const auto field = gizmo_mode == "Move" ? "position"
|
||||||
: gizmo_mode == "Rotate" ? "rotation"
|
: gizmo_mode == "Rotate" ? "rotation"
|
||||||
|
|||||||
@@ -989,6 +989,20 @@ bool Context::handle(const render::Event& event) {
|
|||||||
p.hovered = hit ? hit->id : "";
|
p.hovered = hit ? hit->id : "";
|
||||||
p.hover_started = std::chrono::steady_clock::now();
|
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) {
|
if (event.type == Type::Wheel) {
|
||||||
auto* w = p.hit(p.root, p.mouse_x, p.mouse_y);
|
auto* w = p.hit(p.root, p.mouse_x, p.mouse_y);
|
||||||
for (; w; w = w->parent)
|
for (; w; w = w->parent)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,11 +215,71 @@ void keyboard_scroll_contract() {
|
|||||||
outer.scroll_y == old_outer && inner.scroll_y == old_inner,
|
outer.scroll_y == old_outer && inner.scroll_y == old_inner,
|
||||||
"Rejected numeric commit retains focus and scroll instead of hiding the error");
|
"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
|
} // namespace
|
||||||
int main() {
|
int main() {
|
||||||
try {
|
try {
|
||||||
display_scale_contract();
|
display_scale_contract();
|
||||||
keyboard_scroll_contract();
|
keyboard_scroll_contract();
|
||||||
|
right_click_context_contract();
|
||||||
ui::TextBuffer buffer("Привет");
|
ui::TextBuffer buffer("Привет");
|
||||||
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
||||||
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
||||||
|
|||||||
Reference in New Issue
Block a user