240 lines
11 KiB
C++
240 lines
11 KiB
C++
#include <chrono>
|
|
#include <faset/ui/ui.hpp>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
using namespace faset;
|
|
namespace {
|
|
void check(bool value, const char* message) {
|
|
if (!value)
|
|
throw std::runtime_error(message);
|
|
}
|
|
render::Event key(std::string name, bool control = false, bool shift = false) {
|
|
render::Event e;
|
|
e.type = render::Event::Type::KeyDown;
|
|
e.key = std::move(name);
|
|
e.control = control;
|
|
e.shift = shift;
|
|
return e;
|
|
}
|
|
render::Event text(std::string value) {
|
|
render::Event e;
|
|
e.type = render::Event::Type::TextInput;
|
|
e.text = std::move(value);
|
|
return e;
|
|
}
|
|
render::Event mouse(render::Event::Type type, float x, float y) {
|
|
render::Event e;
|
|
e.type = type;
|
|
e.x = x;
|
|
e.y = y;
|
|
e.button = 1;
|
|
return e;
|
|
}
|
|
void click(ui::Context& context, const ui::Widget& widget) {
|
|
const auto r = widget.rect;
|
|
context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5));
|
|
context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5));
|
|
}
|
|
} // namespace
|
|
int main() {
|
|
try {
|
|
ui::TextBuffer buffer("Привет");
|
|
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
|
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
|
buffer.select_all();
|
|
buffer.insert("Дверь");
|
|
buffer.left(true);
|
|
check(buffer.selected_text() == "ь", "UTF-8 selection");
|
|
buffer.insert("ца");
|
|
check(buffer.text() == "Дверца", "selection replacement");
|
|
check(!buffer.insert(std::string("\xc0\x80", 2)), "overlong UTF-8 accepted");
|
|
buffer.home();
|
|
buffer.delete_forward();
|
|
check(buffer.text() == "верца", "UTF-8 delete");
|
|
buffer.undo();
|
|
check(buffer.text() == "Дверца", "undo delete");
|
|
ui::Context context(FASET_TEST_FONT);
|
|
auto& root = context.root();
|
|
root.layout.gap = 4;
|
|
auto& name = root.add(ui::Kind::TextField, "name", "Door");
|
|
name.layout.height = 32;
|
|
int commits = 0;
|
|
name.on_commit = [&](ui::Widget&) { ++commits; };
|
|
auto& number = root.add(ui::Kind::NumberField, "number");
|
|
number.value = 4;
|
|
number.step = .5;
|
|
number.layout.height = 32;
|
|
int number_commits = 0, previews = 0;
|
|
number.on_commit = [&](ui::Widget&) { ++number_commits; };
|
|
number.on_preview = [&](ui::Widget&) { ++previews; };
|
|
auto& checkbox = root.add(ui::Kind::Checkbox, "enabled", "Enabled");
|
|
int checks = 0;
|
|
checkbox.on_commit = [&](ui::Widget&) { ++checks; };
|
|
auto& button = root.add(ui::Kind::Button, "save", "Save");
|
|
int clicks = 0;
|
|
button.on_click = [&](ui::Widget&) { ++clicks; };
|
|
context.layout(320, 240);
|
|
std::string clipboard;
|
|
context.set_clipboard([&] { return clipboard; },
|
|
[&](const std::string& s) { clipboard = s; });
|
|
bool ime = false;
|
|
int ime_rectangles = 0;
|
|
context.set_ime([&](bool enabled) { ime = enabled; },
|
|
[&](ui::Rect r) {
|
|
check(r.width > 0, "IME area");
|
|
++ime_rectangles;
|
|
});
|
|
check(context.focus("name") && ime, "field focus enables IME");
|
|
context.handle(key("A", true));
|
|
context.handle(text("Привет"));
|
|
check(!context.update_text("name", "External"), "document refresh erased dirty edit");
|
|
context.handle(key("C", true));
|
|
context.handle(key("A", true));
|
|
context.handle(key("C", true));
|
|
check(clipboard == "Привет", "clipboard copied wrong selection");
|
|
clipboard = "Дверь";
|
|
context.handle(key("V", true));
|
|
check(name.text == "Дверь", "UTF-8 paste");
|
|
context.handle(key("Z", true));
|
|
check(name.text == "Привет", "local Ctrl-Z");
|
|
context.handle(key("Return"));
|
|
check(commits == 1 && name.text == "Привет", "text commits once");
|
|
context.handle(key("Return"));
|
|
check(commits == 1, "unchanged text recommitted");
|
|
context.handle(key("End"));
|
|
render::Event composition;
|
|
composition.type = render::Event::Type::TextEditing;
|
|
composition.text = "й";
|
|
composition.edit_length = 1;
|
|
context.handle(composition);
|
|
check(name.text == "Привет", "IME preedit changed document field");
|
|
context.handle(text("й"));
|
|
context.handle(key("Return"));
|
|
check(name.text == "Приветй" && commits == 2, "IME commit");
|
|
context.focus("number");
|
|
const auto r = number.rect;
|
|
context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 12));
|
|
context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 12));
|
|
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
|
|
context.handle(mouse(render::Event::Type::MouseUp, r.x + 50, r.y + 12));
|
|
check(number.value == 19 && number_commits == 1 && previews == 2,
|
|
"numeric drag must commit once at release");
|
|
context.focus("save");
|
|
check(number_commits == 1, "blur duplicated drag commit");
|
|
context.focus("number");
|
|
context.handle(key("A", true));
|
|
context.handle(text("NaN"));
|
|
context.handle(key("Return"));
|
|
check(number_commits == 1 && !number.error.empty(), "invalid numeric input committed");
|
|
context.handle(key("Escape"));
|
|
check(number.value == 19, "cancel numeric input changed value");
|
|
click(context, checkbox);
|
|
check(checkbox.checked && checks == 1, "checkbox commit");
|
|
click(context, button);
|
|
check(clicks == 1, "button click");
|
|
context.handle(key("Return"));
|
|
check(clicks == 2, "keyboard button activation");
|
|
context.handle(key("Tab"));
|
|
check(context.focused_id() == "name", "focus wraps predictably");
|
|
check(ime_rectangles > 0, "IME rectangle never sent");
|
|
ui::Context split(FASET_TEST_FONT);
|
|
auto& row = split.root().add(ui::Kind::Row, "row");
|
|
row.layout.flex = 1;
|
|
row.layout.gap = 0;
|
|
auto& left = row.add(ui::Kind::Panel, "left");
|
|
left.layout.width = 100;
|
|
left.layout.min_width = 50;
|
|
auto& divider = row.add(ui::Kind::Divider, "divider");
|
|
divider.layout.width = 5;
|
|
auto& right = row.add(ui::Kind::Panel, "right");
|
|
right.layout.flex = 1;
|
|
right.layout.min_width = 50;
|
|
int resize_commit = 0;
|
|
divider.on_commit = [&](ui::Widget&) { ++resize_commit; };
|
|
split.layout(300, 100);
|
|
const auto d = divider.rect;
|
|
split.handle(mouse(render::Event::Type::MouseDown, d.x + 2, 20));
|
|
split.handle(mouse(render::Event::Type::MouseMove, d.x + 32, 20));
|
|
split.handle(mouse(render::Event::Type::MouseUp, d.x + 32, 20));
|
|
check(left.layout.width == 130 && right.rect.width == 165 && resize_commit == 1,
|
|
"divider resize");
|
|
const auto moved_divider = divider.rect;
|
|
split.handle(mouse(render::Event::Type::MouseDown, moved_divider.x + 2, 20));
|
|
split.handle(mouse(render::Event::Type::MouseMove, moved_divider.x + 42, 20));
|
|
split.handle(key("Escape"));
|
|
check(left.layout.width == 130 && resize_commit == 1,
|
|
"Escape must restore divider without committing");
|
|
int cancellations = 0;
|
|
number.on_cancel = [&](ui::Widget&) { ++cancellations; };
|
|
context.handle(mouse(render::Event::Type::MouseDown, r.x + 10, r.y + 12));
|
|
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
|
|
context.handle(key("Escape"));
|
|
check(number.value == 19 && number_commits == 1 && cancellations == 1,
|
|
"Escape must cancel numeric drag");
|
|
ui::Context scrolling(FASET_TEST_FONT);
|
|
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
|
|
list.layout.height = 80;
|
|
list.layout.scroll = true;
|
|
list.layout.gap = 0;
|
|
for (int i = 0; i < 10; ++i)
|
|
list.add(ui::Kind::Label, "row" + std::to_string(i), "Объект " + std::to_string(i));
|
|
scrolling.layout(300, 200);
|
|
scrolling.handle(mouse(render::Event::Type::MouseMove, 30, 30));
|
|
render::Event wheel;
|
|
wheel.type = render::Event::Type::Wheel;
|
|
wheel.y = -2;
|
|
check(scrolling.handle(wheel) && list.scroll_y > 0, "scroll event");
|
|
render::Snapshot frame;
|
|
scrolling.draw(frame);
|
|
check(!frame.ui_quads.empty(), "retained UI emitted no quads");
|
|
for (const auto& q : frame.ui_quads)
|
|
check(q.x >= 0 && q.y >= 0 && q.x + q.width <= 300.01f && q.y + q.height <= 80.01f,
|
|
"CPU clipping escaped scroll panel");
|
|
check(scrolling.font().measure("Привет", 14) > 20, "Cyrillic shaping failed");
|
|
ui::DockLayout docks;
|
|
docks.move("Scene", "left", 0);
|
|
docks.move("Assets", "left", 1);
|
|
docks.move("Assets", "left", 0);
|
|
docks.set_size("Scene", 224);
|
|
check(docks.panels("left") == std::vector<std::string>({"Assets", "Scene"}),
|
|
"dock reordering");
|
|
const auto state = docks.to_json();
|
|
ui::DockLayout restored;
|
|
restored.from_json(state);
|
|
check(restored.to_json() == state, "dock serialization");
|
|
auto invalid = state;
|
|
invalid["areas"]["right"] = {"Scene"};
|
|
bool rejected = false;
|
|
try {
|
|
restored.from_json(invalid);
|
|
} catch (...) {
|
|
rejected = true;
|
|
}
|
|
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
|
|
ui::Context declarative(FASET_TEST_FONT);
|
|
declarative.apply_layout({{"id", "root"},
|
|
{"kind", "column"},
|
|
{"children", ui::Json::array({{{"id", "run"},
|
|
{"kind", "button"},
|
|
{"text", "Play"},
|
|
{"layout", {{"height", 30}}}}})}});
|
|
int action = 0;
|
|
declarative.find("run")->on_click = [&](ui::Widget&) { ++action; };
|
|
declarative.apply_layout({{"id", "root"},
|
|
{"kind", "column"},
|
|
{"children", ui::Json::array({{{"id", "run"},
|
|
{"kind", "button"},
|
|
{"text", "Run"},
|
|
{"layout", {{"height", 34}}}}})}});
|
|
declarative.layout(200, 100);
|
|
click(declarative, *declarative.find("run"));
|
|
check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback");
|
|
std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, "
|
|
"layout, clipping, docking OK\n";
|
|
return 0;
|
|
} catch (const std::exception& error) {
|
|
std::cerr << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|