/* Crafter®.Graphics Copyright (C) 2026 Catcrafts® catcrafts.net This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3.0 as published by the Free Software Foundation; This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ export module Crafter.Graphics:UIWidgets; import std; import :UILength; import :UIWidget; import :UILayout; import :UIDrawList; import :UIAtlas; import :Font; export namespace Crafter::UI { // ─────────────────── Text-emission helper ───────────────────────────── // Walks an ASCII string, ensuring each codepoint in the atlas and // emitting one Glyph item per non-whitespace glyph. Returns the final // x-cursor position (useful when emitting multi-run text). namespace detail { inline float EmitText(DrawList& dl, Font* font, std::string_view text, float fontSizePx, Color color, float originX, float topY) { if (!dl.atlas || !font) return originX; FontAtlas& atlas = *dl.atlas; float scaleFactor = fontSizePx / FontAtlas::kBaseSize; float baselineY = topY + font->AscentPx(fontSizePx); float cursorX = originX; std::size_t i = 0; while (i < text.size()) { std::uint32_t cp = DecodeUtf8(text, i); if (cp == 0) break; atlas.Ensure(*font, cp); const Glyph* g = atlas.Lookup(*font, cp); if (!g) continue; if (g->w > 0 && g->h > 0) { Rect quad{ cursorX + g->xoff * scaleFactor, baselineY + g->yoff * scaleFactor, g->w * scaleFactor, g->h * scaleFactor, }; dl.AddGlyph(quad, color, {g->u0, g->v0, g->u1 - g->u0, g->v1 - g->v0}); } cursorX += g->advance * scaleFactor; } return cursorX; } } // ─────────────────────────── Spacer ─────────────────────────────────── // Takes up flex space along whichever axis its parent stacks on. Has // no minimum size of its own; an HStack treats it as a horizontal gap, // a VStack as vertical. struct Spacer : WidgetBuilder { Spacer() { width_ = Length::Frac(1); height_ = Length::Frac(1); } Size Measure(Size /*avail*/, const LayoutContext& /*ctx*/) override { desiredSize = {0, 0}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& /*ctx*/) override { computedRect = rect; } }; // ─────────────────────── Stack-axis helpers ─────────────────────────── namespace detail { // Pick a child's cross-axis size (the axis the stack does NOT lay // children along). For Auto, defer to the child's measured size; // for Px/Pct/Frac, resolve against the available content size. inline float ResolveCrossAxis(const Widget& c, Length len, float contentExtent, float scale, float autoSize) { return ResolveLength(len, contentExtent, scale, [&]{ return autoSize; }); } } // ─────────────────────────── VStack ─────────────────────────────────── struct VStack : WidgetBuilder { float spacing_ = 0; VStack& spacing(float s) { spacing_ = s; return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { EdgesPx p = ResolveEdges(padding_, ctx.scale); float spacingPx = spacing_ * ctx.scale; // If our own width/height is fixed, that bounds children; if Auto, // children may use the full available extent. float ownW = ResolveLength(width_, avail.w, ctx.scale, [&]{ return avail.w; }); float ownH = ResolveLength(height_, avail.h, ctx.scale, [&]{ return avail.h; }); float contentW = std::max(0.0f, ownW - p.Horiz()); float contentH = std::max(0.0f, ownH - p.Vert()); float maxChildW = 0; float totalH = 0; float remainingH = contentH; for (std::size_t i = 0; i < children_.size(); ++i) { auto& c = *children_[i]; Size childAvail = { contentW, std::max(0.0f, remainingH) }; Size cs = c.Measure(childAvail, ctx); maxChildW = std::max(maxChildW, cs.w); totalH += cs.h; remainingH -= cs.h; if (i + 1 < children_.size()) { totalH += spacingPx; remainingH -= spacingPx; } } desiredSize = { (width_.mode == Length::Mode::Auto) ? maxChildW + p.Horiz() : ownW, (height_.mode == Length::Mode::Auto) ? totalH + p.Vert() : ownH, }; return desiredSize; } void Arrange(Rect rect, const LayoutContext& ctx) override { computedRect = rect; EdgesPx p = ResolveEdges(padding_, ctx.scale); Rect content = ShrinkBy(rect, p); float spacingPx = spacing_ * ctx.scale; // First pass: sum fixed heights + count Frac weight. float fracWeight = 0; float fixedH = 0; for (auto& c : children_) { if (c->height_.mode == Length::Mode::Frac) { fracWeight += c->height_.value; } else { fixedH += c->desiredSize.h; } } if (children_.size() > 1) fixedH += spacingPx * (children_.size() - 1); float leftover = std::max(0.0f, content.h - fixedH); float fracUnit = (fracWeight > 0) ? (leftover / fracWeight) : 0; // Second pass: arrange. float y = content.y; for (std::size_t i = 0; i < children_.size(); ++i) { auto& c = *children_[i]; float h = (c.height_.mode == Length::Mode::Frac) ? c.height_.value * fracUnit : c.desiredSize.h; float w = detail::ResolveCrossAxis(c, c.width_, content.w, ctx.scale, c.desiredSize.w); if (c.width_.mode == Length::Mode::Frac) w = content.w * c.width_.value; if (w > content.w) w = content.w; // Cross-axis (horizontal) alignment: honor the child's anchor // for the horizontal half (Left/Center/Right); default Left. float x = content.x; if (c.anchor_) { switch (*c.anchor_) { case Anchor::Top: case Anchor::Center: case Anchor::Bottom: x = content.x + (content.w - w) / 2; break; case Anchor::TopRight: case Anchor::Right: case Anchor::BottomRight: x = content.x + content.w - w; break; default: break; } } c.Arrange({x, y, w, h}, ctx); y += h + spacingPx; } } }; // ─────────────────────────── HStack ─────────────────────────────────── struct HStack : WidgetBuilder { float spacing_ = 0; HStack& spacing(float s) { spacing_ = s; return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { EdgesPx p = ResolveEdges(padding_, ctx.scale); float spacingPx = spacing_ * ctx.scale; float ownW = ResolveLength(width_, avail.w, ctx.scale, [&]{ return avail.w; }); float ownH = ResolveLength(height_, avail.h, ctx.scale, [&]{ return avail.h; }); float contentW = std::max(0.0f, ownW - p.Horiz()); float contentH = std::max(0.0f, ownH - p.Vert()); float maxChildH = 0; float totalW = 0; float remainingW = contentW; for (std::size_t i = 0; i < children_.size(); ++i) { auto& c = *children_[i]; Size childAvail = { std::max(0.0f, remainingW), contentH }; Size cs = c.Measure(childAvail, ctx); maxChildH = std::max(maxChildH, cs.h); totalW += cs.w; remainingW -= cs.w; if (i + 1 < children_.size()) { totalW += spacingPx; remainingW -= spacingPx; } } desiredSize = { (width_.mode == Length::Mode::Auto) ? totalW + p.Horiz() : ownW, (height_.mode == Length::Mode::Auto) ? maxChildH + p.Vert() : ownH, }; return desiredSize; } void Arrange(Rect rect, const LayoutContext& ctx) override { computedRect = rect; EdgesPx p = ResolveEdges(padding_, ctx.scale); Rect content = ShrinkBy(rect, p); float spacingPx = spacing_ * ctx.scale; float fracWeight = 0; float fixedW = 0; for (auto& c : children_) { if (c->width_.mode == Length::Mode::Frac) { fracWeight += c->width_.value; } else { fixedW += c->desiredSize.w; } } if (children_.size() > 1) fixedW += spacingPx * (children_.size() - 1); float leftover = std::max(0.0f, content.w - fixedW); float fracUnit = (fracWeight > 0) ? (leftover / fracWeight) : 0; float x = content.x; for (std::size_t i = 0; i < children_.size(); ++i) { auto& c = *children_[i]; float w = (c.width_.mode == Length::Mode::Frac) ? c.width_.value * fracUnit : c.desiredSize.w; float h = detail::ResolveCrossAxis(c, c.height_, content.h, ctx.scale, c.desiredSize.h); if (c.height_.mode == Length::Mode::Frac) h = content.h * c.height_.value; if (h > content.h) h = content.h; float y = content.y; if (c.anchor_) { switch (*c.anchor_) { case Anchor::Left: case Anchor::Center: case Anchor::Right: y = content.y + (content.h - h) / 2; break; case Anchor::BottomLeft: case Anchor::Bottom: case Anchor::BottomRight: y = content.y + content.h - h; break; default: break; } } c.Arrange({x, y, w, h}, ctx); x += w + spacingPx; } } }; // ──────────────────── Stack (anchored layering) ─────────────────────── // Children stack on top of each other inside the parent's content rect. // Each child positions itself by its own `.anchor(...)`; default is // TopLeft. Children with Auto sizes are sized to their measured needs. struct Stack : WidgetBuilder { Size Measure(Size avail, const LayoutContext& ctx) override { EdgesPx p = ResolveEdges(padding_, ctx.scale); float ownW = ResolveLength(width_, avail.w, ctx.scale, [&]{ return avail.w; }); float ownH = ResolveLength(height_, avail.h, ctx.scale, [&]{ return avail.h; }); float contentW = std::max(0.0f, ownW - p.Horiz()); float contentH = std::max(0.0f, ownH - p.Vert()); float maxW = 0, maxH = 0; for (auto& c : children_) { Size cs = c->Measure({contentW, contentH}, ctx); maxW = std::max(maxW, cs.w); maxH = std::max(maxH, cs.h); } desiredSize = { (width_.mode == Length::Mode::Auto) ? maxW + p.Horiz() : ownW, (height_.mode == Length::Mode::Auto) ? maxH + p.Vert() : ownH, }; return desiredSize; } void Arrange(Rect rect, const LayoutContext& ctx) override { computedRect = rect; EdgesPx p = ResolveEdges(padding_, ctx.scale); Rect content = ShrinkBy(rect, p); for (auto& cp : children_) { auto& c = *cp; // Resolve child width/height. Frac fills parent. float w = (c.width_.mode == Length::Mode::Auto) ? c.desiredSize.w : (c.width_.mode == Length::Mode::Frac ? content.w * c.width_.value : ResolveLength(c.width_, content.w, ctx.scale, [&]{return c.desiredSize.w;})); float h = (c.height_.mode == Length::Mode::Auto) ? c.desiredSize.h : (c.height_.mode == Length::Mode::Frac ? content.h * c.height_.value : ResolveLength(c.height_, content.h, ctx.scale, [&]{return c.desiredSize.h;})); w = std::min(w, content.w); h = std::min(h, content.h); Anchor a = c.anchor_.value_or(Anchor::TopLeft); float x = content.x, y = content.y; switch (a) { case Anchor::TopLeft: break; case Anchor::Top: x = content.x + (content.w - w) / 2; break; case Anchor::TopRight: x = content.x + content.w - w; break; case Anchor::Left: y = content.y + (content.h - h) / 2; break; case Anchor::Center: x = content.x + (content.w - w) / 2; y = content.y + (content.h - h) / 2; break; case Anchor::Right: x = content.x + content.w - w; y = content.y + (content.h - h) / 2; break; case Anchor::BottomLeft: y = content.y + content.h - h; break; case Anchor::Bottom: x = content.x + (content.w - w) / 2; y = content.y + content.h - h; break; case Anchor::BottomRight: x = content.x + content.w - w; y = content.y + content.h - h; break; } c.Arrange({x, y, w, h}, ctx); } } }; // Overlay is functionally identical to Stack today; it exists so user // code can spell intent ("layered HUD" vs "anchored content"). using Overlay = Stack; // ─────────────────────────── TextRun ────────────────────────────────── // A styled span inside a Text widget. Per-run overrides win over the // Text's base style; unset fields fall back to the parent Text. struct TextRun { std::string text; std::optional size_; std::optional color_; bool bold_ = false; bool italic_ = false; bool underline_ = false; bool strikethrough_ = false; TextRun() = default; TextRun(std::string s) : text(std::move(s)) {} TextRun(const char* s) : text(s) {} TextRun& size(float s) { size_ = s; return *this; } TextRun& color(Color c) { color_ = c; return *this; } TextRun& bold() { bold_ = true; return *this; } TextRun& italic() { italic_ = true; return *this; } TextRun& underline() { underline_ = true; return *this; } TextRun& strikethrough() { strikethrough_ = true; return *this; } }; // ─────────────────────────── Text ───────────────────────────────────── // Static text. V1 supports a single line, single font, with per-run // styling (color, size, weight, italics, underline, strikethrough). // Wrap, multi-font, BiDi etc. are V2+. struct Text : WidgetBuilder { // Bring back the layout `size(Length, Length)` overload — our own // `size(float)` would otherwise hide it. using WidgetBuilder::size; std::vector runs_; Font* font_ = nullptr; float size_ = 16.0f; Color color_{1, 1, 1, 1}; Text() = default; Text(std::string s) { runs_.emplace_back(std::move(s)); } Text(const char* s) { runs_.emplace_back(std::string(s)); } Text& font(Font& f) { font_ = &f; return *this; } Text& size(float s) { size_ = s; return *this; } Text& color(Color c) { color_ = c; return *this; } // Replace the run list with a parameter pack of styled runs. template requires (std::convertible_to, TextRun> && ...) Text& runs(Rs&&... rs) { runs_.clear(); runs_.reserve(sizeof...(Rs)); (runs_.emplace_back(std::forward(rs)), ...); return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { float ownW = ResolveLength(width_, avail.w, ctx.scale, [&]{ if (!font_) return 0.0f; float w = 0; for (auto& r : runs_) { float rs = (r.size_.value_or(size_)) * ctx.scale; w += font_->GetLineWidth(r.text, rs); } return w; }); float ownH = ResolveLength(height_, avail.h, ctx.scale, [&]{ if (!font_) return 0.0f; // Tallest run dictates the line height. float h = 0; for (auto& r : runs_) { float rs = (r.size_.value_or(size_)) * ctx.scale; h = std::max(h, font_->LineHeight(rs)); } return h; }); desiredSize = {ownW, ownH}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& /*ctx*/) override { computedRect = rect; } void Emit(DrawList& dl) const override { if (!font_) return; float cursorX = computedRect.x; for (auto& r : runs_) { float rs = r.size_.value_or(size_) * dl.scale; Color c = r.color_.value_or(color_); cursorX = detail::EmitText(dl, font_, r.text, rs, c, cursorX, computedRect.y); } } }; // ─────────────────────────── Image ──────────────────────────────────── // A texture-or-asset reference. V1 stores just the source path; the // renderer will resolve it. If `sourceSize_` is set, an Auto axis paired // with a fixed axis preserves aspect ratio. struct Image : WidgetBuilder { std::filesystem::path source_; Size sourceSize_{}; Color tint_{1, 1, 1, 1}; Image() = default; Image(std::filesystem::path p) : source_(std::move(p)) {} Image(const char* p) : source_(p) {} Image& source(std::filesystem::path p) { source_ = std::move(p); return *this; } Image& sourceSize(Size s) { sourceSize_ = s; return *this; } Image& tint(Color c) { tint_ = c; return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { float w = ResolveLength(width_, avail.w, ctx.scale, [&]{ return sourceSize_.w * ctx.scale; }); float h = ResolveLength(height_, avail.h, ctx.scale, [&]{ return sourceSize_.h * ctx.scale; }); // If we know the source aspect AND only one axis is Auto, derive // the missing axis to preserve aspect ratio. if (sourceSize_.w > 0 && sourceSize_.h > 0) { bool autoW = (width_.mode == Length::Mode::Auto); bool autoH = (height_.mode == Length::Mode::Auto); if (autoW && !autoH && h > 0) w = h * (sourceSize_.w / sourceSize_.h); else if (autoH && !autoW && w > 0) h = w * (sourceSize_.h / sourceSize_.w); } desiredSize = {w, h}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& /*ctx*/) override { computedRect = rect; } std::uint32_t bindlessSlot_ = 0; // assigned by UIScene when source is loaded void Emit(DrawList& dl) const override { if (bindlessSlot_ == 0) return; // texture not loaded yet dl.AddImage(computedRect, tint_, bindlessSlot_); } }; // ─────────────────────────── ProgressBar ────────────────────────────── // Horizontal bar showing `value_` ∈ [min_, max_]. `bindValue` registers // an Observable so external state drives the bar without rebuilding. struct ProgressBar : WidgetBuilder { // Bring back the layout `size(Length, Length)` overload — the // single-arg `value(...)` sets a float; layout sizing uses Length. using WidgetBuilder::size; float value_ = 0.0f; float min_ = 0.0f; float max_ = 1.0f; Color background_{0.20f, 0.20f, 0.20f, 1.0f}; Color foreground_{0.40f, 0.70f, 1.00f, 1.0f}; Observable* boundValue_ = nullptr; ProgressBar() { height_ = Length::Px(8); } ProgressBar& value(float v) { value_ = v; return *this; } ProgressBar& range(float lo, float hi) { min_ = lo; max_ = hi; return *this; } ProgressBar& background(Color c) { background_ = c; return *this; } ProgressBar& foreground(Color c) { foreground_ = c; return *this; } ProgressBar& bindValue(Observable& v, float lo = 0.0f, float hi = 1.0f) { boundValue_ = &v; min_ = lo; max_ = hi; return *this; } // Normalised progress in [0, 1]. float Progress() const { float v = boundValue_ ? boundValue_->Get() : value_; if (max_ <= min_) return 0.0f; return std::clamp((v - min_) / (max_ - min_), 0.0f, 1.0f); } Size Measure(Size avail, const LayoutContext& ctx) override { float w = ResolveLength(width_, avail.w, ctx.scale, [&]{ return avail.w; }); float h = ResolveLength(height_, avail.h, ctx.scale, [&]{ return 8.0f * ctx.scale; }); desiredSize = {w, h}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& /*ctx*/) override { computedRect = rect; } void Emit(DrawList& dl) const override { dl.AddRect(computedRect, background_); float p = Progress(); if (p > 0.0f) { Rect fg{computedRect.x, computedRect.y, computedRect.w * p, computedRect.h}; dl.AddRect(fg, foreground_); } } }; // ─────────────────────────── ButtonStyle ───────────────────────────── // Reusable visual style for Buttons. Lives in UIWidgets (not UITheme) // so Button::style(...) can take it by const-ref without a circular // module dependency. struct ButtonStyle { Color background {0.20f, 0.20f, 0.20f, 1.0f}; Color hoverBackground {0.28f, 0.28f, 0.28f, 1.0f}; Color pressedBackground{0.14f, 0.14f, 0.14f, 1.0f}; Color textColor {0.95f, 0.95f, 0.95f, 1.0f}; Color borderColor {0.30f, 0.30f, 0.30f, 1.0f}; float fontSize = 16.0f; Edges padding{8.0f, 12.0f}; }; // ─────────────────────────── InputFieldStyle ───────────────────────── struct InputFieldStyle { Color background {0.10f, 0.10f, 0.10f, 1.0f}; Color textColor {0.95f, 0.95f, 0.95f, 1.0f}; Color borderColor {0.40f, 0.40f, 0.40f, 1.0f}; Color focusBorderColor {0.40f, 0.70f, 1.00f, 1.0f}; float fontSize = 16.0f; Edges padding{6.0f, 8.0f}; }; // ─────────────────────────── InputField ────────────────────────────── // Single-line text editor. V1 stores a std::string; the renderer draws // the background, border, text, and a focus-cursor caret. Keyboard // events are wired in by UI-Hit/UI-Scene; for now the widget owns the // data + visual config and exposes onChange/onSubmit callbacks. struct InputField : WidgetBuilder { std::string text_; Font* font_ = nullptr; float fontSize_ = 16.0f; Color textColor_{0.95f, 0.95f, 0.95f, 1.0f}; Color background_{0.10f, 0.10f, 0.10f, 1.0f}; Color borderColor_{0.40f, 0.40f, 0.40f, 1.0f}; Color focusBorderColor_{0.40f, 0.70f, 1.00f, 1.0f}; bool focused_ = false; std::size_t cursor_ = 0; // codepoint index within text_ std::string placeholder_; std::function onChange_; std::function onSubmit_; InputField() { padding_ = Edges(6, 8); width_ = Length::Px(160); } InputField(std::string initial) : InputField() { text_ = std::move(initial); cursor_ = text_.size(); } InputField& text(std::string s) { text_ = std::move(s); cursor_ = text_.size(); return *this; } InputField& placeholder(std::string s) { placeholder_ = std::move(s); return *this; } InputField& font(Font& f) { font_ = &f; return *this; } InputField& fontSize(float s) { fontSize_ = s; return *this; } InputField& textColor(Color c) { textColor_ = c; return *this; } InputField& background(Color c) { background_ = c; return *this; } InputField& borderColor(Color c) { borderColor_ = c; return *this; } InputField& focusBorderColor(Color c) { focusBorderColor_ = c; return *this; } InputField& onChange(std::function f) { onChange_ = std::move(f); return *this; } InputField& onSubmit(std::function f) { onSubmit_ = std::move(f); return *this; } InputField& style(const InputFieldStyle& s) { background_ = s.background; textColor_ = s.textColor; borderColor_ = s.borderColor; focusBorderColor_ = s.focusBorderColor; fontSize_ = s.fontSize; padding_ = s.padding; return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { EdgesPx p = ResolveEdges(padding_, ctx.scale); float devSize = fontSize_ * ctx.scale; float lineH = font_ ? font_->LineHeight(devSize) : devSize; float w = ResolveLength(width_, avail.w, ctx.scale, [&]{ return 160.0f * ctx.scale; }); float h = ResolveLength(height_, avail.h, ctx.scale, [&]{ return lineH + p.Vert(); }); desiredSize = {w, h}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& /*ctx*/) override { computedRect = rect; } // Interaction. UIScene's focus manager flips `focused_` via // OnFocus/OnBlur; mouse clicks just need to be claimed so the // bubble stops here (and so a non-focusable parent doesn't eat // the event). The text-edit ops are deliberately tiny: insert at // cursor, backspace, delete, enter; arrow keys move the caret. bool IsFocusable() const override { return true; } void OnFocus() override { focused_ = true; } void OnBlur() override { focused_ = false; } bool OnMouseClick(float, float) override { return true; } bool OnTextInput(std::string_view text) override { text_.insert(cursor_, text); cursor_ += text.size(); if (onChange_) onChange_(text_); return true; } bool OnKeyDown(CrafterKeys key) override { switch (key) { case CrafterKeys::Backspace: { if (cursor_ > 0) { // Remove a full UTF-8 codepoint, not a byte. std::size_t back = 1; while (back < cursor_ && (static_cast(text_[cursor_ - back]) & 0xC0) == 0x80) { ++back; } text_.erase(cursor_ - back, back); cursor_ -= back; if (onChange_) onChange_(text_); } return true; } case CrafterKeys::Delete: { if (cursor_ < text_.size()) { std::size_t fwd = 1; while (cursor_ + fwd < text_.size() && (static_cast(text_[cursor_ + fwd]) & 0xC0) == 0x80) { ++fwd; } text_.erase(cursor_, fwd); if (onChange_) onChange_(text_); } return true; } case CrafterKeys::Left: { while (cursor_ > 0) { --cursor_; if ((static_cast(text_[cursor_]) & 0xC0) != 0x80) break; } return true; } case CrafterKeys::Right: { while (cursor_ < text_.size()) { ++cursor_; if (cursor_ == text_.size() || (static_cast(text_[cursor_]) & 0xC0) != 0x80) break; } return true; } case CrafterKeys::Home: cursor_ = 0; return true; case CrafterKeys::End: cursor_ = text_.size(); return true; case CrafterKeys::Enter: if (onSubmit_) onSubmit_(text_); return true; default: return false; } } void Emit(DrawList& dl) const override { // Background. dl.AddRect(computedRect, background_); // Border: 4 thin rects so the user can see focus state without // a stencil pass. 1-device-pixel-wide outline. float t = std::max(1.0f, dl.scale); Color border = focused_ ? focusBorderColor_ : borderColor_; dl.AddRect({computedRect.x, computedRect.y, computedRect.w, t}, border); // top dl.AddRect({computedRect.x, computedRect.y + computedRect.h - t, computedRect.w, t}, border); // bottom dl.AddRect({computedRect.x, computedRect.y, t, computedRect.h}, border); // left dl.AddRect({computedRect.x + computedRect.w - t, computedRect.y, t, computedRect.h}, border); // right if (font_) { EdgesPx p = ResolveEdges(padding_, dl.scale); float devSize = fontSize_ * dl.scale; float originX = computedRect.x + p.left; float originY = computedRect.y + p.top; std::string_view show = !text_.empty() ? std::string_view(text_) : std::string_view(placeholder_); Color col = !text_.empty() ? textColor_ : Color{textColor_.r, textColor_.g, textColor_.b, textColor_.a * 0.5f}; detail::EmitText(dl, font_, show, devSize, col, originX, originY); // Caret bar at the cursor position when focused. if (focused_) { std::string_view before(text_.data(), cursor_); float prefixW = static_cast(font_->GetLineWidth(before, devSize)); float caretX = originX + prefixW; dl.AddRect({caretX, originY, t, font_->LineHeight(devSize)}, focusBorderColor_); } } } }; // ─────────────────────────── ScrollView ────────────────────────────── // Clipping viewport. Children are laid out vertically (treated like a // VStack with no spacing) and translated by scrollY_; horizontal scroll // is opt-in via .horizontal(true). Hit/wheel/drag interaction is wired // by UI-Hit/UI-Scene. struct ScrollView : WidgetBuilder { bool scrollVertical_ = true; bool scrollHorizontal_ = false; float scrollX_ = 0.0f; float scrollY_ = 0.0f; Size contentSize_{}; // total laid-out children size (for scroll bounds) ScrollView& vertical(bool b) { scrollVertical_ = b; return *this; } ScrollView& horizontal(bool b) { scrollHorizontal_ = b; return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { EdgesPx p = ResolveEdges(padding_, ctx.scale); // Viewport size — defaults to filling available. float w = ResolveLength(width_, avail.w, ctx.scale, [&]{ return avail.w; }); float h = ResolveLength(height_, avail.h, ctx.scale, [&]{ return avail.h; }); // Children measure with effectively-unbounded space along the // scroll axis, allowing them to grow beyond the viewport. constexpr float kInf = std::numeric_limits::max(); Size childAvail = { scrollHorizontal_ ? kInf : std::max(0.0f, w - p.Horiz()), scrollVertical_ ? kInf : std::max(0.0f, h - p.Vert()), }; float totalH = 0; float maxW = 0; for (auto& c : children_) { Size cs = c->Measure(childAvail, ctx); totalH += cs.h; maxW = std::max(maxW, cs.w); } contentSize_ = {maxW, totalH}; desiredSize = {w, h}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& ctx) override { computedRect = rect; EdgesPx p = ResolveEdges(padding_, ctx.scale); Rect content = ShrinkBy(rect, p); // Clamp scroll to valid range. float maxScrollY = std::max(0.0f, contentSize_.h - content.h); float maxScrollX = std::max(0.0f, contentSize_.w - content.w); scrollY_ = std::clamp(scrollY_, 0.0f, maxScrollY); scrollX_ = std::clamp(scrollX_, 0.0f, maxScrollX); float y = content.y - scrollY_; for (auto& c : children_) { float w = (c->width_.mode == Length::Mode::Auto) ? c->desiredSize.w : ResolveLength(c->width_, content.w, ctx.scale, [&]{ return c->desiredSize.w; }); float h = c->desiredSize.h; c->Arrange({content.x - scrollX_, y, w, h}, ctx); y += h; } } void Emit(DrawList& dl) const override { // Wrap children in a clip rect equal to our viewport. dl.PushClip(computedRect); for (auto& c : children_) c->Emit(dl); dl.PopClip(); } }; // ─────────────────────────── TabView ────────────────────────────────── // A tab bar at the top + the selected tab's content below. Each `.tab( // name, widget)` adds a child widget; the selected index drives which // child is laid out and rendered. Tab clicks are routed by UI-Hit later. struct TabView : WidgetBuilder { std::vector tabNames_; int selected_ = 0; Font* font_ = nullptr; float tabFontSize_ = 14.0f; Color tabBackground_{0.10f, 0.10f, 0.10f, 1.0f}; Color tabActiveBackground_{0.18f, 0.18f, 0.18f, 1.0f}; Color tabTextColor_{0.85f, 0.85f, 0.85f, 1.0f}; Color tabActiveTextColor_{1.0f, 1.0f, 1.0f, 1.0f}; float tabBarHeight_ = 32.0f; float tabBarBottomYPx_ = 0.0f; // cached after Arrange for hit-testing TabView& font(Font& f) { font_ = &f; return *this; } TabView& tabFontSize(float s) { tabFontSize_ = s; return *this; } TabView& tabBarHeight(float h) { tabBarHeight_ = h; return *this; } TabView& selected(int i) { selected_ = i; return *this; } // Appends one tab (name + content widget). Each content widget // is owned via children_; tabNames_[i] mirrors children_[i]'s name. template requires std::derived_from, Widget> TabView& tab(std::string name, W&& content) { tabNames_.push_back(std::move(name)); auto p = std::make_unique>(std::move(content)); p->parent = this; children_.push_back(std::move(p)); return *this; } Size Measure(Size avail, const LayoutContext& ctx) override { EdgesPx p = ResolveEdges(padding_, ctx.scale); float tbh = tabBarHeight_ * ctx.scale; float ownW = ResolveLength(width_, avail.w, ctx.scale, [&]{ return avail.w; }); float ownH = ResolveLength(height_, avail.h, ctx.scale, [&]{ return avail.h; }); Size contentAvail = { std::max(0.0f, ownW - p.Horiz()), std::max(0.0f, ownH - p.Vert() - tbh), }; // Measure ALL tab contents (so a switch doesn't trigger a // re-measure). Cheap for typical tab counts. for (auto& c : children_) c->Measure(contentAvail, ctx); desiredSize = {ownW, ownH}; return desiredSize; } void Arrange(Rect rect, const LayoutContext& ctx) override { computedRect = rect; EdgesPx p = ResolveEdges(padding_, ctx.scale); Rect content = ShrinkBy(rect, p); float tbh = tabBarHeight_ * ctx.scale; tabBarBottomYPx_ = content.y + tbh; // The active tab gets the area below the tab bar. Rect contentArea = { content.x, content.y + tbh, content.w, std::max(0.0f, content.h - tbh), }; for (std::size_t i = 0; i < children_.size(); ++i) { if (static_cast(i) == selected_) { children_[i]->Arrange(contentArea, ctx); } else { // Off-screen so nothing draws / hit-tests for inactive tabs. children_[i]->Arrange({0, 0, 0, 0}, ctx); } } } bool OnMouseClick(float x, float y) override { // Only the tab bar consumes clicks; let content-area clicks bubble. if (y < computedRect.y || y >= tabBarBottomYPx_) return false; if (tabNames_.empty()) return false; float tabW = computedRect.w / static_cast(tabNames_.size()); int idx = static_cast((x - computedRect.x) / tabW); idx = std::clamp(idx, 0, static_cast(tabNames_.size()) - 1); selected_ = idx; return true; } void Emit(DrawList& dl) const override { if (tabNames_.empty()) return; float tbh = tabBarBottomYPx_ - computedRect.y; // Whole tab-bar background. dl.AddRect({computedRect.x, computedRect.y, computedRect.w, tbh}, tabBackground_); // Active-tab highlight + labels. float tabW = computedRect.w / static_cast(tabNames_.size()); for (std::size_t i = 0; i < tabNames_.size(); ++i) { float tx = computedRect.x + tabW * static_cast(i); bool active = (static_cast(i) == selected_); if (active) { dl.AddRect({tx, computedRect.y, tabW, tbh}, tabActiveBackground_); } if (font_) { float devSize = tabFontSize_ * dl.scale; float labelW = static_cast(font_->GetLineWidth(tabNames_[i], devSize)); float labelX = tx + (tabW - labelW) * 0.5f; float labelY = computedRect.y + (tbh - font_->LineHeight(devSize)) * 0.5f; Color col = active ? tabActiveTextColor_ : tabTextColor_; detail::EmitText(dl, font_, tabNames_[i], devSize, col, labelX, labelY); } } // Active tab content only. if (selected_ >= 0 && selected_ < static_cast(children_.size())) { children_[selected_]->Emit(dl); } } }; // ─────────────────────────── Button ─────────────────────────────────── // Clickable rectangle with a label. Default padding makes the click // target comfortable; users can override via .padding(...). struct Button : WidgetBuilder