Refine dark editor and launcher interface
Native and manual checks / native (windows-2025) (push) Waiting to run
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

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 {
namespace fs = std::filesystem;
using ui::Appearance;
using ui::Kind;
using ui::Widget;
fs::path user_home() {
@@ -129,10 +130,18 @@ struct ProjectLauncher::Impl {
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
theme.font_size = 15;
theme.row_height = 30;
theme.font_size = 14;
theme.row_height = 28;
ui.set_theme(theme);
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(); },
[this](const std::string& value) { renderer.set_clipboard(value); });
ui.set_ime(
@@ -145,7 +154,6 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
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-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
@@ -177,7 +185,7 @@ struct ProjectLauncher::Impl {
continue;
const auto id = "launcher-recent-" + std::to_string(i++);
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.on_click = [this, path = selection.path](Widget&) {
ui.update_text("launcher-path", path_to_utf8(path), true);
@@ -187,7 +195,7 @@ struct ProjectLauncher::Impl {
}
}
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() {
ui.clear_focus();
@@ -223,18 +231,22 @@ struct ProjectLauncher::Impl {
void build_browser() {
auto& panel = ui.root().add(Kind::Panel, "launcher-browser");
panel.layout.absolute = true;
panel.layout.padding = 16;
panel.layout.gap = 10;
panel.layout.padding = 20;
panel.layout.gap = 8;
panel.visible = false;
auto& title = panel.add(Kind::Label, "browser-heading", "Choose project directory");
title.font_size = 20;
title.layout.height = 35;
title.layout.height = 44;
auto& nav = panel.add(Kind::Row, "browser-navigation");
nav.layout.height = 36;
button(nav, "browser-up", "Up", [this] {
nav.layout.height = 38;
auto& up = button(nav, "browser-up", "Up", [this] {
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");
path.layout.flex = 1;
path.on_preview = [this](Widget&) { browser_valid = false; };
@@ -250,14 +262,18 @@ struct ProjectLauncher::Impl {
list.layout.scroll = true;
list.layout.gap = 2;
auto& actions = panel.add(Kind::Row, "browser-actions");
actions.layout.height = 36;
button(actions, "browser-choose", "Choose directory", [this] {
actions.layout.height = 42;
auto& choose = button(actions, "browser-choose", "Choose directory", [this] {
if (browser_valid) {
ui.update_text("launcher-path", path_to_utf8(browse_path), true);
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");
}
void browse_to(const fs::path& path) {
@@ -285,14 +301,14 @@ struct ProjectLauncher::Impl {
for (const auto& child : children) {
auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++),
"[Folder] " + path_to_utf8(child.filename()));
row.layout.height = 34;
row.layout.height = 36;
row.on_click = [this, child](Widget&) {
ui.clear_focus();
browse_to(child);
};
}
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&) {
error = "Cannot access this directory.";
} catch (const std::exception& e) {
@@ -335,8 +351,14 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2;
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",
"launcher-dimensions"})
"launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space",
"launcher-dimension-group"})
ui.find(id)->visible = create;
ui.find("launcher-heading")->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.x = (logical_width() - dialog->layout.width) * .5f;
dialog->layout.y = (logical_height() - dialog->layout.height) * .5f;
ui.find("launcher-sidebar")->layout.width =
std::clamp(logical_width() * .24f, 180.f, 235.f);
ui.find("launcher-main")->layout.padding = logical_width() < 850 ? 16 : 32;
const auto sidebar_width = std::clamp(logical_width() * .24f, 180.f, 235.f);
const auto main_padding = logical_width() < 850 ? 20.f : 36.f;
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) {
refresh();
+10 -10
View File
@@ -1,14 +1,14 @@
{
"background": [0.073, 0.078, 0.090, 1],
"surface": [0.102, 0.108, 0.122, 1],
"raised": [0.145, 0.151, 0.169, 1],
"hover": [0.19, 0.197, 0.219, 1],
"border": [0.24, 0.25, 0.278, 1],
"text": [0.88, 0.889, 0.91, 1],
"muted": [0.59, 0.61, 0.65, 1],
"accent": [0.65, 0.60, 0.88, 1],
"selection": [0.26, 0.245, 0.35, 1],
"danger": [0.92, 0.39, 0.38, 1],
"background": [0.078, 0.078, 0.078, 1],
"surface": [0.110, 0.110, 0.114, 1],
"raised": [0.153, 0.153, 0.161, 1],
"hover": [0.196, 0.196, 0.204, 1],
"border": [0.227, 0.227, 0.239, 1],
"text": [0.878, 0.878, 0.886, 1],
"muted": [0.604, 0.604, 0.624, 1],
"accent": [0.659, 0.627, 0.761, 1],
"selection": [0.220, 0.212, 0.251, 1],
"danger": [0.835, 0.431, 0.420, 1],
"font_size": 14,
"row_height": 28,
"padding": 8,
+6 -6
View File
@@ -2,18 +2,18 @@
"root": {
"id": "root", "kind": "column", "layout": {"gap": 1},
"children": [
{"id": "menubar", "kind": "panel", "layout": {"height": 34, "padding": 3}},
{"id": "toolbar", "kind": "panel", "layout": {"height": 40, "padding": 5}},
{"id": "menubar", "kind": "panel", "layout": {"height": 36, "padding": 3}},
{"id": "toolbar", "kind": "panel", "layout": {"height": 44, "padding": 7}},
{"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": "viewport", "kind": "viewport", "layout": {"flex": 1, "min_width": 180}},
{"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_panel", "kind": "panel", "layout": {"height": 184, "min_height": 90, "gap": 0}},
{"id": "statusbar", "kind": "panel", "layout": {"height": 26, "padding": 0}}
{"id": "bottom_panel", "kind": "panel", "layout": {"height": 190, "min_height": 90, "gap": 0}},
{"id": "statusbar", "kind": "panel", "layout": {"height": 25, "padding": 0}}
]
}
}
+45 -25
View File
@@ -5,34 +5,54 @@
"children": [{
"id": "launcher-body", "kind": "row", "layout": {"flex": 1, "gap": 0},
"children": [
{"id": "launcher-sidebar", "kind": "panel", "layout": {"width": 235, "padding": 16, "gap": 8}, "children": [
{"id": "launcher-wordmark", "kind": "label", "text": "Faset", "font_size": 26, "layout": {"height": 66}},
{"id": "launcher-open", "kind": "tree_row", "text": "Open project", "layout": {"height": 40}},
{"id": "launcher-create", "kind": "tree_row", "text": "Create project", "layout": {"height": 40}},
{"id": "launcher-recent-title", "kind": "label", "text": "Recent projects", "layout": {"height": 54}},
{"id": "launcher-recents", "kind": "column", "layout": {"flex": 1, "scroll": true, "gap": 6}}
{"id": "launcher-sidebar", "kind": "panel", "layout": {"width": 235, "padding": 18, "gap": 0}, "children": [
{"id": "launcher-wordmark", "kind": "label", "text": "Faset", "font_size": 24, "layout": {"height": 70}},
{"id": "launcher-nav", "kind": "column", "layout": {"gap": 4}, "children": [
{"id": "launcher-open", "kind": "tree_row", "text": "Open project", "layout": {"height": 38}},
{"id": "launcher-create", "kind": "tree_row", "text": "Create project", "layout": {"height": 38}}
]},
{"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-name-label", "kind": "label", "text": "Project name"},
{"id": "launcher-name", "kind": "text_field", "text": "My Game", "layout": {"height": 40}},
{"id": "launcher-path-label", "kind": "label", "text": "Project directory"},
{"id": "launcher-path-row", "kind": "row", "layout": {"height": 40, "gap": 8}, "children": [
{"id": "launcher-path", "kind": "text_field", "layout": {"flex": 1}},
{"id": "launcher-browse", "kind": "button", "text": "Browse", "layout": {"width": 84}}
{"id": "launcher-heading-rule", "kind": "row", "layout": {"height": 1}},
{"id": "launcher-form", "kind": "column", "layout": {"width": 720, "gap": 0}, "children": [
{"id": "launcher-form-top", "kind": "row", "layout": {"height": 24}},
{"id": "launcher-name-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-name-label", "kind": "label", "text": "Project name", "layout": {"height": 24}},
{"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-dimensions", "kind": "row", "layout": {"height": 40, "gap": 0}, "children": [
{"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"}
{"id": "launcher-main-spacer", "kind": "row", "layout": {"flex": 1}},
{"id": "launcher-shortcuts", "kind": "label", "text": "Tab: next field Ctrl+Enter: continue Escape: cancel", "font_size": 12, "layout": {"height": 24}}
]}
]
}]
@@ -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
**Build C++ !** and the status message indicate stale gameplay metadata, for example
after changing gameplay sources or after a failed build. Choose **Build C++**, then
check **Jobs** and **Console**. A successful build and schema export refresh the
Inspector. A failed build retains the previous metadata and reports the failure.
The status message indicates stale gameplay metadata, for example after changing
gameplay sources or after a failed build. Hover over **Build** for details, then
choose **Build** and check **Jobs** and **Console**. A successful build and schema
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
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),
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
to migrate** instead. Top-level local additions migrate in their owning instance.
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
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
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
+3
View File
@@ -39,6 +39,9 @@ class Session {
bool playing() const {
return bool(player_);
}
bool play_pending() const {
return !pending_play_job_.empty();
}
void log(std::string message);
private:
+10 -6
View File
@@ -18,10 +18,11 @@ struct Rect {
Rect intersection(const Rect&) const noexcept;
};
struct Theme {
Color background{.075f, .078f, .086f, 1}, surface{.10f, .105f, .115f, 1};
Color raised{.135f, .14f, .15f, 1}, hover{.175f, .18f, .20f, 1}, border{.23f, .235f, .255f, 1};
Color text{.86f, .875f, .90f, 1}, muted{.55f, .575f, .62f, 1}, accent{.65f, .60f, .88f, 1};
Color selection{.26f, .245f, .35f, 1}, danger{.92f, .39f, .38f, 1};
Color background{.078f, .078f, .078f, 1}, surface{.110f, .110f, .114f, 1};
Color raised{.153f, .153f, .161f, 1}, hover{.196f, .196f, .204f, 1};
Color border{.227f, .227f, .239f, 1}, text{.878f, .878f, .886f, 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;
static Theme from_json(const Json&);
static Theme load(const std::filesystem::path&);
@@ -103,15 +104,18 @@ enum class Kind {
Divider,
Viewport
};
// Visual emphasis is independent of widget behavior and stable identity.
enum class Appearance { Default, Quiet, Primary, Section };
struct Layout {
float width = -1, height = -1, flex = 0;
float min_width = 0, min_height = 0, max_width = 100000, max_height = 100000;
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;
};
struct Widget {
Kind kind = Kind::Panel;
Appearance appearance = Appearance::Default;
std::string id, text;
Layout layout;
Rect rect, clip;
@@ -120,7 +124,7 @@ struct Widget {
int precision = 3, indent = 0;
float font_size = 0; // Zero inherits the theme typography.
float scroll_y = 0, content_height = 0;
std::string error, tooltip;
std::string error, tooltip, placeholder;
Json drag_payload;
std::string dock_area, dock_panel;
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) {
session.log(std::string("Layout reset: ") + e.what());
}
ui.find("scene_panel")->layout.width = dock.size("scene", 224);
ui.find("inspector_panel")->layout.width = dock.size("inspector", 300);
ui.find("bottom_panel")->layout.height = dock.size("bottom", 184);
ui.find("scene_panel")->layout.width = dock.size("scene", 238);
ui.find("inspector_panel")->layout.width = dock.size("inspector", 312);
ui.find("bottom_panel")->layout.height = dock.size("bottom", 190);
ui.set_clipboard([this] { return renderer.clipboard(); },
[this](const std::string& text) { renderer.set_clipboard(text); });
ui.set_ime([this](bool enabled) { renderer.set_text_input(enabled); },
@@ -404,58 +404,88 @@ struct EditorUI::Impl {
}
void build_static() {
auto& menurow = ui.find("menubar")->add(Kind::Row, "menuitems");
menurow.layout.gap = 2;
for (const std::string name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
button(
menurow.layout.gap = 0;
for (const std::string name : {"Faset", "File", "Edit", "Scene", "View", "Help"}) {
auto& item = button(
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",
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");
toolbar.layout.gap = 5;
button(toolbar, "save", "Save", [this] { save(); }, 60);
button(toolbar, "undo", "Undo", [this] { history(false); }, 56);
button(toolbar, "redo", "Redo", [this] { history(true); }, 56);
toolbar.layout.gap = 3;
auto& file_tools = toolbar.add(Kind::Row, "file-tools");
file_tools.layout.width = 180;
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(
toolbar, "play", "Play", [this] { call("faset_play", {{"document", document}}); }, 58);
button(
toolbar, "stop", "Stop",
[this] {
call("faset_stop");
paused = false;
},
56);
button(
toolbar, "pause", "Pause",
play_tools, "pause", "Pause",
[this] {
if (!call("faset_play_control", {{"command", paused ? "resume" : "pause"}})
.is_null())
paused = !paused;
},
65);
68)
.appearance = ui::Appearance::Quiet;
button(
toolbar, "step", "Step", [this] { call("faset_play_control", {{"command", "step"}}); },
55);
button(toolbar, "build", "Build", [this] { call("faset_build"); }, 94);
play_tools, "stop", "Stop",
[this] {
call("faset_stop");
paused = false;
},
60)
.appearance = ui::Appearance::Quiet;
button(
toolbar, "export", "Export",
[this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 64);
play_tools, "step", "Step",
[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(
toolbar, "simulation-settings", "Simulation",
[this] { simulation_open = !simulation_open; }, 96);
auto& space = toolbar.add(Kind::Label, "toolbar-space", "");
space.layout.flex = 1;
button(toolbar, "command-palette", "Commands", [this] { palette = !palette; }, 104);
build_tools, "export", "Export",
[this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 72)
.appearance = ui::Appearance::Quiet;
button(
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");
scene.add(Kind::Tab, "scene-tab", "Scene").selected = true;
auto& tools = scene.add(Kind::Row, "scene-tools");
tools.layout.height = 28;
tools.layout.height = 32;
tools.layout.padding = 3;
tools.layout.gap = 3;
button(tools, "add-object", "+ Object", [this] { create_object("Object"); }, 80);
button(tools, "add-cube", "Cube", [this] { create_object("Cube"); }, 55);
button(tools, "add-sprite", "Sprite", [this] { create_object("Sprite"); }, 58);
button(tools, "add-object", "+ Object", [this] { create_object("Object"); }, 80)
.appearance = ui::Appearance::Quiet;
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");
tree.layout.flex = 1;
tree.layout.scroll = true;
@@ -469,26 +499,32 @@ struct EditorUI::Impl {
auto& body = inspector.add(Kind::Column, "properties");
body.layout.flex = 1;
body.layout.padding = 10;
body.layout.gap = 7;
body.layout.gap = 6;
body.layout.scroll = true;
auto& vp = *ui.find("viewport");
auto& vptools = vp.add(Kind::Row, "viewport-tools");
vptools.layout.absolute = true;
vptools.layout.x = 8;
vptools.layout.y = 8;
auto& tool_surface = vp.add(Kind::Panel, "viewport-tool-surface");
tool_surface.layout.absolute = true;
tool_surface.layout.x = 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.width = 300;
vptools.layout.gap = 4;
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(
vptools, "back-document", "Back",
[this] {
if (!last_document.empty())
choose_document(last_document);
},
54);
vptools.layout.width = 360;
button(vptools, "frame-selection", "Frame", [this] { frame_selection(); }, 64);
54)
.appearance = ui::Appearance::Quiet;
button(vptools, "frame-selection", "Frame", [this] { frame_selection(); }, 64)
.appearance = ui::Appearance::Quiet;
vp.on_drop = [this](Widget&, const Json& data) {
if (data.value("kind", std::string()) == "asset")
instantiate_asset(data.at("id").get<std::string>());
@@ -509,12 +545,15 @@ struct EditorUI::Impl {
tab.on_click = [this, id](Widget&) { active_bottom = id; };
}
auto& assetbar = bottom.add(Kind::Row, "asset-toolbar");
assetbar.layout.height = 30;
assetbar.layout.padding = 2;
assetbar.layout.height = 32;
assetbar.layout.padding = 3;
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", "");
search.layout.width = 220;
search.placeholder = "Search project files";
search.tooltip = "Filter files by project-relative path";
search.on_preview = [this](Widget& w) {
asset_filter = w.text;
assets_dirty = true;
@@ -525,8 +564,10 @@ struct EditorUI::Impl {
if (!source_file.empty())
call("faset_import", {{"path", source_file}});
},
146);
button(assetbar, "asset-open", "Open Scene", [this] { open_source(); }, 104);
146)
.appearance = ui::Appearance::Quiet;
button(assetbar, "asset-open", "Open Scene", [this] { open_source(); }, 104)
.appearance = ui::Appearance::Quiet;
button(
assetbar, "asset-refresh", "Refresh",
[this] {
@@ -534,7 +575,8 @@ struct EditorUI::Impl {
view.clearCache();
picks_key.clear();
},
75);
75)
.appearance = ui::Appearance::Quiet;
auto& items = bottom.add(Kind::Column, "asset-items");
items.layout.flex = 1;
items.layout.scroll = true;
@@ -554,7 +596,13 @@ struct EditorUI::Impl {
auto& statusrow = ui.find("statusbar")->add(Kind::Row, "status-row");
auto& statuslabel = statusrow.add(Kind::Label, "status", "Ready");
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);
statusrow.find("renderer-status")->enabled = false;
build_overlays();
}
void build_overlays() {
@@ -1173,8 +1221,13 @@ struct EditorUI::Impl {
ui.find("undo")->enabled = current.value("can_undo", false);
ui.find("redo")->enabled = current.value("can_redo", false);
ui.find("pause")->enabled = session.playing();
ui.find("stop")->enabled = session.playing() || session.play_pending();
ui.find("step")->enabled = session.playing();
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();
// 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)) {
@@ -1183,17 +1236,27 @@ struct EditorUI::Impl {
}
if (!schema_state.is_null()) {
const bool stale = schema_state.value("stale", false);
ui.find("build")->text = stale ? "Build !" : "Build";
ui.find("build")->tooltip =
stale ? "Gameplay schema is stale: " + schema_state.value("error", std::string())
: "Incremental gameplay build and C++/Lua Inspector metadata refresh";
if (stale && status == "Ready")
status = "Gameplay schema is stale; Build to refresh Inspector metadata";
auto& build = *ui.find("build");
build.text = "Build";
const auto schema_error = schema_state.value("error", std::string());
build.tooltip = stale ? "Gameplay metadata is out of date. Build to refresh Inspector "
"fields."
: "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")) +
" / " + current.at("name").get<std::string>() +
const auto project_name = session.project().value("name", std::string("Project"));
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) ? " *" : "");
ui.find("project-title")->tooltip = project_name + " / " + scene_name;
ui.find("status")->text = status;
ui.find("renderer-status")->text =
"Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects";
@@ -1207,7 +1270,16 @@ struct EditorUI::Impl {
keep.insert(row.id);
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.on_click = [this](Widget&) { select(""); };
touch(root);
@@ -1400,6 +1472,11 @@ struct EditorUI::Impl {
void refresh_inspector() {
auto& body = *ui.find("properties");
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);
if (!instance_selection.empty()) {
refresh_instance_inspector(body, keep);
@@ -1407,29 +1484,36 @@ struct EditorUI::Impl {
return;
}
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");
trim_children(body, keep);
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"));
const bool source_object = inherited(*e), local_addition = owned_addition(*e);
name.enabled = !source_object || local_addition;
if (local_addition) {
label(body, "object-origin", "Local addition to this instance");
keep.insert("object-origin");
keep_in_order("object-origin");
} else if (source_object) {
const auto path = e->at("origin").at("path");
const auto object = e->at("origin").at("object").get<std::string>();
label(body, "object-origin",
"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",
[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&) {
edit_revisions.try_emplace("object-name", current.at("revision").get<std::uint64_t>());
};
@@ -1462,17 +1546,20 @@ struct EditorUI::Impl {
: " (schema missing)")},
{"fields", Json::object()}};
auto& header = body.add(Kind::Row, "component-header-" + cid);
keep.insert(header.id);
header.layout.height = 28;
keep_in_order(header.id);
header.layout.height = 32;
header.appearance = ui::Appearance::Section;
auto& title =
header.add(Kind::Label, "component-title-" + cid, metadata.value("name", type));
title.layout.flex = 1;
title.font_size = 14;
button(
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) {
label(body, "opaque-note-" + cid, "Schema unavailable. Data is preserved.");
keep.insert("opaque-note-" + cid);
keep_in_order("opaque-note-" + cid);
if (has_schema &&
c.value("version", 1) <
session.authoring().schemas().schema(type).value("version", 1)) {
@@ -1489,31 +1576,48 @@ struct EditorUI::Impl {
[this, cid] { migrate_component(cid); });
action.tooltip =
"Apply the schema's declared migration rules. Errors keep all stored data.";
keep.insert(action_id);
keep_in_order(action_id);
}
auto& raw =
body.add(Kind::TextField, "opaque-fields-" + cid, c.at("fields").dump());
raw.enabled = false;
keep.insert(raw.id);
keep_in_order(raw.id);
button(body, "opaque-copy-" + cid, "Copy raw fields",
[this, fields = c.at("fields")] { renderer.set_clipboard(fields.dump(2)); });
keep.insert("opaque-copy-" + cid);
keep_in_order("opaque-copy-" + cid);
continue;
}
for (const auto& [fid, value] : c.at("fields").items()) {
const auto descriptor = metadata.at("fields").value(fid, Json::object());
const auto key = "field-" + cid + "-" + fid;
auto& row = body.add(Kind::Column, key);
keep.insert(key);
row.layout.gap = 2;
label(row, key + "-label",
descriptor.value("name", fid) +
(descriptor.value("unit", std::string()) == "radians" ? " (rad)" : ""));
auto& row = body.add(Kind::Row, key);
keep_in_order(key);
const bool radians = descriptor.value("unit", std::string()) == "radians";
const auto& options = descriptor.value("enum", Json::array());
const bool vector_value = value.is_array() && value.size() >= 2 &&
value.size() <= 4 &&
std::all_of(value.begin(), value.end(),
[](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_kind = !options.empty() ? Kind::Button
: vector_value ? Kind::Row
@@ -1537,7 +1641,9 @@ struct EditorUI::Impl {
{"address", address}}}));
});
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,
key + "-revert"}
@@ -1554,14 +1660,16 @@ struct EditorUI::Impl {
options.size();
set_field(cid, fid, options[index], key + "-value");
});
input.layout.height = 27;
input.layout.flex = 1;
} else if (value.is_boolean()) {
auto& input = row.add(Kind::Checkbox, key + "-value", "Enabled");
input.layout.flex = 1;
input.checked = value;
bind_field(input, cid, fid, value);
} else if (vector_value) {
auto& vectorrow = row.add(Kind::Row, key + "-vector");
vectorrow.layout.gap = 3;
vectorrow.layout.flex = 1;
std::set<std::string> axis_ids;
for (std::size_t axis = 0; axis < value.size(); ++axis) {
auto& input =
@@ -1576,12 +1684,14 @@ struct EditorUI::Impl {
trim_children(vectorrow, axis_ids);
} else if (value.is_number()) {
auto& input = row.add(Kind::NumberField, key + "-value");
input.layout.flex = 1;
input.step = value.is_number_integer() ? 1 : .05;
input.precision = value.is_number_integer() ? 0 : 3;
ui.update_number(input.id, value.get<double>());
bind_field(input, cid, fid, value);
} else {
auto& input = row.add(Kind::TextField, key + "-value");
input.layout.flex = 1;
ui.update_text(input.id,
value.is_string() ? value.get<std::string>() : value.dump());
bind_field(input, cid, fid, value);
@@ -1591,23 +1701,29 @@ struct EditorUI::Impl {
auto& add = button(body, "add-component", "+ Add Component",
[this] { component_menu = !component_menu; });
add.enabled = !source_object || local_addition;
keep.insert(add.id);
keep_in_order(add.id);
if (source_object) {
const auto path = e->at("origin").at("path");
const auto object = e->at("origin").at("object").get<std::string>();
auto& child = button(body, "instance-add-child", "Add local child",
[this, path, object] { add_instance_child(path, object); });
child.enabled = path.size() == 1;
keep.insert(child.id);
keep_in_order(child.id);
}
if (component_menu && (!source_object || local_addition))
for (const auto& schema : schemas.at("types")) {
const auto type = schema.at("id").get<std::string>();
auto& choice = button(body, "component-choice-" + type, schema.value("name", type),
[this, type] { add_component(type); });
keep.insert(choice.id);
keep_in_order(choice.id);
}
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() {
const auto now = std::chrono::steady_clock::now();
@@ -1659,9 +1775,21 @@ struct EditorUI::Impl {
std::set<std::string> keep;
for (const auto& file : files) {
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.indent = 1;
row.tooltip = path;
row.selected = source_file == path;
row.on_click = [this, path](Widget&) {
source_file = path;
@@ -1681,9 +1809,10 @@ struct EditorUI::Impl {
: state == "stale" ? "Stale"
: "Unavailable";
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.indent = 1;
row.tooltip = "Asset " + id + "" + state;
row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}};
row.on_click = [this, id, asset, freshness, state](Widget&) {
renderer.set_clipboard(id);
+101 -13
View File
@@ -1,4 +1,5 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <faset/core/io.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.scroll = j.value("scroll", l.scroll);
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 ||
l.max_width < l.min_width || l.max_height < l.min_height)
throw std::runtime_error("Invalid layout constraints");
@@ -243,6 +245,7 @@ struct Context::Impl {
FontAtlas font;
float width = 0, height = 0, scale = 1, mouse_x = 0, mouse_y = 0;
std::string focused, hovered, captured;
std::chrono::steady_clock::time_point hover_started = std::chrono::steady_clock::now();
struct Edit {
TextBuffer buffer;
std::string original, composition;
@@ -285,18 +288,19 @@ struct Context::Impl {
return 80 * scale;
}
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;
std::size_t count = 0;
for (auto& child : w.children)
if (child->visible && !child->layout.absolute) {
const auto value = intrinsic(*child, false);
if (w.kind == Kind::Row)
if (horizontal)
total = std::max(total, value);
else
total += value;
++count;
}
if (w.kind != Kind::Row && count)
if (!horizontal && count)
total += float(count - 1) * w.layout.gap * scale;
return total + w.layout.padding * 2 * scale;
}
@@ -310,7 +314,7 @@ struct Context::Impl {
const auto pad = w.layout.padding * scale;
Rect inner{rect.x + pad, rect.y + pad, std::max(0.f, rect.width - 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;
std::vector<Widget*> flow;
float fixed = 0, flex = 0;
@@ -557,17 +561,30 @@ struct Context::Impl {
const auto& rect = w.rect;
if (w.kind == Kind::Panel) {
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) {
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 (w.appearance == Appearance::Primary && w.enabled) {
fill(frame, rect, theme.accent, w.clip);
ink = theme.background;
} else if (w.appearance == Appearance::Quiet) {
if (w.selected || (hover && w.enabled))
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) {
if (w.selected || hover)
if (w.kind == Kind::TreeRow && (w.selected || hover))
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.kind == Kind::Tab)
fill(frame, {rect.x, rect.y + rect.height - 2 * scale, rect.width, 2 * scale},
@@ -575,8 +592,12 @@ struct Context::Impl {
else
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)
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)) {
fill(frame, rect, theme.background, w.clip);
outline(frame, rect,
@@ -585,7 +606,7 @@ struct Context::Impl {
: theme.border,
w.clip);
} 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;
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},
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,
tx, ty, font_size, ink,
w.clip.intersection(
@@ -805,6 +830,14 @@ void Context::layout(float width, float height, float scale) {
check(root());
impl_->arrange(root(), {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); });
auto* focused = impl_->find(impl_->focused);
if (!focused || !focusable(*focused))
@@ -814,6 +847,58 @@ void Context::layout(float width, float height, float scale) {
}
void Context::draw(render::Snapshot& 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()) {
const Rect viewport{0, 0, impl_->width, impl_->height};
const auto text =
@@ -890,6 +975,7 @@ bool Context::handle(const render::Event& event) {
using Type = render::Event::Type;
if (event.type == Type::FocusLost) {
p.cancel_capture();
p.hovered.clear();
if (auto found = p.edits.find(p.focused); found != p.edits.end())
found->second.composition.clear();
p.unfocus(true);
@@ -901,6 +987,7 @@ bool Context::handle(const render::Event& event) {
p.mouse_y = event.y;
auto* hit = p.hit(p.root, event.x, event.y);
p.hovered = hit ? hit->id : "";
p.hover_started = std::chrono::steady_clock::now();
}
if (event.type == Type::Wheel) {
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 +
2 * w->layout.padding * p.scale));
layout(p.width, p.height, p.scale);
p.hover_started = std::chrono::steady_clock::now();
return true;
}
return false;
+6
View File
@@ -20,6 +20,12 @@ int main() {
"test", "Unicode scene was saved under the wrong filename");
session.commands().call("faset_document_open", {{"path", "Scenes/Начало 世界.scene.json"}});
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());
auto changed = commands.call("faset_project_settings_set",
{{"revision", initial.at("revision")},
+13
View File
@@ -244,6 +244,19 @@ int main() {
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["name"] == "Дверь", "Inspector Unicode rename");
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";
text(ui, position, "3.5");
state = session.authoring().query(ui.current_document());