diff --git a/apps/project_launcher.cpp b/apps/project_launcher.cpp index da9c80d..2302b82 100644 --- a/apps/project_launcher.cpp +++ b/apps/project_launcher.cpp @@ -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& events) { refresh(); diff --git a/assets/ui/dark.json b/assets/ui/dark.json index 0828d82..8f6f1c9 100644 --- a/assets/ui/dark.json +++ b/assets/ui/dark.json @@ -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, diff --git a/assets/ui/editor-layout.json b/assets/ui/editor-layout.json index 09c8d08..eb4bbc9 100644 --- a/assets/ui/editor-layout.json +++ b/assets/ui/editor-layout.json @@ -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}} ] } } diff --git a/assets/ui/project-launcher-layout.json b/assets/ui/project-launcher-layout.json index 15f9789..896c9a0 100644 --- a/assets/ui/project-launcher-layout.json +++ b/assets/ui/project-launcher-layout.json @@ -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}} ]} ] }] diff --git a/design/references/editor-redesign-2026-09-23.md b/design/references/editor-redesign-2026-09-23.md new file mode 100644 index 0000000..646aeb7 --- /dev/null +++ b/design/references/editor-redesign-2026-09-23.md @@ -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. diff --git a/design/references/editor-redesign-2026-09-23.png b/design/references/editor-redesign-2026-09-23.png new file mode 100644 index 0000000..3148f05 Binary files /dev/null and b/design/references/editor-redesign-2026-09-23.png differ diff --git a/design/references/editor-redesign-implemented.png b/design/references/editor-redesign-implemented.png new file mode 100644 index 0000000..930f969 Binary files /dev/null and b/design/references/editor-redesign-implemented.png differ diff --git a/design/references/project-launcher-redesign-implemented.png b/design/references/project-launcher-redesign-implemented.png new file mode 100644 index 0000000..926555b Binary files /dev/null and b/design/references/project-launcher-redesign-implemented.png differ diff --git a/docs/manual/editor/workspace.md b/docs/manual/editor/workspace.md index 0cf1c22..4ba079d 100644 --- a/docs/manual/editor/workspace.md +++ b/docs/manual/editor/workspace.md @@ -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 diff --git a/include/faset/editor/session.hpp b/include/faset/editor/session.hpp index 16ca103..4577e72 100644 --- a/include/faset/editor/session.hpp +++ b/include/faset/editor/session.hpp @@ -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: diff --git a/include/faset/ui/ui.hpp b/include/faset/ui/ui.hpp index 646911f..fe0d615 100644 --- a/include/faset/ui/ui.hpp +++ b/include/faset/ui/ui.hpp @@ -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 on_click, on_preview, on_commit, on_cancel; diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp index bb2251e..b762cc2 100644 --- a/src/editor/editor_ui.cpp +++ b/src/editor/editor_ui.cpp @@ -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()); @@ -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() + + const auto project_name = session.project().value("name", std::string("Project")); + const auto scene_name = current.at("name").get(); + 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(); + 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 keep; + std::vector 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(); 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()); }; @@ -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{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 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()); 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() : 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(); 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(); 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 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 keep; for (const auto& file : files) { const auto path = file.get(); - 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); diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp index 99e79b1..854c03c 100644 --- a/src/ui/ui.cpp +++ b/src/ui/ui.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -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 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 lines; + std::string line; + for (std::size_t i = 0; i < widget->tooltip.size();) { + const auto first = static_cast(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; diff --git a/tests/editor_session_tests.cpp b/tests/editor_session_tests.cpp index 50738cd..666044f 100644 --- a/tests/editor_session_tests.cpp +++ b/tests/editor_session_tests.cpp @@ -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")}, diff --git a/tests/editor_ui_tests.cpp b/tests/editor_ui_tests.cpp index 3cb7be7..6a9007f 100644 --- a/tests/editor_ui_tests.cpp +++ b/tests/editor_ui_tests.cpp @@ -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(); + 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());