#include #include #include #include #include #include #include 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)); } void display_scale_contract() { ui::Context context(path_from_utf8(FASET_TEST_FONT)); auto& field = context.root().add(ui::Kind::TextField, "dpi-field", "Ж"); field.layout.width = 180; field.layout.height = 32; auto& number = context.root().add(ui::Kind::NumberField, "dpi-number"); number.layout.height = 32; number.value = 4; number.step = 1; auto& button = context.root().add(ui::Kind::Button, "dpi-action", "Open"); button.layout.width = 180; button.layout.height = 32; int clicks = 0, commits = 0, number_commits = 0; button.on_click = [&](ui::Widget&) { ++clicks; }; field.on_commit = [&](ui::Widget&) { ++commits; }; number.on_commit = [&](ui::Widget&) { ++number_commits; }; ui::Rect ime_area; context.set_ime([](bool) {}, [&](ui::Rect rect) { ime_area = rect; }); context.layout(320, 240, 1); const auto initial = field.rect; render::Snapshot one; context.draw(one); const auto first_glyph = std::find_if(one.ui_quads.begin(), one.ui_quads.end(), [](const auto& q) { return bool(q.texture); }); check(first_glyph != one.ui_quads.end(), "1x rasterized text missing"); const auto glyph_width = first_glyph->width, glyph_height = first_glyph->height; const auto revision = context.font().texture()->revision; check(context.focus("dpi-field"), "DPI field focus"); context.handle(key("A", true)); context.handle(text("Живая сцена")); context.layout(640, 480, 2); check(field.rect.width == initial.width * 2 && field.rect.height == initial.height * 2 && field.clip.width == initial.width * 2 && ime_area.width == field.rect.width, "2x layout, clipping and IME rectangle use drawable pixels"); check(context.focused_id() == "dpi-field" && field.text == "Живая сцена" && commits == 0, "Live display scale preserves focused edit without committing"); context.handle(key("Escape")); render::Snapshot two; context.draw(two); const auto second_glyph = std::find_if(two.ui_quads.begin(), two.ui_quads.end(), [](const auto& q) { return bool(q.texture); }); check(second_glyph != two.ui_quads.end() && second_glyph->width >= glyph_width * 1.8f && second_glyph->height >= glyph_height * 1.8f && context.font().texture()->revision > revision, "2x display requests larger glyph rasterization"); check(std::abs(second_glyph->width - (second_glyph->uv_rect[2] - second_glyph->uv_rect[0]) * second_glyph->texture->width) < .1f, "High-DPI glyphs use one atlas texel per pixel, not stretched 1x bitmaps"); context.handle(mouse(render::Event::Type::MouseDown, button.rect.x + 300, button.rect.y + 40)); context.handle(mouse(render::Event::Type::MouseUp, button.rect.x + 300, button.rect.y + 40)); check(clicks == 1, "Hit testing reaches the second half of a scaled control"); const auto r = number.rect; context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 20)); context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 20)); check(number.value == 14, "Numeric drag uses logical distance at 2x"); context.layout(320, 240, 1); context.handle(mouse(render::Event::Type::MouseUp, 40, 52)); check(number.value == 4 && number_commits == 0, "Monitor scale change cancels pointer drag without an accidental commit"); check(field.rect.width == initial.width && button.layout.width == 180, "Returning to 1x preserves logical layout metrics"); ui::Context scroll(path_from_utf8(FASET_TEST_FONT)); auto& panel = scroll.root().add(ui::Kind::Column, "scroll"); panel.layout.height = 80; panel.layout.scroll = true; panel.layout.gap = 0; for (int i = 0; i < 10; ++i) panel.add(ui::Kind::Button, "row-" + std::to_string(i), "Row").layout.height = 30; scroll.layout(200, 100, 1); panel.scroll_y = 30; scroll.layout(200, 100, 1); scroll.layout(400, 200, 2); check(panel.scroll_y == 60 && panel.children[1]->rect.y == panel.rect.y, "DPI changes retain the logical scroll position"); } } // namespace int main() { try { display_scale_contract(); 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"); const auto missing_font = std::filesystem::temp_directory_path() / path_from_utf8("Недоступный шрифт-" + new_id() + ".ttf"); bool unicode_font_diagnostic = false; try { ui::FontAtlas missing(missing_font); } catch (const std::exception& error) { unicode_font_diagnostic = std::string(error.what()).find(path_to_utf8(missing_font)) != std::string::npos; } check(unicode_font_diagnostic, "Font errors preserve the UTF-8 path"); const auto unicode_font_directory = std::filesystem::temp_directory_path() / path_from_utf8("Faset шрифты-" + new_id()) / std::string(80, 'a') / std::string(80, 'b') / std::string(80, 'c'); std::filesystem::create_directories(unicode_font_directory); const auto unicode_font_path = unicode_font_directory / path_from_utf8("Основной шрифт.ttf"); std::filesystem::copy_file(path_from_utf8(FASET_TEST_FONT), unicode_font_path); { ui::FontAtlas unicode_font(unicode_font_path); check(std::filesystem::remove(unicode_font_path), "Unicode font file must be closed after loading"); check(unicode_font.measure("Живая сцена", 18) > 0, "Shaping uses retained font bytes after Unicode source file is removed"); render::Snapshot glyphs; unicode_font.draw(glyphs, "Живая сцена", 0, 0, 18, {1, 1, 1, 1}, {0, 0, 400, 40}); check(!glyphs.ui_quads.empty() && unicode_font.texture()->revision > 1, "Rasterization remains valid after Unicode font source disappears"); } std::filesystem::remove_all( unicode_font_directory.parent_path().parent_path().parent_path()); ui::Context context(path_from_utf8(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(path_from_utf8(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(path_from_utf8(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({"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(path_from_utf8(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"); const auto initial_height = declarative.find("run")->layout.height; const auto invalid_patch = ui::Json{{"id", "root"}, {"children", ui::Json::array({{{"id", "run"}, {"layout", {{"height", 99}}}}, {{"id", "bad-label"}, {"kind", "label"}, {"text", 123}}})}}; rejected = false; try { declarative.apply_layout(invalid_patch); } catch (...) { rejected = true; } check(rejected && declarative.find("run")->layout.height == initial_height && !declarative.find("bad-label"), "Layout validation must reject all changes before mutation"); rejected = false; try { declarative.apply_layout( {{"id", "root"}, {"children", ui::Json::array({{{"id", "new-parent"}, {"kind", "column"}, {"children", ui::Json::array({{{"id", "run"}}})}}})}}); } catch (...) { rejected = true; } check(rejected && !declarative.find("new-parent"), "Hot layout must not duplicate/reparent a retained widget ID"); 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; } }