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
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:
@@ -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
@@ -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 |
@@ -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 |
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
"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");
|
||||
|
||||
Reference in New Issue
Block a user