#include #include #include #include #include #include #include #include namespace faset::ui { bool Rect::contains(float px, float py) const noexcept { return px >= x && py >= y && px < x + width && py < y + height; } Rect Rect::intersection(const Rect& b) const noexcept { const auto left = std::max(x, b.x), top = std::max(y, b.y); return {left, top, std::max(0.f, std::min(x + width, b.x + b.width) - left), std::max(0.f, std::min(y + height, b.y + b.height) - top)}; } namespace { Color color(const Json& j, const char* key, Color fallback) { if (!j.contains(key)) return fallback; auto value = j.at(key).get(); for (float c : value) if (!std::isfinite(c) || c < 0 || c > 1) throw std::runtime_error("Theme colors require normalized RGBA"); return value; } float positive(const Json& j, const char* key, float fallback) { float value = j.value(key, fallback); if (!std::isfinite(value) || value <= 0 || value > 256) throw std::runtime_error("Invalid theme metric"); return value; } std::string number(double value, int precision) { std::ostringstream out; out << std::fixed << std::setprecision(std::clamp(precision, 0, 9)) << value; return out.str(); } bool field(const Widget& w) { return w.kind == Kind::TextField || w.kind == Kind::NumberField; } bool focusable(const Widget& w) { for (auto* parent = w.parent; parent; parent = parent->parent) if (!parent->visible || !parent->enabled) return false; return w.visible && w.enabled && (field(w) || w.kind == Kind::Button || w.kind == Kind::Checkbox || w.kind == Kind::Tab || w.kind == Kind::TreeRow); } void collect_focus(Widget& w, std::vector& ids) { if (!w.visible) return; if (focusable(w)) ids.push_back(w.id); for (auto& child : w.children) collect_focus(*child, ids); } void fill(render::Snapshot& frame, Rect rect, Color color, const Rect& clip) { rect = rect.intersection(clip); if (rect.width > 0 && rect.height > 0) frame.ui_quads.push_back( {rect.x, rect.y, rect.width, rect.height, color, {}, {0, 0, 1, 1}}); } void outline(render::Snapshot& f, const Rect& r, Color c, const Rect& clip, float thickness = 1) { fill(f, {r.x, r.y, r.width, thickness}, c, clip); fill(f, {r.x, r.y + r.height - thickness, r.width, thickness}, c, clip); fill(f, {r.x, r.y, thickness, r.height}, c, clip); fill(f, {r.x + r.width - thickness, r.y, thickness, r.height}, c, clip); } Kind kind_from_string(const std::string& name) { static const std::unordered_map kinds = {{"panel", Kind::Panel}, {"row", Kind::Row}, {"column", Kind::Column}, {"label", Kind::Label}, {"button", Kind::Button}, {"tab", Kind::Tab}, {"tree_row", Kind::TreeRow}, {"text_field", Kind::TextField}, {"number_field", Kind::NumberField}, {"checkbox", Kind::Checkbox}, {"divider", Kind::Divider}, {"viewport", Kind::Viewport}}; auto found = kinds.find(name); if (found == kinds.end()) throw std::runtime_error("Unknown widget kind: " + name); return found->second; } Layout parse_layout(const Json& j, Layout l = {}) { if (!j.is_object()) throw std::runtime_error("Layout must be an object"); #define UI_FLOAT(name) \ l.name = j.value(#name, l.name); \ if (!std::isfinite(l.name)) \ throw std::runtime_error("Non-finite layout metric: " #name) UI_FLOAT(width); UI_FLOAT(height); UI_FLOAT(flex); UI_FLOAT(min_width); UI_FLOAT(min_height); UI_FLOAT(max_width); UI_FLOAT(max_height); UI_FLOAT(padding); UI_FLOAT(gap); UI_FLOAT(x); UI_FLOAT(y); #undef UI_FLOAT l.absolute = j.value("absolute", l.absolute); l.scroll = j.value("scroll", l.scroll); l.clip = j.value("clip", l.clip); 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"); return l; } } // namespace Theme Theme::from_json(const Json& j) { if (!j.is_object()) throw std::runtime_error("Theme must be an object"); Theme t; #define UI_COLOR(name) t.name = color(j, #name, t.name) UI_COLOR(background); UI_COLOR(surface); UI_COLOR(raised); UI_COLOR(hover); UI_COLOR(border); UI_COLOR(text); UI_COLOR(muted); UI_COLOR(accent); UI_COLOR(selection); UI_COLOR(danger); #undef UI_COLOR t.font_size = positive(j, "font_size", t.font_size); t.row_height = positive(j, "row_height", t.row_height); t.padding = positive(j, "padding", t.padding); t.gap = positive(j, "gap", t.gap); return t; } Theme Theme::load(const std::filesystem::path& path) { return from_json(faset::read_json(path)); } Json Theme::to_json() const { return {{"background", background}, {"surface", surface}, {"raised", raised}, {"hover", hover}, {"border", border}, {"text", text}, {"muted", muted}, {"accent", accent}, {"selection", selection}, {"danger", danger}, {"font_size", font_size}, {"row_height", row_height}, {"padding", padding}, {"gap", gap}}; } Widget& Widget::add(Kind type, const std::string& stable_id, const std::string& label) { for (auto& child : children) if (child->id == stable_id) { if (child->kind != type) throw std::runtime_error("Widget ID reused with different kind: " + stable_id); if (!label.empty()) child->text = label; return *child; } auto child = std::make_unique(); child->kind = type; child->id = stable_id; child->text = label; child->parent = this; children.push_back(std::move(child)); return *children.back(); } Widget* Widget::find(std::string_view wanted) { if (id == wanted) return this; for (auto& child : children) if (auto* result = child->find(wanted)) return result; return nullptr; } const Widget* Widget::find(std::string_view wanted) const { if (id == wanted) return this; for (const auto& child : children) if (auto* result = child->find(wanted)) return result; return nullptr; } void Widget::remove(std::string_view wanted) { std::erase_if(children, [&](const auto& child) { return child->id == wanted; }); } void DockLayout::move(const std::string& panel, const std::string& area, std::size_t index) { if (panel.empty() || area.empty()) throw std::runtime_error("Dock IDs cannot be empty"); for (auto& [name, panels] : areas_) { (void)name; std::erase(panels, panel); } auto& panels = areas_[area]; panels.insert(panels.begin() + static_cast(std::min(index, panels.size())), panel); } std::vector DockLayout::panels(const std::string& area) const { auto found = areas_.find(area); return found == areas_.end() ? std::vector{} : found->second; } void DockLayout::set_size(const std::string& panel, float size) { if (!std::isfinite(size) || size < 24 || size > 10000) throw std::runtime_error("Invalid dock panel size"); sizes_[panel] = size; } float DockLayout::size(const std::string& panel, float fallback) const { auto found = sizes_.find(panel); return found == sizes_.end() ? fallback : found->second; } Json DockLayout::to_json() const { return {{"version", 1}, {"areas", areas_}, {"sizes", sizes_}}; } void DockLayout::from_json(const Json& j) { if (j.at("version") != 1) throw std::runtime_error("Unsupported dock layout version"); auto areas = j.at("areas").get(); auto sizes = j.at("sizes").get(); std::set seen; for (const auto& [area, panels] : areas) { if (area.empty()) throw std::runtime_error("Empty dock area"); for (const auto& panel : panels) if (panel.empty() || !seen.insert(panel).second) throw std::runtime_error("Duplicate dock panel"); } for (const auto& [id, size] : sizes) if (id.empty() || !std::isfinite(size) || size < 24 || size > 10000) throw std::runtime_error("Invalid dock size"); areas_ = std::move(areas); sizes_ = std::move(sizes); } void DockLayout::save(const std::filesystem::path& path) const { faset::atomic_write_json(path, to_json()); } void DockLayout::load(const std::filesystem::path& path) { from_json(faset::read_json(path)); } struct Context::Impl { Widget root; Theme theme; FontAtlas font; float width = 0, height = 0, scale = 1, mouse_x = 0, mouse_y = 0; std::string focused, hovered, captured; struct Edit { TextBuffer buffer; std::string original, composition; double original_value = 0; float scroll = 0; int composition_start = 0, composition_length = 0; }; std::unordered_map edits; float down_x = 0, down_y = 0; double down_value = 0; bool dragging = false; Json payload; float before_size = 0, after_size = 0; Layout before_layout, after_layout; std::function clipboard_read; std::function clipboard_write; std::function ime_enabled; std::function ime_rectangle; DockLayout* docking = nullptr; std::function docking_changed; explicit Impl(const std::filesystem::path& file) : font(file) { root.kind = Kind::Column; root.id = "root"; root.layout.gap = 0; } Widget* find(std::string_view id) { return root.find(id); } float intrinsic(Widget& w, bool horizontal) { const auto fixed = horizontal ? w.layout.width : w.layout.height; if (fixed >= 0) return fixed * scale; if (w.kind == Kind::Divider) return 5 * scale; if (horizontal) { if (w.kind == Kind::Label || w.kind == Kind::Button || w.kind == Kind::Tab) return font.measure(w.text, (w.font_size > 0 ? w.font_size : theme.font_size) * scale) + theme.padding * 2 * scale; return 80 * scale; } if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) { 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) total = std::max(total, value); else total += value; ++count; } if (w.kind != Kind::Row && count) total += float(count - 1) * w.layout.gap * scale; return total + w.layout.padding * 2 * scale; } return theme.row_height * scale; } void arrange(Widget& w, Rect rect, Rect clip) { w.rect = rect; w.clip = w.layout.clip ? clip.intersection(rect) : clip; if (!w.visible) return; 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 float available = horizontal ? inner.width : inner.height; std::vector flow; float fixed = 0, flex = 0; for (auto& child : w.children) if (child->visible && !child->layout.absolute) { flow.push_back(child.get()); if (child->layout.flex > 0) { flex += child->layout.flex; fixed += (horizontal ? child->layout.min_width : child->layout.min_height) * scale; } else fixed += intrinsic(*child, horizontal); } if (!flow.empty()) fixed += float(flow.size() - 1) * w.layout.gap * scale; const auto extra = std::max(0.f, available - fixed); std::vector extents; float content = 0; for (auto* child : flow) { auto extent = child->layout.flex > 0 ? (horizontal ? child->layout.min_width : child->layout.min_height) * scale + extra * child->layout.flex / std::max(.001f, flex) : intrinsic(*child, horizontal); extent = std::clamp( extent, (horizontal ? child->layout.min_width : child->layout.min_height) * scale, (horizontal ? child->layout.max_width : child->layout.max_height) * scale); extents.push_back(extent); content += extent; } if (!flow.empty()) content += float(flow.size() - 1) * w.layout.gap * scale; w.content_height = horizontal ? inner.height : content; w.scroll_y = std::clamp(w.scroll_y, 0.f, std::max(0.f, w.content_height - inner.height)); float position = horizontal ? inner.x : inner.y - (w.layout.scroll ? w.scroll_y : 0); for (std::size_t i = 0; i < flow.size(); ++i) { auto& child = *flow[i]; float cross = horizontal ? (child.layout.height >= 0 ? child.layout.height * scale : inner.height) : (child.layout.width >= 0 ? child.layout.width * scale : inner.width); cross = std::clamp( cross, (horizontal ? child.layout.min_height : child.layout.min_width) * scale, (horizontal ? child.layout.max_height : child.layout.max_width) * scale); Rect target = horizontal ? Rect{position, inner.y, extents[i], cross} : Rect{inner.x, position, cross, extents[i]}; arrange(child, target, w.clip.intersection(inner)); position += extents[i] + w.layout.gap * scale; } for (auto& child : w.children) if (child->visible && child->layout.absolute) arrange(*child, {inner.x + child->layout.x * scale, inner.y + child->layout.y * scale, child->layout.width >= 0 ? child->layout.width * scale : inner.width, child->layout.height >= 0 ? child->layout.height * scale : intrinsic(*child, false)}, w.clip.intersection(inner)); } Widget* hit(Widget& w, float x, float y) { if (!w.visible || !w.clip.contains(x, y)) return nullptr; if (!w.enabled) return w.rect.contains(x, y) ? &w : nullptr; for (auto child = w.children.rbegin(); child != w.children.rend(); ++child) if (auto* target = hit(**child, x, y)) return target; return w.rect.contains(x, y) ? &w : nullptr; } bool commit() { if (focused.empty()) return true; auto* widget = find(focused); auto found = edits.find(focused); if (!widget || !field(*widget) || found == edits.end()) return true; auto& edit = found->second; if (!edit.composition.empty()) return false; const bool changed = edit.buffer.text() != edit.original; if (widget->kind == Kind::NumberField) { try { std::size_t end = 0; const auto value = std::stod(edit.buffer.text(), &end); if (end != edit.buffer.text().size() || !std::isfinite(value)) throw std::runtime_error("number"); widget->value = value; } catch (...) { widget->error = "Enter a finite number"; return false; } } widget->error.clear(); widget->text = edit.buffer.text(); edit.original = edit.buffer.text(); edit.original_value = widget->value; const auto cursor = edit.buffer.cursor(); edit.buffer.reset(edit.original); edit.buffer.set_cursor(cursor); auto callback = widget->on_commit; if (changed && callback) callback(*widget); return true; } void unfocus(bool save) { if (save && !commit()) return; focused.clear(); if (ime_enabled) ime_enabled(false); } void reveal(Widget& widget) { // Reveal inside-out: an offscreen nested scroller still needs its own // contents positioned before an outer scroller brings it into view. auto* branch = &widget; for (auto* parent = widget.parent; parent; branch = parent, parent = parent->parent) { if (!parent->layout.scroll || parent->kind == Kind::Row || branch->layout.absolute) continue; const float padding = parent->layout.padding * scale; const float top = parent->rect.y + padding; const float extent = std::max(0.f, parent->rect.height - 2 * padding); if (extent <= 0) continue; float delta = 0; if (widget.rect.y < top || widget.rect.height > extent) delta = widget.rect.y - top; else if (widget.rect.y + widget.rect.height > top + extent) delta = widget.rect.y + widget.rect.height - top - extent; const float scroll = std::clamp(parent->scroll_y + delta, 0.f, std::max(0.f, parent->content_height - extent)); if (scroll != parent->scroll_y) { parent->scroll_y = scroll; arrange(root, {0, 0, width, height}, {0, 0, width, height}); } } } bool focus(const std::string& id) { auto* widget = find(id); if (!widget || !focusable(*widget)) return false; if (focused == id) { reveal(*widget); if (field(*widget) && ime_rectangle) ime_rectangle(widget->rect); return true; } if (!commit()) return false; // A commit callback may reconcile the retained tree. widget = find(id); if (!widget || !focusable(*widget)) return false; focused = id; reveal(*widget); if (field(*widget)) { auto& edit = edits[id]; const auto text = widget->kind == Kind::NumberField ? number(widget->value, widget->precision) : widget->text; edit.buffer.reset(text); edit.original = text; edit.original_value = widget->value; edit.composition.clear(); edit.scroll = 0; if (widget->kind == Kind::NumberField) edit.buffer.select_all(); if (ime_enabled) ime_enabled(true); if (ime_rectangle) ime_rectangle(widget->rect); } else if (ime_enabled) ime_enabled(false); return true; } void preview() { auto* widget = find(focused); auto found = edits.find(focused); if (!widget || found == edits.end()) return; widget->text = found->second.buffer.text(); widget->error.clear(); auto callback = widget->on_preview; if (callback) callback(*widget); } std::size_t text_position(const Widget& w, const Edit& e, float x) { const auto local = x - w.rect.x - theme.padding * scale + e.scroll; float previous_width = 0; std::size_t previous_byte = 0; for (std::size_t i = 0; i < e.buffer.text().size();) { ++i; while (i < e.buffer.text().size() && (static_cast(e.buffer.text()[i]) & 0xc0) == 0x80) ++i; const auto width = font.measure(std::string_view(e.buffer.text()).substr(0, i), (w.font_size > 0 ? w.font_size : theme.font_size) * scale); if (local < (previous_width + width) * .5f) return previous_byte; previous_width = width; previous_byte = i; } return e.buffer.text().size(); } std::pair neighbors(Widget& divider) { if (!divider.parent) return {}; auto& list = divider.parent->children; for (std::size_t i = 1; i + 1 < list.size(); ++i) if (list[i].get() == ÷r) return {list[i - 1].get(), list[i + 1].get()}; return {}; } void cancel_capture() { if (auto* w = find(captured); w && w->kind == Kind::NumberField && dragging) { w->value = down_value; auto& edit = edits[w->id]; edit.buffer.reset(number(w->value, w->precision)); w->text = edit.buffer.text(); auto callback = w->on_preview; if (callback) callback(*w); } if (auto* w = find(captured); w && w->kind == Kind::Divider && dragging) { auto [before, after] = neighbors(*w); if (before && after) { before->layout = before_layout; after->layout = after_layout; arrange(root, {0, 0, width, height}, {0, 0, width, height}); } } if (auto* w = find(captured); w && dragging) { auto callback = w->on_cancel; if (callback) callback(*w); } captured.clear(); payload = nullptr; dragging = false; } void draw_widget(Widget& w, render::Snapshot& frame) { if (!w.visible || w.clip.width <= 0 || w.clip.height <= 0) return; const bool hover = w.id == hovered, focus = w.id == focused; auto ink = w.enabled ? theme.text : theme.muted; const auto& rect = w.rect; if (w.kind == Kind::Panel) { fill(frame, rect, theme.surface, w.clip); 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); } else if (w.kind == Kind::Tab || w.kind == Kind::TreeRow) { if (w.selected || hover) fill(frame, rect, w.selected ? theme.selection : 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}, theme.accent, w.clip); else fill(frame, {rect.x, rect.y, 2 * scale, rect.height}, theme.accent, w.clip); } if (focus) outline(frame, rect, theme.accent, w.clip); } else if (field(w)) { fill(frame, rect, theme.background, w.clip); outline(frame, rect, !w.error.empty() ? theme.danger : focus ? theme.accent : theme.border, w.clip); } else if (w.kind == Kind::Divider) { fill(frame, rect, hover || w.id == captured ? theme.accent : 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; const auto ty = rect.y + std::max(0.f, (rect.height - font_size * 1.45f) * .5f); if (w.kind == Kind::Checkbox) { Rect box{tx, rect.y + (rect.height - 14 * scale) / 2, 14 * scale, 14 * scale}; fill(frame, box, theme.background, w.clip); outline(frame, box, focus ? theme.accent : theme.border, w.clip); if (w.checked) fill(frame, {box.x + 3 * scale, box.y + 3 * scale, 8 * scale, 8 * scale}, theme.accent, w.clip); tx += 23 * scale; } if (field(w) && focus && edits.contains(w.id)) { auto& edit = edits[w.id]; const auto available = std::max(1.f, rect.width - theme.padding * 2 * scale); const auto selection_begin = std::min(edit.buffer.cursor(), edit.buffer.anchor()); const auto selection_end = std::max(edit.buffer.cursor(), edit.buffer.anchor()); auto composition_byte = [&](int codepoints) { std::size_t byte = 0; for (int i = 0; i < std::max(0, codepoints) && byte < edit.composition.size(); ++i) { ++byte; while (byte < edit.composition.size() && (static_cast(edit.composition[byte]) & 0xc0) == 0x80) ++byte; } return byte; }; const auto prefix = edit.buffer.text().substr(0, selection_begin); const auto composition_left = font.measure(prefix, font_size); const auto composition_cursor = composition_byte(edit.composition_start); const auto caret = edit.composition.empty() ? font.measure( std::string_view(edit.buffer.text()).substr(0, edit.buffer.cursor()), font_size) : composition_left + font.measure( std::string_view(edit.composition).substr(0, composition_cursor), font_size); if (caret - edit.scroll > available - 2 * scale) edit.scroll = caret - available + 2 * scale; if (caret < edit.scroll) edit.scroll = caret; edit.scroll = std::max(0.f, edit.scroll); const Rect text_clip = w.clip.intersection({rect.x + 3 * scale, rect.y + 2 * scale, rect.width - 6 * scale, rect.height - 4 * scale}); tx -= edit.scroll; if (edit.buffer.has_selection() && edit.composition.empty()) { const auto begin = std::min(edit.buffer.cursor(), edit.buffer.anchor()), end = std::max(edit.buffer.cursor(), edit.buffer.anchor()); const auto left = font.measure( std::string_view(edit.buffer.text()).substr(0, begin), font_size), right = font.measure(std::string_view(edit.buffer.text()).substr(0, end), font_size); fill(frame, {tx + left, rect.y + 4 * scale, right - left, rect.height - 8 * scale}, theme.selection, text_clip); } if (edit.composition.empty()) { font.draw(frame, edit.buffer.text(), tx, ty, font_size, ink, text_clip); } else { const auto composition_width = font.measure(edit.composition, font_size); const auto selection_width = font.measure( std::string_view(edit.composition) .substr(composition_cursor, composition_byte(edit.composition_start + std::max(0, edit.composition_length)) - composition_cursor), font_size); if (selection_width > 0) fill(frame, {tx + caret, rect.y + 4 * scale, selection_width, rect.height - 8 * scale}, theme.selection, text_clip); font.draw(frame, prefix, tx, ty, font_size, ink, text_clip); font.draw(frame, edit.composition, tx + composition_left, ty, font_size, theme.accent, text_clip); font.draw(frame, std::string_view(edit.buffer.text()).substr(selection_end), tx + composition_left + composition_width, ty, font_size, ink, text_clip); fill(frame, {tx + composition_left, rect.y + rect.height - 4 * scale, composition_width, scale}, theme.accent, text_clip); } 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()) font.draw(frame, w.kind == Kind::NumberField ? number(w.value, w.precision) : w.text, tx, ty, font_size, ink, w.clip.intersection( {rect.x + 2 * scale, rect.y, rect.width - 4 * scale, rect.height})); else if (w.kind == Kind::NumberField) font.draw(frame, number(w.value, w.precision), tx, ty, font_size, ink, w.clip); for (auto& child : w.children) draw_widget(*child, frame); if (w.layout.scroll && w.content_height > rect.height) { const float track = rect.height - 8 * scale, thumb = std::max(16 * scale, track * rect.height / w.content_height), offset = (track - thumb) * w.scroll_y / std::max(1.f, w.content_height - rect.height); fill(frame, {rect.x + rect.width - 5 * scale, rect.y + 4 * scale + offset, 3 * scale, thumb}, theme.border, w.clip); } } }; Context::Context(const std::filesystem::path& font) : impl_(std::make_unique(font)) {} Context::~Context() = default; Widget& Context::root() { return impl_->root; } Widget* Context::find(std::string_view id) { return impl_->find(id); } void Context::set_theme(Theme theme) { impl_->theme = theme; } const Theme& Context::theme() const { return impl_->theme; } FontAtlas& Context::font() { return impl_->font; } void Context::validate_layout(const Json& document) const { if (!document.is_object()) throw std::runtime_error("Layout must be an object"); const auto& definition = document.contains("root") ? document.at("root") : document; if (definition.at("id") != impl_->root.id) throw std::runtime_error("Layout root ID must match retained root"); std::set ids; std::function validate = [&](const Json& j, const std::string& parent) { const auto id = j.at("id").get(); if (id.empty() || !ids.insert(id).second) throw std::runtime_error("Duplicate layout widget ID"); const auto* existing = impl_->find(id); if (existing && ((existing->parent ? existing->parent->id : std::string()) != parent)) throw std::runtime_error("Hot layout cannot reparent existing widget: " + id); if (j.contains("kind")) { const auto type = kind_from_string(j.at("kind")); if (existing && existing->kind != type) throw std::runtime_error("Hot layout cannot replace widget kind"); } if (j.contains("text") && (!j.at("text").is_string() || !TextBuffer::valid_utf8(j.at("text").get()))) throw std::runtime_error("Widget text must be valid UTF-8"); if (j.contains("layout")) parse_layout(j.at("layout"), existing ? existing->layout : Layout{}); if (j.contains("font_size")) { const auto size = j.at("font_size").get(); if (!std::isfinite(size) || size < 0 || size > 128) throw std::runtime_error("Invalid widget font size"); } if (j.contains("children")) { if (!j.at("children").is_array()) throw std::runtime_error("Layout children must be an array"); for (const auto& child : j.at("children")) validate(child, id); } }; validate(definition, ""); } void Context::apply_layout(const Json& document) { validate_layout(document); const auto& definition = document.contains("root") ? document.at("root") : document; std::function apply = [&](Widget& w, const Json& j) { if (j.contains("text")) update_text(w.id, j.at("text")); if (j.contains("layout")) w.layout = parse_layout(j.at("layout"), w.layout); if (j.contains("font_size")) w.font_size = j.at("font_size"); if (j.contains("children")) for (const auto& child : j.at("children")) { const auto id = child.at("id").get(); auto& target = w.add(child.contains("kind") ? kind_from_string(child.at("kind")) : (w.find(id) ? w.find(id)->kind : Kind::Panel), id); apply(target, child); } }; apply(root(), definition); } void Context::layout(float width, float height, float scale) { if (!std::isfinite(width) || !std::isfinite(height) || !std::isfinite(scale) || scale <= 0) throw std::invalid_argument("UI dimensions and display scale must be finite and valid"); scale = std::clamp(scale, .5f, 4.f); if (impl_->scale != scale) { // Pointer captures use the old drawable coordinate system. Keep edits, // but cancel a drag rather than committing a jump after a monitor change. impl_->cancel_capture(); const auto ratio = scale / impl_->scale; std::function rescale = [&](Widget& widget) { widget.scroll_y *= ratio; for (auto& child : widget.children) rescale(*child); }; rescale(root()); for (auto& [id, edit] : impl_->edits) edit.scroll *= ratio; } impl_->width = std::max(0.f, width); impl_->height = std::max(0.f, height); impl_->scale = scale; std::set ids; std::function check = [&](Widget& w) { if (w.id.empty() || !ids.insert(w.id).second) throw std::runtime_error("Retained widget IDs must be unique"); for (auto& child : w.children) { child->parent = &w; check(*child); } }; check(root()); impl_->arrange(root(), {0, 0, impl_->width, impl_->height}, {0, 0, impl_->width, impl_->height}); std::erase_if(impl_->edits, [&](const auto& entry) { return !ids.contains(entry.first); }); auto* focused = impl_->find(impl_->focused); if (!focused || !focusable(*focused)) impl_->unfocus(false); else if (field(*focused) && impl_->ime_rectangle) impl_->ime_rectangle(focused->rect); } void Context::draw(render::Snapshot& snapshot) { impl_->draw_widget(root(), snapshot); if (!impl_->payload.is_null()) { const Rect viewport{0, 0, impl_->width, impl_->height}; const auto text = impl_->payload.value("label", impl_->payload.value("panel", std::string("Move"))); const auto width = impl_->font.measure(text, impl_->theme.font_size * impl_->scale) + 20 * impl_->scale; const Rect rect{impl_->mouse_x + 12 * impl_->scale, impl_->mouse_y + 12 * impl_->scale, width, 28 * impl_->scale}; fill(snapshot, rect, impl_->theme.raised, viewport); outline(snapshot, rect, impl_->theme.accent, viewport); impl_->font.draw(snapshot, text, rect.x + 8 * impl_->scale, rect.y + 3 * impl_->scale, impl_->theme.font_size * impl_->scale, impl_->theme.text, viewport); } } bool Context::update_text(const std::string& id, const std::string& value, bool force) { auto* w = find(id); if (!w) return false; if (!TextBuffer::valid_utf8(value)) return false; if (impl_->focused == id && impl_->edits.contains(id)) { auto& edit = impl_->edits[id]; if (!force && (edit.buffer.text() != edit.original || !edit.composition.empty())) return false; const auto cursor = edit.buffer.cursor(); edit.buffer.reset(value); edit.buffer.set_cursor(cursor); edit.original = value; edit.composition.clear(); } w->text = value; return true; } bool Context::update_number(const std::string& id, double value, bool force) { auto* widget = find(id); if (!widget || widget->kind != Kind::NumberField || !std::isfinite(value)) return false; if (!update_text(id, number(value, widget->precision), force)) return false; widget->value = value; if (impl_->focused == id && impl_->edits.contains(id)) impl_->edits[id].original_value = value; return true; } bool Context::focus(const std::string& id) { return impl_->focus(id); } const std::string& Context::focused_id() const { return impl_->focused; } void Context::clear_focus(bool commit) { impl_->unfocus(commit); } bool Context::editing() const { const auto* w = impl_->find(impl_->focused); return w && field(*w); } void Context::set_clipboard(std::function read, std::function write) { impl_->clipboard_read = std::move(read); impl_->clipboard_write = std::move(write); } void Context::set_ime(std::function enabled, std::function rectangle) { impl_->ime_enabled = std::move(enabled); impl_->ime_rectangle = std::move(rectangle); } void Context::set_docking(DockLayout* docking, std::function changed) { impl_->docking = docking; impl_->docking_changed = std::move(changed); } bool Context::handle(const render::Event& event) { auto& p = *impl_; using Type = render::Event::Type; if (event.type == Type::FocusLost) { p.cancel_capture(); if (auto found = p.edits.find(p.focused); found != p.edits.end()) found->second.composition.clear(); p.unfocus(true); return false; } if (event.type == Type::MouseMove || event.type == Type::MouseDown || event.type == Type::MouseUp) { p.mouse_x = event.x; p.mouse_y = event.y; auto* hit = p.hit(p.root, event.x, event.y); p.hovered = hit ? hit->id : ""; } if (event.type == Type::Wheel) { auto* w = p.hit(p.root, p.mouse_x, p.mouse_y); for (; w; w = w->parent) if (w->layout.scroll) { w->scroll_y = std::clamp(w->scroll_y - event.y * p.theme.row_height * 3 * p.scale, 0.f, std::max(0.f, w->content_height - w->rect.height + 2 * w->layout.padding * p.scale)); layout(p.width, p.height, p.scale); return true; } return false; } if (event.type == Type::MouseDown && event.button == 1) { auto* w = p.hit(p.root, event.x, event.y); if (!w || w->kind == Kind::Viewport) { p.unfocus(true); return false; } if (!w->enabled) return true; if (focusable(*w)) { if (!p.focus(w->id)) return true; } else { if (!p.commit()) return true; p.unfocus(false); } p.captured = w->id; p.down_x = event.x; p.down_y = event.y; p.down_value = w->value; p.dragging = false; p.payload = nullptr; if (w->kind == Kind::TextField) { auto& edit = p.edits[w->id]; edit.buffer.set_cursor(p.text_position(*w, edit, event.x), event.shift); } if (w->kind == Kind::Divider) { auto [before, after] = p.neighbors(*w); if (before && after) { const bool horizontal = w->parent->kind == Kind::Row; p.before_size = (horizontal ? before->rect.width : before->rect.height) / p.scale; p.after_size = (horizontal ? after->rect.width : after->rect.height) / p.scale; p.before_layout = before->layout; p.after_layout = after->layout; } } return true; } if (event.type == Type::MouseMove && !p.captured.empty()) { auto* w = p.find(p.captured); if (!w) { p.cancel_capture(); return false; } const auto distance = std::hypot(event.x - p.down_x, event.y - p.down_y); if (w->kind == Kind::NumberField && distance > 3 * p.scale) { p.dragging = true; w->value = p.down_value + (event.x - p.down_x) / p.scale * w->step * (event.shift ? .1 : 1.0); auto& edit = p.edits[w->id]; edit.buffer.reset(number(w->value, w->precision)); w->text = edit.buffer.text(); auto callback = w->on_preview; if (callback) callback(*w); return true; } if (w->kind == Kind::TextField) { auto& edit = p.edits[w->id]; edit.buffer.set_cursor(p.text_position(*w, edit, event.x), true); return true; } if (w->kind == Kind::Divider) { auto [before, after] = p.neighbors(*w); if (before && after) { const bool horizontal = w->parent->kind == Kind::Row; const auto delta = (horizontal ? event.x - p.down_x : event.y - p.down_y) / p.scale; const auto minimum_before = std::max(24.f, horizontal ? before->layout.min_width : before->layout.min_height), minimum_after = std::max(24.f, horizontal ? after->layout.min_width : after->layout.min_height); const auto low = minimum_before - p.before_size, high = p.after_size - minimum_after; const auto amount = low <= high ? std::clamp(delta, low, high) : 0.f; if (before->layout.flex <= 0) { if (horizontal) before->layout.width = p.before_size + amount; else before->layout.height = p.before_size + amount; } if (after->layout.flex <= 0) { if (horizontal) after->layout.width = p.after_size - amount; else after->layout.height = p.after_size - amount; } if (before->layout.flex > 0 && after->layout.flex > 0) { before->layout.flex = p.before_size + amount; after->layout.flex = p.after_size - amount; } p.dragging = std::abs(amount) > .01f; w->value = p.before_size + amount; auto callback = w->on_preview; if (callback) callback(*w); layout(p.width, p.height, p.scale); } return true; } if (distance > 5 * p.scale) { if (!w->dock_panel.empty()) { p.payload = {{"kind", "dock_panel"}, {"panel", w->dock_panel}, {"label", w->text}}; p.dragging = true; } else if (!w->drag_payload.is_null()) { p.payload = w->drag_payload; p.dragging = true; } } return true; } if (event.type == Type::MouseUp && event.button == 1 && !p.captured.empty()) { const auto id = p.captured; auto* w = p.find(id); const auto dragging = p.dragging; auto payload = p.payload; p.captured.clear(); p.payload = nullptr; p.dragging = false; if (!w) return true; if (!payload.is_null()) { auto* target = p.hit(p.root, event.x, event.y); for (; target; target = target->parent) { if (p.docking && payload.value("kind", std::string()) == "dock_panel" && !target->dock_area.empty()) { auto order = p.docking->panels(target->dock_area); auto found = std::find(order.begin(), order.end(), target->dock_panel); p.docking->move(payload.at("panel"), target->dock_area, static_cast(found - order.begin())); if (p.docking_changed) p.docking_changed(); break; } if (target->on_drop) { auto callback = target->on_drop; callback(*target, payload); break; } } return true; } if (w->kind == Kind::NumberField && dragging) { p.commit(); return true; } if (w->kind == Kind::Divider && dragging) { auto callback = w->on_commit; if (callback) callback(*w); return true; } if (!w->rect.contains(event.x, event.y)) return true; if (w->kind == Kind::Checkbox) { w->checked = !w->checked; auto callback = w->on_commit; if (callback) callback(*w); w = p.find(id); if (!w) return true; } if (w->kind == Kind::Button || w->kind == Kind::Tab || w->kind == Kind::TreeRow || w->kind == Kind::Checkbox) { auto callback = w->on_click; if (callback) callback(*w); } return true; } if (event.type == Type::KeyDown && event.key == "Escape") { if (!p.captured.empty()) { p.cancel_capture(); return true; } if (auto* w = p.find(p.focused); w && field(*w)) { auto& edit = p.edits[w->id]; w->text = edit.original; w->value = edit.original_value; w->error.clear(); edit.buffer.reset(edit.original); edit.composition.clear(); auto callback = w->on_cancel; if (callback) callback(*w); p.unfocus(false); return true; } return false; } if (event.type == Type::KeyDown && event.key == "Tab") { std::vector ids; collect_focus(p.root, ids); if (ids.empty()) return false; auto current = std::find(ids.begin(), ids.end(), p.focused); std::size_t index = current == ids.end() ? 0 : static_cast(current - ids.begin()); if (current != ids.end()) index = event.shift ? (index + ids.size() - 1) % ids.size() : (index + 1) % ids.size(); return p.focus(ids[index]); } auto* w = p.find(p.focused); if (!w) return false; if (field(*w)) { auto& edit = p.edits[w->id]; if (event.type == Type::TextEditing) { if (TextBuffer::valid_utf8(event.text)) { edit.composition = event.text; edit.composition_start = event.edit_start; edit.composition_length = event.edit_length; } return true; } if (event.type == Type::TextInput) { auto text = event.text; std::replace(text.begin(), text.end(), '\n', ' '); std::replace(text.begin(), text.end(), '\r', ' '); edit.composition.clear(); if (edit.buffer.insert(text)) p.preview(); return true; } if (event.type != Type::KeyDown) return false; if (event.control && (event.key == "A" || event.key == "a")) { edit.buffer.select_all(); return true; } if (event.control && (event.key == "C" || event.key == "c" || event.key == "X" || event.key == "x")) { if (p.clipboard_write && edit.buffer.has_selection()) p.clipboard_write(edit.buffer.selected_text()); if ((event.key == "X" || event.key == "x") && edit.buffer.has_selection()) { edit.buffer.insert(""); p.preview(); } return true; } if (event.control && (event.key == "V" || event.key == "v")) { if (p.clipboard_read) { auto text = p.clipboard_read(); std::replace(text.begin(), text.end(), '\n', ' '); std::replace(text.begin(), text.end(), '\r', ' '); if (edit.buffer.insert(text)) p.preview(); } return true; } if (event.control && (event.key == "Z" || event.key == "z")) { const auto changed = event.shift ? edit.buffer.redo() : edit.buffer.undo(); if (changed) p.preview(); return changed; } if (event.control && (event.key == "Y" || event.key == "y")) { const auto changed = edit.buffer.redo(); if (changed) p.preview(); return changed; } if (event.key == "Left") { edit.buffer.left(event.shift, event.control); return true; } if (event.key == "Right") { edit.buffer.right(event.shift, event.control); return true; } if (event.key == "Home") { edit.buffer.home(event.shift); return true; } if (event.key == "End") { edit.buffer.end(event.shift); return true; } if (event.key == "Backspace") { if (edit.buffer.backspace()) p.preview(); return true; } if (event.key == "Delete") { if (edit.buffer.delete_forward()) p.preview(); return true; } if (event.key == "Return" || event.key == "Enter") { if (p.commit()) { auto* current = p.find(p.focused); if (current && current->kind == Kind::NumberField) p.edits[current->id].buffer.select_all(); } return true; } return false; } if (event.type == Type::KeyDown && (event.key == "Return" || event.key == "Space")) { const auto id = w->id; if (w->kind == Kind::Checkbox) { w->checked = !w->checked; auto callback = w->on_commit; if (callback) callback(*w); w = p.find(id); } if (w) { auto callback = w->on_click; if (callback) callback(*w); } return true; } return false; } } // namespace faset::ui