Crafter.Graphics/interfaces/Crafter.Graphics-UIWidgets.cppm
2026-05-01 23:35:37 +02:00

989 lines
46 KiB
C++

/*
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> {
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<VStack> {
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<HStack> {
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<Stack> {
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<float> size_;
std::optional<Color> 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<Text> {
// Bring back the layout `size(Length, Length)` overload — our own
// `size(float)` would otherwise hide it.
using WidgetBuilder<Text>::size;
std::vector<TextRun> 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<typename... Rs>
requires (std::convertible_to<std::decay_t<Rs>, TextRun> && ...)
Text& runs(Rs&&... rs) {
runs_.clear();
runs_.reserve(sizeof...(Rs));
(runs_.emplace_back(std::forward<Rs>(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<Image> {
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<ProgressBar> {
// Bring back the layout `size(Length, Length)` overload — the
// single-arg `value(...)` sets a float; layout sizing uses Length.
using WidgetBuilder<ProgressBar>::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<float>* 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<float>& 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<InputField> {
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<void(const std::string&)> onChange_;
std::function<void(const std::string&)> 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<void(const std::string&)> f) { onChange_ = std::move(f); return *this; }
InputField& onSubmit(std::function<void(const std::string&)> 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<unsigned char>(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<unsigned char>(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<unsigned char>(text_[cursor_]) & 0xC0) != 0x80) break;
}
return true;
}
case CrafterKeys::Right: {
while (cursor_ < text_.size()) {
++cursor_;
if (cursor_ == text_.size() ||
(static_cast<unsigned char>(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<float>(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<ScrollView> {
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<float>::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<TabView> {
std::vector<std::string> 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<typename W>
requires std::derived_from<std::remove_cvref_t<W>, Widget>
TabView& tab(std::string name, W&& content) {
tabNames_.push_back(std::move(name));
auto p = std::make_unique<std::remove_cvref_t<W>>(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<int>(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<float>(tabNames_.size());
int idx = static_cast<int>((x - computedRect.x) / tabW);
idx = std::clamp(idx, 0, static_cast<int>(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<float>(tabNames_.size());
for (std::size_t i = 0; i < tabNames_.size(); ++i) {
float tx = computedRect.x + tabW * static_cast<float>(i);
bool active = (static_cast<int>(i) == selected_);
if (active) {
dl.AddRect({tx, computedRect.y, tabW, tbh}, tabActiveBackground_);
}
if (font_) {
float devSize = tabFontSize_ * dl.scale;
float labelW = static_cast<float>(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<int>(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<Button> {
std::string label_;
std::function<void()> onClick_;
Color background_{0.2f, 0.2f, 0.2f, 1.0f};
Color textColor_{1, 1, 1, 1};
Font* font_ = nullptr;
float fontSize_ = 16.0f;
Button() { padding_ = Edges(8, 12); }
Button(std::string l) : Button() { label_ = std::move(l); }
Button(const char* l) : Button() { label_ = l; }
Button& text(std::string s) { label_ = std::move(s); return *this; }
Button& onClick(std::function<void()> f) { onClick_ = std::move(f); return *this; }
Button& background(Color c) { background_ = c; return *this; }
Button& textColor(Color c) { textColor_ = c; return *this; }
Button& font(Font& f) { font_ = &f; return *this; }
Button& fontSize(float s) { fontSize_ = s; return *this; }
Button& style(const ButtonStyle& s) {
background_ = s.background;
textColor_ = s.textColor;
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 textW = (font_ && !label_.empty()) ? font_->GetLineWidth(label_, devSize) : 0.0f;
float textH = font_ ? font_->LineHeight(devSize) : devSize;
float w = ResolveLength(width_, avail.w, ctx.scale, [&]{ return textW + p.Horiz(); });
float h = ResolveLength(height_, avail.h, ctx.scale, [&]{ return textH + p.Vert(); });
desiredSize = {w, h};
return desiredSize;
}
void Arrange(Rect rect, const LayoutContext& /*ctx*/) override {
computedRect = rect;
}
bool OnMouseClick(float /*x*/, float /*y*/) override {
if (onClick_) { onClick_(); return true; }
return false;
}
void Emit(DrawList& dl) const override {
// Rounded background — corner radius scales with DPI.
std::uint32_t radius = static_cast<std::uint32_t>(std::round(4.0f * dl.scale));
dl.AddRoundRect(computedRect, background_, static_cast<float>(radius));
// Centred label.
if (font_ && !label_.empty()) {
float devSize = fontSize_ * dl.scale;
float labelW = static_cast<float>(font_->GetLineWidth(label_, devSize));
float labelH = font_->LineHeight(devSize);
float originX = computedRect.x + (computedRect.w - labelW) * 0.5f;
float originY = computedRect.y + (computedRect.h - labelH) * 0.5f;
detail::EmitText(dl, font_, label_, devSize, textColor_, originX, originY);
}
}
};
}