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

This commit is contained in:
Emil
2026-09-23 21:42:48 +03:00
parent 4cc58d8c59
commit e2c887dc1f
15 changed files with 494 additions and 175 deletions
+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;