Refine dark editor and launcher interface
Native and manual checks / native (ubuntu-24.04) (push) Failing after 52s
Native and manual checks / manual (push) Successful in 34s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s

This commit is contained in:
Emil
2026-09-23 21:42:48 +03:00
parent 4cc58d8c59
commit e2c887dc1f
15 changed files with 494 additions and 175 deletions
+48 -22
View File
@@ -10,6 +10,7 @@
namespace faset::editor { namespace faset::editor {
namespace { namespace {
namespace fs = std::filesystem; namespace fs = std::filesystem;
using ui::Appearance;
using ui::Kind; using ui::Kind;
using ui::Widget; using ui::Widget;
fs::path user_home() { fs::path user_home() {
@@ -129,10 +130,18 @@ struct ProjectLauncher::Impl {
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"), : renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) { recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json"); auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
theme.font_size = 15; theme.font_size = 14;
theme.row_height = 30; theme.row_height = 28;
ui.set_theme(theme); ui.set_theme(theme);
ui.apply_layout(read_json(engine / "assets/ui/project-launcher-layout.json")); ui.apply_layout(read_json(engine / "assets/ui/project-launcher-layout.json"));
for (const auto* id : {"launcher-heading-rule", "launcher-recent-rule",
"launcher-actions-rule"})
ui.find(id)->appearance = Appearance::Section;
for (const auto* id : {"launcher-browse", "launcher-cancel"})
ui.find(id)->appearance = Appearance::Quiet;
ui.find("launcher-submit")->appearance = Appearance::Primary;
for (const auto* id : {"launcher-recent-title", "launcher-hint", "launcher-shortcuts"})
ui.find(id)->enabled = false;
ui.set_clipboard([this] { return renderer.clipboard(); }, ui.set_clipboard([this] { return renderer.clipboard(); },
[this](const std::string& value) { renderer.set_clipboard(value); }); [this](const std::string& value) { renderer.set_clipboard(value); });
ui.set_ime( ui.set_ime(
@@ -145,7 +154,6 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); }; ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; }; ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; }; ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; };
ui.find("launcher-submit")->selected = true;
ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); }; ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); };
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; }; ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); }; ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
@@ -177,7 +185,7 @@ struct ProjectLauncher::Impl {
continue; continue;
const auto id = "launcher-recent-" + std::to_string(i++); const auto id = "launcher-recent-" + std::to_string(i++);
auto& item = list.add(Kind::TreeRow, id, selection.name); auto& item = list.add(Kind::TreeRow, id, selection.name);
item.layout.height = 40; item.layout.height = 38;
item.tooltip = path_to_utf8(selection.path); item.tooltip = path_to_utf8(selection.path);
item.on_click = [this, path = selection.path](Widget&) { item.on_click = [this, path = selection.path](Widget&) {
ui.update_text("launcher-path", path_to_utf8(path), true); ui.update_text("launcher-path", path_to_utf8(path), true);
@@ -187,7 +195,7 @@ struct ProjectLauncher::Impl {
} }
} }
if (i == 0) if (i == 0)
list.add(Kind::Label, "launcher-recents-empty", "No recent projects"); list.add(Kind::Label, "launcher-recents-empty", "No recent projects").enabled = false;
} }
void submit() { void submit() {
ui.clear_focus(); ui.clear_focus();
@@ -223,18 +231,22 @@ struct ProjectLauncher::Impl {
void build_browser() { void build_browser() {
auto& panel = ui.root().add(Kind::Panel, "launcher-browser"); auto& panel = ui.root().add(Kind::Panel, "launcher-browser");
panel.layout.absolute = true; panel.layout.absolute = true;
panel.layout.padding = 16; panel.layout.padding = 20;
panel.layout.gap = 10; panel.layout.gap = 8;
panel.visible = false; panel.visible = false;
auto& title = panel.add(Kind::Label, "browser-heading", "Choose project directory"); auto& title = panel.add(Kind::Label, "browser-heading", "Choose project directory");
title.font_size = 20; title.font_size = 20;
title.layout.height = 35; title.layout.height = 44;
auto& nav = panel.add(Kind::Row, "browser-navigation"); auto& nav = panel.add(Kind::Row, "browser-navigation");
nav.layout.height = 36; nav.layout.height = 38;
button(nav, "browser-up", "Up", [this] { auto& up = button(nav, "browser-up", "Up", [this] {
browse_to(browse_path.parent_path()); browse_to(browse_path.parent_path());
}).layout.width = 60; });
button(nav, "browser-home", "Home", [this] { browse_to(user_home()); }).layout.width = 76; up.appearance = Appearance::Quiet;
up.layout.width = 60;
auto& home = button(nav, "browser-home", "Home", [this] { browse_to(user_home()); });
home.appearance = Appearance::Quiet;
home.layout.width = 76;
auto& path = nav.add(Kind::TextField, "browser-path"); auto& path = nav.add(Kind::TextField, "browser-path");
path.layout.flex = 1; path.layout.flex = 1;
path.on_preview = [this](Widget&) { browser_valid = false; }; path.on_preview = [this](Widget&) { browser_valid = false; };
@@ -250,14 +262,18 @@ struct ProjectLauncher::Impl {
list.layout.scroll = true; list.layout.scroll = true;
list.layout.gap = 2; list.layout.gap = 2;
auto& actions = panel.add(Kind::Row, "browser-actions"); auto& actions = panel.add(Kind::Row, "browser-actions");
actions.layout.height = 36; actions.layout.height = 42;
button(actions, "browser-choose", "Choose directory", [this] { auto& choose = button(actions, "browser-choose", "Choose directory", [this] {
if (browser_valid) { if (browser_valid) {
ui.update_text("launcher-path", path_to_utf8(browse_path), true); ui.update_text("launcher-path", path_to_utf8(browse_path), true);
close_browser(); close_browser();
} }
}).layout.width = 180; });
button(actions, "browser-cancel", "Cancel", [this] { close_browser(); }).layout.width = 90; choose.appearance = Appearance::Primary;
choose.layout.width = 180;
auto& cancel = button(actions, "browser-cancel", "Cancel", [this] { close_browser(); });
cancel.appearance = Appearance::Quiet;
cancel.layout.width = 90;
panel.add(Kind::Label, "browser-error"); panel.add(Kind::Label, "browser-error");
} }
void browse_to(const fs::path& path) { void browse_to(const fs::path& path) {
@@ -285,14 +301,14 @@ struct ProjectLauncher::Impl {
for (const auto& child : children) { for (const auto& child : children) {
auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++), auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++),
"[Folder] " + path_to_utf8(child.filename())); "[Folder] " + path_to_utf8(child.filename()));
row.layout.height = 34; row.layout.height = 36;
row.on_click = [this, child](Widget&) { row.on_click = [this, child](Widget&) {
ui.clear_focus(); ui.clear_focus();
browse_to(child); browse_to(child);
}; };
} }
if (children.empty()) if (children.empty())
list.add(Kind::Label, "browser-empty", "No subdirectories"); list.add(Kind::Label, "browser-empty", "No subdirectories").enabled = false;
} catch (const fs::filesystem_error&) { } catch (const fs::filesystem_error&) {
error = "Cannot access this directory."; error = "Cannot access this directory.";
} catch (const std::exception& e) { } catch (const std::exception& e) {
@@ -335,8 +351,14 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create; ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2; ui.find("launcher-2d")->selected = dimension == 2;
ui.find("launcher-3d")->selected = dimension == 3; ui.find("launcher-3d")->selected = dimension == 3;
ui.find("launcher-2d")->appearance =
dimension == 2 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-3d")->appearance =
dimension == 3 ? Appearance::Quiet : Appearance::Default;
for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label", for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
"launcher-dimensions"}) "launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space",
"launcher-dimension-group"})
ui.find(id)->visible = create; ui.find(id)->visible = create;
ui.find("launcher-heading")->text = create ? "Create project" : "Open project"; ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
ui.find("launcher-submit")->text = create ? "Create project" : "Open project"; ui.find("launcher-submit")->text = create ? "Create project" : "Open project";
@@ -352,9 +374,13 @@ struct ProjectLauncher::Impl {
dialog->layout.height = std::max(300.f, std::min(540.f, logical_height() - 40)); dialog->layout.height = std::max(300.f, std::min(540.f, logical_height() - 40));
dialog->layout.x = (logical_width() - dialog->layout.width) * .5f; dialog->layout.x = (logical_width() - dialog->layout.width) * .5f;
dialog->layout.y = (logical_height() - dialog->layout.height) * .5f; dialog->layout.y = (logical_height() - dialog->layout.height) * .5f;
ui.find("launcher-sidebar")->layout.width = const auto sidebar_width = std::clamp(logical_width() * .24f, 180.f, 235.f);
std::clamp(logical_width() * .24f, 180.f, 235.f); const auto main_padding = logical_width() < 850 ? 20.f : 36.f;
ui.find("launcher-main")->layout.padding = logical_width() < 850 ? 16 : 32; ui.find("launcher-sidebar")->layout.width = sidebar_width;
ui.find("launcher-sidebar")->layout.padding = logical_width() < 850 ? 14.f : 18.f;
ui.find("launcher-main")->layout.padding = main_padding;
ui.find("launcher-form")->layout.width =
std::min(720.f, std::max(0.f, logical_width() - sidebar_width - main_padding * 2));
} }
void frame(const std::vector<render::Event>& events) { void frame(const std::vector<render::Event>& events) {
refresh(); refresh();
+10 -10
View File
@@ -1,14 +1,14 @@
{ {
"background": [0.073, 0.078, 0.090, 1], "background": [0.078, 0.078, 0.078, 1],
"surface": [0.102, 0.108, 0.122, 1], "surface": [0.110, 0.110, 0.114, 1],
"raised": [0.145, 0.151, 0.169, 1], "raised": [0.153, 0.153, 0.161, 1],
"hover": [0.19, 0.197, 0.219, 1], "hover": [0.196, 0.196, 0.204, 1],
"border": [0.24, 0.25, 0.278, 1], "border": [0.227, 0.227, 0.239, 1],
"text": [0.88, 0.889, 0.91, 1], "text": [0.878, 0.878, 0.886, 1],
"muted": [0.59, 0.61, 0.65, 1], "muted": [0.604, 0.604, 0.624, 1],
"accent": [0.65, 0.60, 0.88, 1], "accent": [0.659, 0.627, 0.761, 1],
"selection": [0.26, 0.245, 0.35, 1], "selection": [0.220, 0.212, 0.251, 1],
"danger": [0.92, 0.39, 0.38, 1], "danger": [0.835, 0.431, 0.420, 1],
"font_size": 14, "font_size": 14,
"row_height": 28, "row_height": 28,
"padding": 8, "padding": 8,
+6 -6
View File
@@ -2,18 +2,18 @@
"root": { "root": {
"id": "root", "kind": "column", "layout": {"gap": 1}, "id": "root", "kind": "column", "layout": {"gap": 1},
"children": [ "children": [
{"id": "menubar", "kind": "panel", "layout": {"height": 34, "padding": 3}}, {"id": "menubar", "kind": "panel", "layout": {"height": 36, "padding": 3}},
{"id": "toolbar", "kind": "panel", "layout": {"height": 40, "padding": 5}}, {"id": "toolbar", "kind": "panel", "layout": {"height": 44, "padding": 7}},
{"id": "workspace", "kind": "row", "layout": {"flex": 1, "gap": 1}, "children": [ {"id": "workspace", "kind": "row", "layout": {"flex": 1, "gap": 1}, "children": [
{"id": "scene_panel", "kind": "panel", "layout": {"width": 224, "min_width": 150, "gap": 0}}, {"id": "scene_panel", "kind": "panel", "layout": {"width": 238, "min_width": 150, "gap": 0}},
{"id": "left_divider", "kind": "divider", "layout": {"width": 5}}, {"id": "left_divider", "kind": "divider", "layout": {"width": 5}},
{"id": "viewport", "kind": "viewport", "layout": {"flex": 1, "min_width": 180}}, {"id": "viewport", "kind": "viewport", "layout": {"flex": 1, "min_width": 180}},
{"id": "right_divider", "kind": "divider", "layout": {"width": 5}}, {"id": "right_divider", "kind": "divider", "layout": {"width": 5}},
{"id": "inspector_panel", "kind": "panel", "layout": {"width": 300, "min_width": 210, "gap": 0}} {"id": "inspector_panel", "kind": "panel", "layout": {"width": 312, "min_width": 210, "gap": 0}}
]}, ]},
{"id": "bottom_divider", "kind": "divider", "layout": {"height": 5}}, {"id": "bottom_divider", "kind": "divider", "layout": {"height": 5}},
{"id": "bottom_panel", "kind": "panel", "layout": {"height": 184, "min_height": 90, "gap": 0}}, {"id": "bottom_panel", "kind": "panel", "layout": {"height": 190, "min_height": 90, "gap": 0}},
{"id": "statusbar", "kind": "panel", "layout": {"height": 26, "padding": 0}} {"id": "statusbar", "kind": "panel", "layout": {"height": 25, "padding": 0}}
] ]
} }
} }
+45 -25
View File
@@ -5,34 +5,54 @@
"children": [{ "children": [{
"id": "launcher-body", "kind": "row", "layout": {"flex": 1, "gap": 0}, "id": "launcher-body", "kind": "row", "layout": {"flex": 1, "gap": 0},
"children": [ "children": [
{"id": "launcher-sidebar", "kind": "panel", "layout": {"width": 235, "padding": 16, "gap": 8}, "children": [ {"id": "launcher-sidebar", "kind": "panel", "layout": {"width": 235, "padding": 18, "gap": 0}, "children": [
{"id": "launcher-wordmark", "kind": "label", "text": "Faset", "font_size": 26, "layout": {"height": 66}}, {"id": "launcher-wordmark", "kind": "label", "text": "Faset", "font_size": 24, "layout": {"height": 70}},
{"id": "launcher-open", "kind": "tree_row", "text": "Open project", "layout": {"height": 40}}, {"id": "launcher-nav", "kind": "column", "layout": {"gap": 4}, "children": [
{"id": "launcher-create", "kind": "tree_row", "text": "Create project", "layout": {"height": 40}}, {"id": "launcher-open", "kind": "tree_row", "text": "Open project", "layout": {"height": 38}},
{"id": "launcher-recent-title", "kind": "label", "text": "Recent projects", "layout": {"height": 54}}, {"id": "launcher-create", "kind": "tree_row", "text": "Create project", "layout": {"height": 38}}
{"id": "launcher-recents", "kind": "column", "layout": {"flex": 1, "scroll": true, "gap": 6}} ]},
{"id": "launcher-sidebar-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-recent-rule", "kind": "row", "layout": {"height": 1}},
{"id": "launcher-recent-title", "kind": "label", "text": "Recent projects", "font_size": 13, "layout": {"height": 48}},
{"id": "launcher-recents", "kind": "column", "layout": {"flex": 1, "scroll": true, "gap": 4}}
]}, ]},
{"id": "launcher-main", "kind": "column", "layout": {"flex": 1, "padding": 32, "gap": 12, "scroll": true}, "children": [ {"id": "launcher-main", "kind": "column", "layout": {"flex": 1, "padding": 36, "gap": 0, "scroll": true}, "children": [
{"id": "launcher-heading", "kind": "label", "text": "Open project", "font_size": 26, "layout": {"height": 64}}, {"id": "launcher-heading", "kind": "label", "text": "Open project", "font_size": 26, "layout": {"height": 64}},
{"id": "launcher-name-label", "kind": "label", "text": "Project name"}, {"id": "launcher-heading-rule", "kind": "row", "layout": {"height": 1}},
{"id": "launcher-name", "kind": "text_field", "text": "My Game", "layout": {"height": 40}}, {"id": "launcher-form", "kind": "column", "layout": {"width": 720, "gap": 0}, "children": [
{"id": "launcher-path-label", "kind": "label", "text": "Project directory"}, {"id": "launcher-form-top", "kind": "row", "layout": {"height": 24}},
{"id": "launcher-path-row", "kind": "row", "layout": {"height": 40, "gap": 8}, "children": [ {"id": "launcher-name-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-path", "kind": "text_field", "layout": {"flex": 1}}, {"id": "launcher-name-label", "kind": "label", "text": "Project name", "layout": {"height": 24}},
{"id": "launcher-browse", "kind": "button", "text": "Browse", "layout": {"width": 84}} {"id": "launcher-name", "kind": "text_field", "text": "My Game", "layout": {"height": 42}}
]},
{"id": "launcher-name-space", "kind": "row", "layout": {"height": 20}},
{"id": "launcher-path-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-path-label", "kind": "label", "text": "Project directory", "layout": {"height": 24}},
{"id": "launcher-path-row", "kind": "row", "layout": {"height": 42, "gap": 8}, "children": [
{"id": "launcher-path", "kind": "text_field", "layout": {"flex": 1}},
{"id": "launcher-browse", "kind": "button", "text": "Browse", "layout": {"width": 88}}
]}
]},
{"id": "launcher-path-space", "kind": "row", "layout": {"height": 20}},
{"id": "launcher-dimension-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-dimension-label", "kind": "label", "text": "Initial scene type", "layout": {"height": 24}},
{"id": "launcher-dimensions", "kind": "row", "layout": {"height": 42, "gap": 4}, "children": [
{"id": "launcher-2d", "kind": "button", "text": "2D", "layout": {"width": 116}},
{"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}}
]}
]},
{"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}},
{"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}},
{"id": "launcher-actions-top", "kind": "row", "layout": {"height": 18}},
{"id": "launcher-actions", "kind": "row", "layout": {"height": 42, "gap": 10}, "children": [
{"id": "launcher-submit", "kind": "button", "text": "Open project", "layout": {"width": 176}},
{"id": "launcher-cancel", "kind": "button", "text": "Cancel", "layout": {"width": 90}}
]},
{"id": "launcher-error", "kind": "label", "layout": {"height": 36}}
]}, ]},
{"id": "launcher-dimension-label", "kind": "label", "text": "Initial scene type"}, {"id": "launcher-main-spacer", "kind": "row", "layout": {"flex": 1}},
{"id": "launcher-dimensions", "kind": "row", "layout": {"height": 40, "gap": 0}, "children": [ {"id": "launcher-shortcuts", "kind": "label", "text": "Tab: next field Ctrl+Enter: continue Escape: cancel", "font_size": 12, "layout": {"height": 24}}
{"id": "launcher-2d", "kind": "tab", "text": "2D", "layout": {"width": 120}},
{"id": "launcher-3d", "kind": "tab", "text": "3D", "layout": {"width": 120}}
]},
{"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "layout": {"height": 36}},
{"id": "launcher-actions", "kind": "row", "layout": {"height": 40, "gap": 8}, "children": [
{"id": "launcher-submit", "kind": "button", "text": "Open project", "layout": {"width": 180}},
{"id": "launcher-cancel", "kind": "button", "text": "Cancel", "layout": {"width": 90}}
]},
{"id": "launcher-error", "kind": "label", "layout": {"height": 30}},
{"id": "launcher-shortcuts", "kind": "label", "text": "Tab: next field Ctrl+Enter: continue Escape: cancel"}
]} ]}
] ]
}] }]
@@ -0,0 +1,24 @@
# Editor visual direction — 2026-09-23
![Editor redesign prototype](editor-redesign-2026-09-23.png)
Generated with the built-in image generation tool before changing the native editor UI. This is a visual reference, not a working Faset screenshot or a feature specification. The generated scene, icons, asset columns, FPS counter, tag/layer controls and viewport shading are illustrative. The implemented controls must reflect real Faset behavior.
## Implemented native UI
![Editor integration test capture](editor-redesign-implemented.png)
![Project launcher integration test capture](project-launcher-redesign-implemented.png)
These are Vulkan captures from the Linux UI integration tests after the redesign. Their test project data and viewport contents are examples; the interaction model is defined by the native UI and editor code.
## Design brief
- A quiet graphics workbench: the rendered scene is the focal point; tool chrome recedes.
- Near-black neutral surfaces: background `#141414`, panels `#1c1c1d`, raised controls `#272729`, hairline borders `#3a3a3d`.
- Light text `#e0e0e2`, secondary text `#9a9a9f`, restrained lavender `#a8a0c2` for focus, selection and the primary Play action.
- Keep Noto Sans for the native editor's single-font Unicode pipeline; use size and spacing for hierarchy. Compact 14 px controls and 28 px rows.
- Retain the real Scene / viewport / Inspector / bottom dock layout, stable widget IDs, keyboard focus, editor commands and MCP parity. Use no raster screenshot as an interactive background.
- Make secondary actions quiet; keep explicit input fields and modal actions legible. Show file names first and preserve full asset paths in tooltips. Keep import freshness visible at the start of imported asset rows.
The generated prototype was requested with the current editor's real regions and actions, a simple greybox scene and flat retained-mode controls. The visual hierarchy, palette and density are the reference; the repository's UI code and authoring contract determine functionality.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+11 -5
View File
@@ -97,15 +97,16 @@ Read the fresh value and retry; the stale edit does not silently replace the new
## Refresh C++ metadata ## Refresh C++ metadata
**Build C++ !** and the status message indicate stale gameplay metadata, for example The status message indicates stale gameplay metadata, for example after changing
after changing gameplay sources or after a failed build. Choose **Build C++**, then gameplay sources or after a failed build. Hover over **Build** for details, then
check **Jobs** and **Console**. A successful build and schema export refresh the choose **Build** and check **Jobs** and **Console**. A successful build and schema
Inspector. A failed build retains the previous metadata and reports the failure. export refresh the Inspector. A failed build retains the previous metadata and
reports the failure.
A missing schema or unsupported component version appears as read-only raw fields A missing schema or unsupported component version appears as read-only raw fields
with **Copy raw fields**. Restore a missing module to make its schema available. with **Copy raw fields**. Restore a missing module to make its schema available.
For an older component, declare [data migration rules](../scripting/api.md#editor-data-migrations), For an older component, declare [data migration rules](../scripting/api.md#editor-data-migrations),
choose **Build C++**, then **Migrate to v…** in the Inspector. This is one undoable choose **Build**, then **Migrate to v…** in the Inspector. This is one undoable
authoring edit; save explicitly afterward. Inherited components show **Open source authoring edit; save explicitly afterward. Inherited components show **Open source
to migrate** instead. Top-level local additions migrate in their owning instance. to migrate** instead. Top-level local additions migrate in their owning instance.
Missing rules or conversion errors preserve the data and appear in Console. Missing rules or conversion errors preserve the data and appear in Console.
@@ -151,6 +152,11 @@ messages and diagnostics. **Jobs** shows build/import/export progress and cancel
drag panel dividers to resize the Scene, Inspector, and bottom areas. These choices drag panel dividers to resize the Scene, Inspector, and bottom areas. These choices
are stored per project in `.faset/editor-layout.json`. are stored per project in `.faset/editor-layout.json`.
File rows put the filename before its directory; hover to see the full project-relative
path. Long generated hexadecimal filenames are shortened in the list, while the
path and underlying asset identity remain intact. The search field filters by the
full project-relative path.
The current layout supports these panel sizes and bottom-tab ordering. It does not The current layout supports these panel sizes and bottom-tab ordering. It does not
provide floating panels or multiple Editor windows. Theme and base layout JSON live provide floating panels or multiple Editor windows. Theme and base layout JSON live
in the engine's `assets/ui/dark.json` and `assets/ui/editor-layout.json`. The Editor in the engine's `assets/ui/dark.json` and `assets/ui/editor-layout.json`. The Editor
+3
View File
@@ -39,6 +39,9 @@ class Session {
bool playing() const { bool playing() const {
return bool(player_); return bool(player_);
} }
bool play_pending() const {
return !pending_play_job_.empty();
}
void log(std::string message); void log(std::string message);
private: private:
+10 -6
View File
@@ -18,10 +18,11 @@ struct Rect {
Rect intersection(const Rect&) const noexcept; Rect intersection(const Rect&) const noexcept;
}; };
struct Theme { struct Theme {
Color background{.075f, .078f, .086f, 1}, surface{.10f, .105f, .115f, 1}; Color background{.078f, .078f, .078f, 1}, surface{.110f, .110f, .114f, 1};
Color raised{.135f, .14f, .15f, 1}, hover{.175f, .18f, .20f, 1}, border{.23f, .235f, .255f, 1}; Color raised{.153f, .153f, .161f, 1}, hover{.196f, .196f, .204f, 1};
Color text{.86f, .875f, .90f, 1}, muted{.55f, .575f, .62f, 1}, accent{.65f, .60f, .88f, 1}; Color border{.227f, .227f, .239f, 1}, text{.878f, .878f, .886f, 1};
Color selection{.26f, .245f, .35f, 1}, danger{.92f, .39f, .38f, 1}; Color muted{.604f, .604f, .624f, 1}, accent{.659f, .627f, .761f, 1};
Color selection{.220f, .212f, .251f, 1}, danger{.835f, .431f, .420f, 1};
float font_size = 14, row_height = 28, padding = 8, gap = 4; float font_size = 14, row_height = 28, padding = 8, gap = 4;
static Theme from_json(const Json&); static Theme from_json(const Json&);
static Theme load(const std::filesystem::path&); static Theme load(const std::filesystem::path&);
@@ -103,15 +104,18 @@ enum class Kind {
Divider, Divider,
Viewport Viewport
}; };
// Visual emphasis is independent of widget behavior and stable identity.
enum class Appearance { Default, Quiet, Primary, Section };
struct Layout { struct Layout {
float width = -1, height = -1, flex = 0; float width = -1, height = -1, flex = 0;
float min_width = 0, min_height = 0, max_width = 100000, max_height = 100000; float min_width = 0, min_height = 0, max_width = 100000, max_height = 100000;
float padding = 0, gap = 4; float padding = 0, gap = 4;
bool absolute = false, scroll = false, clip = true; bool absolute = false, scroll = false, clip = true, stack_vertical = false;
float x = 0, y = 0; float x = 0, y = 0;
}; };
struct Widget { struct Widget {
Kind kind = Kind::Panel; Kind kind = Kind::Panel;
Appearance appearance = Appearance::Default;
std::string id, text; std::string id, text;
Layout layout; Layout layout;
Rect rect, clip; Rect rect, clip;
@@ -120,7 +124,7 @@ struct Widget {
int precision = 3, indent = 0; int precision = 3, indent = 0;
float font_size = 0; // Zero inherits the theme typography. float font_size = 0; // Zero inherits the theme typography.
float scroll_y = 0, content_height = 0; float scroll_y = 0, content_height = 0;
std::string error, tooltip; std::string error, tooltip, placeholder;
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;
+217 -88
View File
@@ -239,9 +239,9 @@ struct EditorUI::Impl {
} catch (const std::exception& e) { } catch (const std::exception& e) {
session.log(std::string("Layout reset: ") + e.what()); session.log(std::string("Layout reset: ") + e.what());
} }
ui.find("scene_panel")->layout.width = dock.size("scene", 224); ui.find("scene_panel")->layout.width = dock.size("scene", 238);
ui.find("inspector_panel")->layout.width = dock.size("inspector", 300); ui.find("inspector_panel")->layout.width = dock.size("inspector", 312);
ui.find("bottom_panel")->layout.height = dock.size("bottom", 184); ui.find("bottom_panel")->layout.height = dock.size("bottom", 190);
ui.set_clipboard([this] { return renderer.clipboard(); }, ui.set_clipboard([this] { return renderer.clipboard(); },
[this](const std::string& text) { renderer.set_clipboard(text); }); [this](const std::string& text) { renderer.set_clipboard(text); });
ui.set_ime([this](bool enabled) { renderer.set_text_input(enabled); }, ui.set_ime([this](bool enabled) { renderer.set_text_input(enabled); },
@@ -404,58 +404,88 @@ struct EditorUI::Impl {
} }
void build_static() { void build_static() {
auto& menurow = ui.find("menubar")->add(Kind::Row, "menuitems"); auto& menurow = ui.find("menubar")->add(Kind::Row, "menuitems");
menurow.layout.gap = 2; menurow.layout.gap = 0;
for (const std::string name : {"Faset", "File", "Edit", "Scene", "View", "Help"}) for (const std::string name : {"Faset", "File", "Edit", "Scene", "View", "Help"}) {
button( auto& item = button(
menurow, "menu-" + name, name, [this, name] { menu = menu == name ? "" : name; }, menurow, "menu-" + name, name, [this, name] { menu = menu == name ? "" : name; },
name == "Faset" ? 72 : 54); name == "Faset" ? 66 : name == "Scene" ? 58 : 50);
item.appearance = ui::Appearance::Quiet;
if (name == "Faset")
item.font_size = 15;
}
menurow.add(Kind::Label, "menu-space").layout.flex = 1;
auto& project = menurow.add(Kind::Label, "project-title", auto& project = menurow.add(Kind::Label, "project-title",
session.project().value("name", std::string("Project"))); session.project().value("name", std::string("Project")));
project.layout.flex = 1; project.layout.width = 350;
project.font_size = 12;
project.enabled = false;
auto& toolbar = ui.find("toolbar")->add(Kind::Row, "tools"); auto& toolbar = ui.find("toolbar")->add(Kind::Row, "tools");
toolbar.layout.gap = 5; toolbar.layout.gap = 3;
button(toolbar, "save", "Save", [this] { save(); }, 60); auto& file_tools = toolbar.add(Kind::Row, "file-tools");
button(toolbar, "undo", "Undo", [this] { history(false); }, 56); file_tools.layout.width = 180;
button(toolbar, "redo", "Redo", [this] { history(true); }, 56); file_tools.layout.gap = 3;
button(file_tools, "save", "Save", [this] { save(); }, 58).appearance =
ui::Appearance::Quiet;
button(file_tools, "undo", "Undo", [this] { history(false); }, 56).appearance =
ui::Appearance::Quiet;
button(file_tools, "redo", "Redo", [this] { history(true); }, 56).appearance =
ui::Appearance::Quiet;
toolbar.add(Kind::Label, "left-tools-space").layout.flex = 1;
auto& play_tools = toolbar.add(Kind::Row, "play-tools");
play_tools.layout.width = 265;
play_tools.layout.gap = 4;
button(play_tools, "play", "Play", [this] { call("faset_play", {{"document", document}}); },
66)
.appearance = ui::Appearance::Primary;
button( button(
toolbar, "play", "Play", [this] { call("faset_play", {{"document", document}}); }, 58); play_tools, "pause", "Pause",
button(
toolbar, "stop", "Stop",
[this] {
call("faset_stop");
paused = false;
},
56);
button(
toolbar, "pause", "Pause",
[this] { [this] {
if (!call("faset_play_control", {{"command", paused ? "resume" : "pause"}}) if (!call("faset_play_control", {{"command", paused ? "resume" : "pause"}})
.is_null()) .is_null())
paused = !paused; paused = !paused;
}, },
65); 68)
.appearance = ui::Appearance::Quiet;
button( button(
toolbar, "step", "Step", [this] { call("faset_play_control", {{"command", "step"}}); }, play_tools, "stop", "Stop",
55); [this] {
button(toolbar, "build", "Build", [this] { call("faset_build"); }, 94); call("faset_stop");
paused = false;
},
60)
.appearance = ui::Appearance::Quiet;
button( button(
toolbar, "export", "Export", play_tools, "step", "Step",
[this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 64); [this] { call("faset_play_control", {{"command", "step"}}); }, 56)
.appearance = ui::Appearance::Quiet;
toolbar.add(Kind::Label, "right-tools-space").layout.flex = 1;
auto& build_tools = toolbar.add(Kind::Row, "build-tools");
build_tools.layout.width = 354;
build_tools.layout.gap = 3;
button(build_tools, "build", "Build", [this] { call("faset_build"); }, 76).appearance =
ui::Appearance::Quiet;
button( button(
toolbar, "simulation-settings", "Simulation", build_tools, "export", "Export",
[this] { simulation_open = !simulation_open; }, 96); [this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 72)
auto& space = toolbar.add(Kind::Label, "toolbar-space", ""); .appearance = ui::Appearance::Quiet;
space.layout.flex = 1; button(
button(toolbar, "command-palette", "Commands", [this] { palette = !palette; }, 104); build_tools, "simulation-settings", "Simulation",
[this] { simulation_open = !simulation_open; }, 98)
.appearance = ui::Appearance::Quiet;
button(build_tools, "command-palette", "Commands", [this] { palette = !palette; }, 99)
.appearance = ui::Appearance::Quiet;
auto& scene = *ui.find("scene_panel"); auto& scene = *ui.find("scene_panel");
scene.add(Kind::Tab, "scene-tab", "Scene").selected = true; scene.add(Kind::Tab, "scene-tab", "Scene").selected = true;
auto& tools = scene.add(Kind::Row, "scene-tools"); auto& tools = scene.add(Kind::Row, "scene-tools");
tools.layout.height = 28; tools.layout.height = 32;
tools.layout.padding = 3; tools.layout.padding = 3;
tools.layout.gap = 3; tools.layout.gap = 3;
button(tools, "add-object", "+ Object", [this] { create_object("Object"); }, 80); button(tools, "add-object", "+ Object", [this] { create_object("Object"); }, 80)
button(tools, "add-cube", "Cube", [this] { create_object("Cube"); }, 55); .appearance = ui::Appearance::Quiet;
button(tools, "add-sprite", "Sprite", [this] { create_object("Sprite"); }, 58); button(tools, "add-cube", "Cube", [this] { create_object("Cube"); }, 55).appearance =
ui::Appearance::Quiet;
button(tools, "add-sprite", "Sprite", [this] { create_object("Sprite"); }, 58).appearance =
ui::Appearance::Quiet;
auto& tree = scene.add(Kind::Column, "scene-tree"); auto& tree = scene.add(Kind::Column, "scene-tree");
tree.layout.flex = 1; tree.layout.flex = 1;
tree.layout.scroll = true; tree.layout.scroll = true;
@@ -469,26 +499,32 @@ struct EditorUI::Impl {
auto& body = inspector.add(Kind::Column, "properties"); auto& body = inspector.add(Kind::Column, "properties");
body.layout.flex = 1; body.layout.flex = 1;
body.layout.padding = 10; body.layout.padding = 10;
body.layout.gap = 7; body.layout.gap = 6;
body.layout.scroll = true; body.layout.scroll = true;
auto& vp = *ui.find("viewport"); auto& vp = *ui.find("viewport");
auto& vptools = vp.add(Kind::Row, "viewport-tools"); auto& tool_surface = vp.add(Kind::Panel, "viewport-tool-surface");
vptools.layout.absolute = true; tool_surface.layout.absolute = true;
vptools.layout.x = 8; tool_surface.layout.x = 8;
vptools.layout.y = 8; tool_surface.layout.y = 8;
tool_surface.layout.width = 352;
tool_surface.layout.height = 36;
tool_surface.layout.padding = 4;
auto& vptools = tool_surface.add(Kind::Row, "viewport-tools");
vptools.layout.height = 28; vptools.layout.height = 28;
vptools.layout.width = 300; vptools.layout.gap = 4;
for (const std::string mode : {"Move", "Rotate", "Scale"}) for (const std::string mode : {"Move", "Rotate", "Scale"})
button(vptools, "gizmo-" + mode, mode, [this, mode] { gizmo_mode = mode; }, 64); button(vptools, "gizmo-" + mode, mode, [this, mode] { gizmo_mode = mode; }, 64)
.appearance = ui::Appearance::Quiet;
button( button(
vptools, "back-document", "Back", vptools, "back-document", "Back",
[this] { [this] {
if (!last_document.empty()) if (!last_document.empty())
choose_document(last_document); choose_document(last_document);
}, },
54); 54)
vptools.layout.width = 360; .appearance = ui::Appearance::Quiet;
button(vptools, "frame-selection", "Frame", [this] { frame_selection(); }, 64); button(vptools, "frame-selection", "Frame", [this] { frame_selection(); }, 64)
.appearance = ui::Appearance::Quiet;
vp.on_drop = [this](Widget&, const Json& data) { vp.on_drop = [this](Widget&, const Json& data) {
if (data.value("kind", std::string()) == "asset") if (data.value("kind", std::string()) == "asset")
instantiate_asset(data.at("id").get<std::string>()); instantiate_asset(data.at("id").get<std::string>());
@@ -509,12 +545,15 @@ struct EditorUI::Impl {
tab.on_click = [this, id](Widget&) { active_bottom = id; }; tab.on_click = [this, id](Widget&) { active_bottom = id; };
} }
auto& assetbar = bottom.add(Kind::Row, "asset-toolbar"); auto& assetbar = bottom.add(Kind::Row, "asset-toolbar");
assetbar.layout.height = 30; assetbar.layout.height = 32;
assetbar.layout.padding = 2; assetbar.layout.padding = 3;
assetbar.layout.gap = 5; assetbar.layout.gap = 5;
label(assetbar, "asset-path", "Project files", 135); label(assetbar, "asset-path", "Project files", 102);
assetbar.find("asset-path")->enabled = false;
auto& search = assetbar.add(Kind::TextField, "asset-search", ""); auto& search = assetbar.add(Kind::TextField, "asset-search", "");
search.layout.width = 220; search.layout.width = 220;
search.placeholder = "Search project files";
search.tooltip = "Filter files by project-relative path";
search.on_preview = [this](Widget& w) { search.on_preview = [this](Widget& w) {
asset_filter = w.text; asset_filter = w.text;
assets_dirty = true; assets_dirty = true;
@@ -525,8 +564,10 @@ struct EditorUI::Impl {
if (!source_file.empty()) if (!source_file.empty())
call("faset_import", {{"path", source_file}}); call("faset_import", {{"path", source_file}});
}, },
146); 146)
button(assetbar, "asset-open", "Open Scene", [this] { open_source(); }, 104); .appearance = ui::Appearance::Quiet;
button(assetbar, "asset-open", "Open Scene", [this] { open_source(); }, 104)
.appearance = ui::Appearance::Quiet;
button( button(
assetbar, "asset-refresh", "Refresh", assetbar, "asset-refresh", "Refresh",
[this] { [this] {
@@ -534,7 +575,8 @@ struct EditorUI::Impl {
view.clearCache(); view.clearCache();
picks_key.clear(); picks_key.clear();
}, },
75); 75)
.appearance = ui::Appearance::Quiet;
auto& items = bottom.add(Kind::Column, "asset-items"); auto& items = bottom.add(Kind::Column, "asset-items");
items.layout.flex = 1; items.layout.flex = 1;
items.layout.scroll = true; items.layout.scroll = true;
@@ -554,7 +596,13 @@ struct EditorUI::Impl {
auto& statusrow = ui.find("statusbar")->add(Kind::Row, "status-row"); auto& statusrow = ui.find("statusbar")->add(Kind::Row, "status-row");
auto& statuslabel = statusrow.add(Kind::Label, "status", "Ready"); auto& statuslabel = statusrow.add(Kind::Label, "status", "Ready");
statuslabel.layout.flex = 1; statuslabel.layout.flex = 1;
auto& schema_status = statusrow.add(Kind::Label, "schema-status");
schema_status.layout.width = 220;
schema_status.visible = false;
schema_status.enabled = false;
schema_status.tooltip = "Choose Build to refresh gameplay fields in Inspector";
label(statusrow, "renderer-status", "Vulkan", 225); label(statusrow, "renderer-status", "Vulkan", 225);
statusrow.find("renderer-status")->enabled = false;
build_overlays(); build_overlays();
} }
void build_overlays() { void build_overlays() {
@@ -1173,8 +1221,13 @@ struct EditorUI::Impl {
ui.find("undo")->enabled = current.value("can_undo", false); ui.find("undo")->enabled = current.value("can_undo", false);
ui.find("redo")->enabled = current.value("can_redo", false); ui.find("redo")->enabled = current.value("can_redo", false);
ui.find("pause")->enabled = session.playing(); ui.find("pause")->enabled = session.playing();
ui.find("stop")->enabled = session.playing() || session.play_pending();
ui.find("step")->enabled = session.playing(); ui.find("step")->enabled = session.playing();
ui.find("pause")->text = paused ? "Resume" : "Pause"; ui.find("pause")->text = paused ? "Resume" : "Pause";
for (const std::string mode : {"Move", "Rotate", "Scale"})
ui.find("gizmo-" + mode)->selected = gizmo_mode == mode;
for (const std::string name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
ui.find("menu-" + name)->selected = menu == name;
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
// Source fingerprints read complete script bytes; do not hash them every render frame. // Source fingerprints read complete script bytes; do not hash them every render frame.
if (schema_state.is_null() || now - last_schema_poll >= std::chrono::seconds(1)) { if (schema_state.is_null() || now - last_schema_poll >= std::chrono::seconds(1)) {
@@ -1183,17 +1236,27 @@ struct EditorUI::Impl {
} }
if (!schema_state.is_null()) { if (!schema_state.is_null()) {
const bool stale = schema_state.value("stale", false); const bool stale = schema_state.value("stale", false);
ui.find("build")->text = stale ? "Build !" : "Build"; auto& build = *ui.find("build");
ui.find("build")->tooltip = build.text = "Build";
stale ? "Gameplay schema is stale: " + schema_state.value("error", std::string()) const auto schema_error = schema_state.value("error", std::string());
: "Incremental gameplay build and C++/Lua Inspector metadata refresh"; build.tooltip = stale ? "Gameplay metadata is out of date. Build to refresh Inspector "
if (stale && status == "Ready") "fields."
status = "Gameplay schema is stale; Build to refresh Inspector metadata"; : "Build gameplay code and refresh Inspector metadata";
if (stale && !schema_error.empty())
build.tooltip += " Last error: " + schema_error;
ui.find("schema-status")->visible = stale;
ui.find("schema-status")->text = "Gameplay metadata out of date";
} }
ui.find("project-title")->text = session.project().value("name", std::string("Project")) + const auto project_name = session.project().value("name", std::string("Project"));
" / " + current.at("name").get<std::string>() + const auto scene_name = current.at("name").get<std::string>();
const auto scene_path = current.value("path", std::string());
const auto scene_label = scene_path.empty()
? scene_name
: path_to_utf8(path_from_utf8(scene_path).filename());
ui.find("project-title")->text = project_name + " / " + scene_label +
(current.value("dirty", false) ? " *" : ""); (current.value("dirty", false) ? " *" : "");
ui.find("project-title")->tooltip = project_name + " / " + scene_name;
ui.find("status")->text = status; ui.find("status")->text = status;
ui.find("renderer-status")->text = ui.find("renderer-status")->text =
"Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects"; "Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects";
@@ -1207,7 +1270,16 @@ struct EditorUI::Impl {
keep.insert(row.id); keep.insert(row.id);
order[row.id] = sequence++; order[row.id] = sequence++;
}; };
auto& root = tree.add(Kind::TreeRow, "scene-root", current.at("scene").at("name")); const auto full_scene_name = current.at("scene").at("name").get<std::string>();
auto scene_name = full_scene_name;
if (ui.font().measure(scene_name, ui.theme().font_size) >
ui.find("scene_panel")->layout.width - 18) {
const auto path = current.value("path", std::string());
if (!path.empty())
scene_name = path_to_utf8(path_from_utf8(path).filename());
}
auto& root = tree.add(Kind::TreeRow, "scene-root", 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(""); };
touch(root); touch(root);
@@ -1400,6 +1472,11 @@ struct EditorUI::Impl {
void refresh_inspector() { void refresh_inspector() {
auto& body = *ui.find("properties"); auto& body = *ui.find("properties");
std::set<std::string> keep; std::set<std::string> keep;
std::vector<std::string> ordered;
const auto keep_in_order = [&](const std::string& id) {
keep.insert(id);
ordered.push_back(id);
};
const auto* e = entity(resolved, selected); const auto* e = entity(resolved, selected);
if (!instance_selection.empty()) { if (!instance_selection.empty()) {
refresh_instance_inspector(body, keep); refresh_instance_inspector(body, keep);
@@ -1407,29 +1484,36 @@ struct EditorUI::Impl {
return; return;
} }
if (!e) { if (!e) {
label(body, "inspector-empty", "Select an object to edit its components"); label(body, "inspector-empty", "Select an object in Scene to edit it");
body.find("inspector-empty")->enabled = false;
keep.insert("inspector-empty"); keep.insert("inspector-empty");
trim_children(body, keep); trim_children(body, keep);
return; return;
} }
auto& name = body.add(Kind::TextField, "object-name"); auto& identity = body.add(Kind::Row, "object-identity");
keep_in_order(identity.id);
identity.layout.height = 30;
identity.layout.gap = 4;
label(identity, "object-name-label", "Name", 90);
identity.find("object-name-label")->enabled = false;
auto& name = identity.add(Kind::TextField, "object-name");
name.layout.flex = 1;
ui.update_text(name.id, e->at("name")); ui.update_text(name.id, e->at("name"));
const bool source_object = inherited(*e), local_addition = owned_addition(*e); const bool source_object = inherited(*e), local_addition = owned_addition(*e);
name.enabled = !source_object || local_addition; name.enabled = !source_object || local_addition;
if (local_addition) { if (local_addition) {
label(body, "object-origin", "Local addition to this instance"); label(body, "object-origin", "Local addition to this instance");
keep.insert("object-origin"); keep_in_order("object-origin");
} else if (source_object) { } else if (source_object) {
const auto path = e->at("origin").at("path"); const auto path = e->at("origin").at("path");
const auto object = e->at("origin").at("object").get<std::string>(); const auto object = e->at("origin").at("object").get<std::string>();
label(body, "object-origin", label(body, "object-origin",
"Source: " + path_to_utf8(path_from_utf8(instance_source(path)).filename())); "Source: " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
keep.insert("object-origin"); keep_in_order("object-origin");
button(body, "object-open-source", "Open source", button(body, "object-open-source", "Open source",
[this, path, object] { open_template_source(path, object); }); [this, path, object] { open_template_source(path, object); });
keep.insert("object-open-source"); keep_in_order("object-open-source");
} }
keep.insert(name.id);
name.on_preview = [this](Widget&) { name.on_preview = [this](Widget&) {
edit_revisions.try_emplace("object-name", current.at("revision").get<std::uint64_t>()); edit_revisions.try_emplace("object-name", current.at("revision").get<std::uint64_t>());
}; };
@@ -1462,17 +1546,20 @@ struct EditorUI::Impl {
: " (schema missing)")}, : " (schema missing)")},
{"fields", Json::object()}}; {"fields", Json::object()}};
auto& header = body.add(Kind::Row, "component-header-" + cid); auto& header = body.add(Kind::Row, "component-header-" + cid);
keep.insert(header.id); keep_in_order(header.id);
header.layout.height = 28; header.layout.height = 32;
header.appearance = ui::Appearance::Section;
auto& title = auto& title =
header.add(Kind::Label, "component-title-" + cid, metadata.value("name", type)); header.add(Kind::Label, "component-title-" + cid, metadata.value("name", type));
title.layout.flex = 1; title.layout.flex = 1;
title.font_size = 14;
button( button(
header, "component-remove-" + cid, "x", [this, cid] { remove_component(cid); }, 25) header, "component-remove-" + cid, "x", [this, cid] { remove_component(cid); }, 25)
.enabled = !source_object || local_addition; .appearance = ui::Appearance::Quiet;
header.find("component-remove-" + cid)->enabled = !source_object || local_addition;
if (!known) { if (!known) {
label(body, "opaque-note-" + cid, "Schema unavailable. Data is preserved."); label(body, "opaque-note-" + cid, "Schema unavailable. Data is preserved.");
keep.insert("opaque-note-" + cid); keep_in_order("opaque-note-" + cid);
if (has_schema && if (has_schema &&
c.value("version", 1) < c.value("version", 1) <
session.authoring().schemas().schema(type).value("version", 1)) { session.authoring().schemas().schema(type).value("version", 1)) {
@@ -1489,31 +1576,48 @@ struct EditorUI::Impl {
[this, cid] { migrate_component(cid); }); [this, cid] { migrate_component(cid); });
action.tooltip = action.tooltip =
"Apply the schema's declared migration rules. Errors keep all stored data."; "Apply the schema's declared migration rules. Errors keep all stored data.";
keep.insert(action_id); keep_in_order(action_id);
} }
auto& raw = auto& raw =
body.add(Kind::TextField, "opaque-fields-" + cid, c.at("fields").dump()); body.add(Kind::TextField, "opaque-fields-" + cid, c.at("fields").dump());
raw.enabled = false; raw.enabled = false;
keep.insert(raw.id); keep_in_order(raw.id);
button(body, "opaque-copy-" + cid, "Copy raw fields", button(body, "opaque-copy-" + cid, "Copy raw fields",
[this, fields = c.at("fields")] { renderer.set_clipboard(fields.dump(2)); }); [this, fields = c.at("fields")] { renderer.set_clipboard(fields.dump(2)); });
keep.insert("opaque-copy-" + cid); keep_in_order("opaque-copy-" + cid);
continue; continue;
} }
for (const auto& [fid, value] : c.at("fields").items()) { for (const auto& [fid, value] : c.at("fields").items()) {
const auto descriptor = metadata.at("fields").value(fid, Json::object()); const auto descriptor = metadata.at("fields").value(fid, Json::object());
const auto key = "field-" + cid + "-" + fid; const auto key = "field-" + cid + "-" + fid;
auto& row = body.add(Kind::Column, key); auto& row = body.add(Kind::Row, key);
keep.insert(key); keep_in_order(key);
row.layout.gap = 2; const bool radians = descriptor.value("unit", std::string()) == "radians";
label(row, key + "-label",
descriptor.value("name", fid) +
(descriptor.value("unit", std::string()) == "radians" ? " (rad)" : ""));
const auto& options = descriptor.value("enum", Json::array()); const auto& options = descriptor.value("enum", Json::array());
const bool vector_value = value.is_array() && value.size() >= 2 && const bool vector_value = value.is_array() && value.size() >= 2 &&
value.size() <= 4 && value.size() <= 4 &&
std::all_of(value.begin(), value.end(), std::all_of(value.begin(), value.end(),
[](const Json& v) { return v.is_number(); }); [](const Json& v) { return v.is_number(); });
const float label_width = radians ? 108.f : 90.f;
const float field_width = vector_value
? float(value.size()) * 44.f +
float(value.size() - 1) * 3.f
: 72.f;
const float available_width =
ui.find("inspector_panel")->layout.width - 2.f * body.layout.padding;
const bool stacked =
available_width < label_width + 4.f + field_width +
(source_object ? 61.f : 0.f);
row.layout.stack_vertical = stacked;
row.layout.height = stacked ? (source_object ? 72.f : 48.f) : 28.f;
row.layout.gap = stacked ? 2.f : 4.f;
label(row, key + "-label",
descriptor.value("name", fid) + (radians ? " (rad)" : ""),
stacked ? -1.f : label_width);
auto& field_label = *row.find(key + "-label");
field_label.layout.height = stacked ? 18.f : -1.f;
field_label.enabled = false;
field_label.tooltip = descriptor.value("name", fid);
const auto input_id = key + (vector_value ? "-vector" : "-value"); const auto input_id = key + (vector_value ? "-vector" : "-value");
const auto input_kind = !options.empty() ? Kind::Button const auto input_kind = !options.empty() ? Kind::Button
: vector_value ? Kind::Row : vector_value ? Kind::Row
@@ -1537,7 +1641,9 @@ struct EditorUI::Impl {
{"address", address}}})); {"address", address}}}));
}); });
revert.enabled = local_override; revert.enabled = local_override;
revert.layout.height = 23; revert.appearance = ui::Appearance::Quiet;
revert.layout.width = stacked ? -1.f : 57.f;
revert.layout.height = stacked ? 22.f : -1.f;
} }
trim_children(row, source_object ? std::set<std::string>{key + "-label", input_id, trim_children(row, source_object ? std::set<std::string>{key + "-label", input_id,
key + "-revert"} key + "-revert"}
@@ -1554,14 +1660,16 @@ struct EditorUI::Impl {
options.size(); options.size();
set_field(cid, fid, options[index], key + "-value"); set_field(cid, fid, options[index], key + "-value");
}); });
input.layout.height = 27; input.layout.flex = 1;
} else if (value.is_boolean()) { } else if (value.is_boolean()) {
auto& input = row.add(Kind::Checkbox, key + "-value", "Enabled"); auto& input = row.add(Kind::Checkbox, key + "-value", "Enabled");
input.layout.flex = 1;
input.checked = value; input.checked = value;
bind_field(input, cid, fid, value); bind_field(input, cid, fid, value);
} else if (vector_value) { } else if (vector_value) {
auto& vectorrow = row.add(Kind::Row, key + "-vector"); auto& vectorrow = row.add(Kind::Row, key + "-vector");
vectorrow.layout.gap = 3; vectorrow.layout.gap = 3;
vectorrow.layout.flex = 1;
std::set<std::string> axis_ids; std::set<std::string> axis_ids;
for (std::size_t axis = 0; axis < value.size(); ++axis) { for (std::size_t axis = 0; axis < value.size(); ++axis) {
auto& input = auto& input =
@@ -1576,12 +1684,14 @@ struct EditorUI::Impl {
trim_children(vectorrow, axis_ids); trim_children(vectorrow, axis_ids);
} else if (value.is_number()) { } else if (value.is_number()) {
auto& input = row.add(Kind::NumberField, key + "-value"); auto& input = row.add(Kind::NumberField, key + "-value");
input.layout.flex = 1;
input.step = value.is_number_integer() ? 1 : .05; input.step = value.is_number_integer() ? 1 : .05;
input.precision = value.is_number_integer() ? 0 : 3; input.precision = value.is_number_integer() ? 0 : 3;
ui.update_number(input.id, value.get<double>()); ui.update_number(input.id, value.get<double>());
bind_field(input, cid, fid, value); bind_field(input, cid, fid, value);
} else { } else {
auto& input = row.add(Kind::TextField, key + "-value"); auto& input = row.add(Kind::TextField, key + "-value");
input.layout.flex = 1;
ui.update_text(input.id, ui.update_text(input.id,
value.is_string() ? value.get<std::string>() : value.dump()); value.is_string() ? value.get<std::string>() : value.dump());
bind_field(input, cid, fid, value); bind_field(input, cid, fid, value);
@@ -1591,23 +1701,29 @@ struct EditorUI::Impl {
auto& add = button(body, "add-component", "+ Add Component", auto& add = button(body, "add-component", "+ Add Component",
[this] { component_menu = !component_menu; }); [this] { component_menu = !component_menu; });
add.enabled = !source_object || local_addition; add.enabled = !source_object || local_addition;
keep.insert(add.id); keep_in_order(add.id);
if (source_object) { if (source_object) {
const auto path = e->at("origin").at("path"); const auto path = e->at("origin").at("path");
const auto object = e->at("origin").at("object").get<std::string>(); const auto object = e->at("origin").at("object").get<std::string>();
auto& child = button(body, "instance-add-child", "Add local child", auto& child = button(body, "instance-add-child", "Add local child",
[this, path, object] { add_instance_child(path, object); }); [this, path, object] { add_instance_child(path, object); });
child.enabled = path.size() == 1; child.enabled = path.size() == 1;
keep.insert(child.id); keep_in_order(child.id);
} }
if (component_menu && (!source_object || local_addition)) if (component_menu && (!source_object || local_addition))
for (const auto& schema : schemas.at("types")) { for (const auto& schema : schemas.at("types")) {
const auto type = schema.at("id").get<std::string>(); const auto type = schema.at("id").get<std::string>();
auto& choice = button(body, "component-choice-" + type, schema.value("name", type), auto& choice = button(body, "component-choice-" + type, schema.value("name", type),
[this, type] { add_component(type); }); [this, type] { add_component(type); });
keep.insert(choice.id); keep_in_order(choice.id);
} }
trim_children(body, keep); trim_children(body, keep);
std::map<std::string, std::size_t> position;
for (std::size_t i = 0; i < ordered.size(); ++i)
position[ordered[i]] = i;
std::stable_sort(body.children.begin(), body.children.end(), [&](const auto& a, const auto& b) {
return position.at(a->id) < position.at(b->id);
});
} }
void refresh_assets() { void refresh_assets() {
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
@@ -1659,9 +1775,21 @@ struct EditorUI::Impl {
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>();
auto& row = list.add(Kind::TreeRow, "file-" + path, path); const auto file_path = path_from_utf8(path);
auto name = path_to_utf8(file_path.filename());
const auto stem = path_to_utf8(file_path.stem());
if (stem.size() > 24 &&
std::all_of(stem.begin(), stem.end(), [](unsigned char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F');
}))
name = stem.substr(0, 10) + "…" + stem.substr(stem.size() - 6) +
path_to_utf8(file_path.extension());
const auto folder = generic_path_to_utf8(file_path.parent_path());
auto& row = list.add(Kind::TreeRow, "file-" + path, name + " / " + folder);
row.layout.height = 25; row.layout.height = 25;
row.indent = 1; row.indent = 1;
row.tooltip = path;
row.selected = source_file == path; row.selected = source_file == path;
row.on_click = [this, path](Widget&) { row.on_click = [this, path](Widget&) {
source_file = path; source_file = path;
@@ -1681,9 +1809,10 @@ struct EditorUI::Impl {
: state == "stale" ? "Stale" : state == "stale" ? "Stale"
: "Unavailable"; : "Unavailable";
auto& row = auto& row =
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.tooltip = "Asset " + id + " — " + state;
row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}}; row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}};
row.on_click = [this, id, asset, freshness, state](Widget&) { row.on_click = [this, id, asset, freshness, state](Widget&) {
renderer.set_clipboard(id); renderer.set_clipboard(id);
+101 -13
View File
@@ -1,4 +1,5 @@
#include <algorithm> #include <algorithm>
#include <chrono>
#include <cmath> #include <cmath>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/ui/ui.hpp> #include <faset/ui/ui.hpp>
@@ -108,6 +109,7 @@ Layout parse_layout(const Json& j, Layout l = {}) {
l.absolute = j.value("absolute", l.absolute); l.absolute = j.value("absolute", l.absolute);
l.scroll = j.value("scroll", l.scroll); l.scroll = j.value("scroll", l.scroll);
l.clip = j.value("clip", l.clip); l.clip = j.value("clip", l.clip);
l.stack_vertical = j.value("stack_vertical", l.stack_vertical);
if (l.flex < 0 || l.padding < 0 || l.gap < 0 || l.min_width < 0 || l.min_height < 0 || if (l.flex < 0 || l.padding < 0 || l.gap < 0 || l.min_width < 0 || l.min_height < 0 ||
l.max_width < l.min_width || l.max_height < l.min_height) l.max_width < l.min_width || l.max_height < l.min_height)
throw std::runtime_error("Invalid layout constraints"); throw std::runtime_error("Invalid layout constraints");
@@ -243,6 +245,7 @@ struct Context::Impl {
FontAtlas font; FontAtlas font;
float width = 0, height = 0, scale = 1, mouse_x = 0, mouse_y = 0; float width = 0, height = 0, scale = 1, mouse_x = 0, mouse_y = 0;
std::string focused, hovered, captured; std::string focused, hovered, captured;
std::chrono::steady_clock::time_point hover_started = std::chrono::steady_clock::now();
struct Edit { struct Edit {
TextBuffer buffer; TextBuffer buffer;
std::string original, composition; std::string original, composition;
@@ -285,18 +288,19 @@ struct Context::Impl {
return 80 * scale; return 80 * scale;
} }
if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) { if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) {
const bool horizontal = w.kind == Kind::Row && !w.layout.stack_vertical;
float total = 0; float total = 0;
std::size_t count = 0; std::size_t count = 0;
for (auto& child : w.children) for (auto& child : w.children)
if (child->visible && !child->layout.absolute) { if (child->visible && !child->layout.absolute) {
const auto value = intrinsic(*child, false); const auto value = intrinsic(*child, false);
if (w.kind == Kind::Row) if (horizontal)
total = std::max(total, value); total = std::max(total, value);
else else
total += value; total += value;
++count; ++count;
} }
if (w.kind != Kind::Row && count) if (!horizontal && count)
total += float(count - 1) * w.layout.gap * scale; total += float(count - 1) * w.layout.gap * scale;
return total + w.layout.padding * 2 * scale; return total + w.layout.padding * 2 * scale;
} }
@@ -310,7 +314,7 @@ struct Context::Impl {
const auto pad = w.layout.padding * scale; const auto pad = w.layout.padding * scale;
Rect inner{rect.x + pad, rect.y + pad, std::max(0.f, rect.width - 2 * pad), Rect inner{rect.x + pad, rect.y + pad, std::max(0.f, rect.width - 2 * pad),
std::max(0.f, rect.height - 2 * pad)}; std::max(0.f, rect.height - 2 * pad)};
const bool horizontal = w.kind == Kind::Row; const bool horizontal = w.kind == Kind::Row && !w.layout.stack_vertical;
const float available = horizontal ? inner.width : inner.height; const float available = horizontal ? inner.width : inner.height;
std::vector<Widget*> flow; std::vector<Widget*> flow;
float fixed = 0, flex = 0; float fixed = 0, flex = 0;
@@ -557,17 +561,30 @@ struct Context::Impl {
const auto& rect = w.rect; const auto& rect = w.rect;
if (w.kind == Kind::Panel) { if (w.kind == Kind::Panel) {
fill(frame, rect, theme.surface, w.clip); fill(frame, rect, theme.surface, w.clip);
outline(frame, rect, theme.border, w.clip); if (w.layout.absolute)
outline(frame, rect, theme.border, w.clip);
} else if (w.kind == Kind::Button) { } else if (w.kind == Kind::Button) {
fill(frame, rect, if (w.appearance == Appearance::Primary && w.enabled) {
hover && w.enabled ? theme.hover fill(frame, rect, theme.accent, w.clip);
: w.selected ? theme.selection ink = theme.background;
: theme.raised, } else if (w.appearance == Appearance::Quiet) {
w.clip); if (w.selected || (hover && w.enabled))
outline(frame, rect, focus ? theme.accent : theme.border, w.clip); fill(frame, rect, w.selected ? theme.selection : theme.hover, w.clip);
} else {
fill(frame, rect,
hover && w.enabled ? theme.hover
: w.selected ? theme.selection
: theme.raised,
w.clip);
outline(frame, rect, focus ? theme.accent : theme.border, w.clip);
}
if (focus && w.appearance != Appearance::Default)
outline(frame, rect, theme.accent, w.clip);
} else if (w.kind == Kind::Tab || w.kind == Kind::TreeRow) { } else if (w.kind == Kind::Tab || w.kind == Kind::TreeRow) {
if (w.selected || hover) if (w.kind == Kind::TreeRow && (w.selected || hover))
fill(frame, rect, w.selected ? theme.selection : theme.hover, w.clip); fill(frame, rect, w.selected ? theme.selection : theme.hover, w.clip);
else if (w.kind == Kind::Tab && hover && !w.selected)
fill(frame, rect, theme.hover, w.clip);
if (w.selected) { if (w.selected) {
if (w.kind == Kind::Tab) if (w.kind == Kind::Tab)
fill(frame, {rect.x, rect.y + rect.height - 2 * scale, rect.width, 2 * scale}, fill(frame, {rect.x, rect.y + rect.height - 2 * scale, rect.width, 2 * scale},
@@ -575,8 +592,12 @@ struct Context::Impl {
else else
fill(frame, {rect.x, rect.y, 2 * scale, rect.height}, theme.accent, w.clip); fill(frame, {rect.x, rect.y, 2 * scale, rect.height}, theme.accent, w.clip);
} }
if (w.kind == Kind::Tab && !w.selected)
ink = theme.muted;
if (focus) if (focus)
outline(frame, rect, theme.accent, w.clip); outline(frame, rect, theme.accent, w.clip);
} else if (w.kind == Kind::Row && w.appearance == Appearance::Section) {
fill(frame, {rect.x, rect.y, rect.width, scale}, theme.border, w.clip);
} else if (field(w)) { } else if (field(w)) {
fill(frame, rect, theme.background, w.clip); fill(frame, rect, theme.background, w.clip);
outline(frame, rect, outline(frame, rect,
@@ -585,7 +606,7 @@ struct Context::Impl {
: theme.border, : theme.border,
w.clip); w.clip);
} else if (w.kind == Kind::Divider) { } else if (w.kind == Kind::Divider) {
fill(frame, rect, hover || w.id == captured ? theme.accent : theme.background, w.clip); fill(frame, rect, hover || w.id == captured ? theme.border : theme.background, w.clip);
} }
float tx = rect.x + theme.padding * scale + w.indent * 14 * scale; float tx = rect.x + theme.padding * scale + w.indent * 14 * scale;
const auto font_size = (w.font_size > 0 ? w.font_size : theme.font_size) * scale; const auto font_size = (w.font_size > 0 ? w.font_size : theme.font_size) * scale;
@@ -673,7 +694,11 @@ struct Context::Impl {
} }
fill(frame, {tx + caret, rect.y + 5 * scale, scale, rect.height - 10 * scale}, fill(frame, {tx + caret, rect.y + 5 * scale, scale, rect.height - 10 * scale},
theme.accent, text_clip); theme.accent, text_clip);
} else if (w.kind != Kind::Divider && w.kind != Kind::Viewport && !w.text.empty()) } else if (field(w) && w.text.empty() && !w.placeholder.empty())
font.draw(frame, w.placeholder, tx, ty, font_size, theme.muted,
w.clip.intersection(
{rect.x + 2 * scale, rect.y, rect.width - 4 * scale, rect.height}));
else if (w.kind != Kind::Divider && w.kind != Kind::Viewport && !w.text.empty())
font.draw(frame, w.kind == Kind::NumberField ? number(w.value, w.precision) : w.text, font.draw(frame, w.kind == Kind::NumberField ? number(w.value, w.precision) : w.text,
tx, ty, font_size, ink, tx, ty, font_size, ink,
w.clip.intersection( w.clip.intersection(
@@ -805,6 +830,14 @@ void Context::layout(float width, float height, float scale) {
check(root()); check(root());
impl_->arrange(root(), {0, 0, impl_->width, impl_->height}, impl_->arrange(root(), {0, 0, impl_->width, impl_->height},
{0, 0, impl_->width, impl_->height}); {0, 0, impl_->width, impl_->height});
if (!impl_->hovered.empty()) {
const auto* hit = impl_->hit(root(), impl_->mouse_x, impl_->mouse_y);
const auto current_hover = hit ? hit->id : "";
if (current_hover != impl_->hovered) {
impl_->hovered = current_hover;
impl_->hover_started = std::chrono::steady_clock::now();
}
}
std::erase_if(impl_->edits, [&](const auto& entry) { return !ids.contains(entry.first); }); std::erase_if(impl_->edits, [&](const auto& entry) { return !ids.contains(entry.first); });
auto* focused = impl_->find(impl_->focused); auto* focused = impl_->find(impl_->focused);
if (!focused || !focusable(*focused)) if (!focused || !focusable(*focused))
@@ -814,6 +847,58 @@ void Context::layout(float width, float height, float scale) {
} }
void Context::draw(render::Snapshot& snapshot) { void Context::draw(render::Snapshot& snapshot) {
impl_->draw_widget(root(), snapshot); impl_->draw_widget(root(), snapshot);
if (impl_->payload.is_null() && impl_->captured.empty() &&
std::chrono::steady_clock::now() - impl_->hover_started >=
std::chrono::milliseconds(450)) {
const auto* widget = impl_->find(impl_->hovered);
if (widget && widget->visible && !widget->tooltip.empty() && impl_->width > 100) {
const auto font_size = impl_->theme.font_size * impl_->scale;
const auto padding = 8.f * impl_->scale;
const auto max_line_width =
std::max(40.f, std::min(480.f * impl_->scale, impl_->width - 24.f * impl_->scale) -
2.f * padding);
std::vector<std::string> lines;
std::string line;
for (std::size_t i = 0; i < widget->tooltip.size();) {
const auto first = static_cast<unsigned char>(widget->tooltip[i]);
std::size_t bytes = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4;
bytes = std::min(bytes, widget->tooltip.size() - i);
const auto character = widget->tooltip.substr(i, bytes);
i += bytes;
if (character == "\n") {
lines.push_back(line);
line.clear();
continue;
}
if (!line.empty() &&
impl_->font.measure(line + character, font_size) > max_line_width) {
lines.push_back(line);
line.clear();
}
line += character;
}
lines.push_back(line);
float line_width = 0;
for (const auto& value : lines)
line_width = std::max(line_width, impl_->font.measure(value, font_size));
const auto line_height = font_size + 5.f * impl_->scale;
const float width = line_width + 2.f * padding;
const float height = float(lines.size()) * line_height + 2.f * padding;
const Rect viewport{0, 0, impl_->width, impl_->height};
const Rect tooltip{
std::clamp(impl_->mouse_x + 12.f * impl_->scale, 4.f,
std::max(4.f, impl_->width - width - 4.f)),
std::clamp(impl_->mouse_y + 18.f * impl_->scale, 4.f,
std::max(4.f, impl_->height - height - 4.f)),
width, height};
fill(snapshot, tooltip, impl_->theme.raised, viewport);
outline(snapshot, tooltip, impl_->theme.border, viewport);
for (std::size_t i = 0; i < lines.size(); ++i)
impl_->font.draw(snapshot, lines[i], tooltip.x + padding,
tooltip.y + padding + float(i) * line_height, font_size,
impl_->theme.text, viewport);
}
}
if (!impl_->payload.is_null()) { if (!impl_->payload.is_null()) {
const Rect viewport{0, 0, impl_->width, impl_->height}; const Rect viewport{0, 0, impl_->width, impl_->height};
const auto text = const auto text =
@@ -890,6 +975,7 @@ bool Context::handle(const render::Event& event) {
using Type = render::Event::Type; using Type = render::Event::Type;
if (event.type == Type::FocusLost) { if (event.type == Type::FocusLost) {
p.cancel_capture(); p.cancel_capture();
p.hovered.clear();
if (auto found = p.edits.find(p.focused); found != p.edits.end()) if (auto found = p.edits.find(p.focused); found != p.edits.end())
found->second.composition.clear(); found->second.composition.clear();
p.unfocus(true); p.unfocus(true);
@@ -901,6 +987,7 @@ bool Context::handle(const render::Event& event) {
p.mouse_y = event.y; p.mouse_y = event.y;
auto* hit = p.hit(p.root, event.x, event.y); auto* hit = p.hit(p.root, event.x, event.y);
p.hovered = hit ? hit->id : ""; p.hovered = hit ? hit->id : "";
p.hover_started = std::chrono::steady_clock::now();
} }
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);
@@ -911,6 +998,7 @@ bool Context::handle(const render::Event& event) {
std::max(0.f, w->content_height - w->rect.height + std::max(0.f, w->content_height - w->rect.height +
2 * w->layout.padding * p.scale)); 2 * w->layout.padding * p.scale));
layout(p.width, p.height, p.scale); layout(p.width, p.height, p.scale);
p.hover_started = std::chrono::steady_clock::now();
return true; return true;
} }
return false; return false;
+6
View File
@@ -20,6 +20,12 @@ int main() {
"test", "Unicode scene was saved under the wrong filename"); "test", "Unicode scene was saved under the wrong filename");
session.commands().call("faset_document_open", {{"path", "Scenes/Начало 世界.scene.json"}}); session.commands().call("faset_document_open", {{"path", "Scenes/Начало 世界.scene.json"}});
auto& commands = session.commands(); auto& commands = session.commands();
const auto play = commands.call("faset_play", {{"document", document.at("id")}});
require(play.value("play_pending", false) && session.play_pending() && !session.playing(),
"test", "Play must expose its cancellable build phase before Player starts");
commands.call("faset_stop", Json::object());
require(!session.play_pending() && !session.playing(), "test",
"Stop must cancel a pending Play build");
const auto initial = commands.call("faset_project_settings_get", Json::object()); const auto initial = commands.call("faset_project_settings_get", Json::object());
auto changed = commands.call("faset_project_settings_set", auto changed = commands.call("faset_project_settings_set",
{{"revision", initial.at("revision")}, {{"revision", initial.at("revision")},
+13
View File
@@ -244,6 +244,19 @@ int main() {
state = session.authoring().query(ui.current_document()); state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["name"] == "Дверь", "Inspector Unicode rename"); check(state["scene"]["entities"][0]["name"] == "Дверь", "Inspector Unicode rename");
const auto cid = state["scene"]["entities"][0]["components"][0]["id"].get<std::string>(); const auto cid = state["scene"]["entities"][0]["components"][0]["id"].get<std::string>();
ui.widgets().find("inspector_panel")->layout.width = 210;
ui.frame({});
const auto* position_row = ui.widgets().find("field-" + cid + "-position");
const auto* last_axis = ui.widgets().find("field-" + cid + "-position-2");
check(position_row && position_row->layout.stack_vertical,
"Narrow Inspector stacks vector fields");
check(last_axis && last_axis->rect.width >= 35 &&
last_axis->rect.x + last_axis->rect.width <=
ui.widgets().find("properties")->rect.x +
ui.widgets().find("properties")->rect.width,
"Narrow Inspector keeps vector inputs within the panel");
ui.widgets().find("inspector_panel")->layout.width = 312;
ui.frame({});
const auto position = "field-" + cid + "-position-0"; const auto position = "field-" + cid + "-position-0";
text(ui, position, "3.5"); text(ui, position, "3.5");
state = session.authoring().query(ui.current_document()); state = session.authoring().query(ui.current_document());