1049 lines
49 KiB
C++
1049 lines
49 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};
|
|
Observable<std::string>* boundText_ = nullptr;
|
|
|
|
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; }
|
|
|
|
// Bind to an observable; the text is sourced from `obs` each frame
|
|
// (overriding any `runs_`). Useful for live-updating labels driven
|
|
// by a game-state observable.
|
|
Text& bind(Observable<std::string>& obs) { boundText_ = &obs; 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;
|
|
if (boundText_) {
|
|
return static_cast<float>(font_->GetLineWidth(boundText_->Get(), size_ * ctx.scale));
|
|
}
|
|
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;
|
|
if (boundText_) {
|
|
return font_->LineHeight(size_ * ctx.scale);
|
|
}
|
|
// 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;
|
|
if (boundText_) {
|
|
detail::EmitText(dl, font_, boundText_->Get(),
|
|
size_ * dl.scale, color_,
|
|
computedRect.x, computedRect.y);
|
|
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_;
|
|
float scrollX_ = 0.0f; // device-px offset to keep caret in view
|
|
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;
|
|
|
|
// Adjust horizontal scroll so the caret is always visible.
|
|
if (font_) {
|
|
EdgesPx p = ResolveEdges(padding_, ctx.scale);
|
|
float visibleW = std::max(0.0f, rect.w - p.Horiz());
|
|
float devSize = fontSize_ * ctx.scale;
|
|
float caretW = std::max(1.0f, ctx.scale);
|
|
|
|
std::string_view before(text_.data(), cursor_);
|
|
float prefixW = static_cast<float>(font_->GetLineWidth(before, devSize));
|
|
|
|
// If the caret has run off the right edge, scroll right.
|
|
if (prefixW + caretW > scrollX_ + visibleW) {
|
|
scrollX_ = prefixW + caretW - visibleW;
|
|
}
|
|
// If the caret has moved left of the visible window, scroll left.
|
|
if (prefixW < scrollX_) {
|
|
scrollX_ = prefixW;
|
|
}
|
|
// Don't scroll past the start; don't scroll past content end.
|
|
float totalW = static_cast<float>(font_->GetLineWidth(text_, devSize));
|
|
float maxScroll = std::max(0.0f, totalW - visibleW);
|
|
scrollX_ = std::clamp(scrollX_, 0.0f, maxScroll);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
float visibleW = std::max(0.0f, computedRect.w - p.Horiz());
|
|
float visibleH = std::max(0.0f, computedRect.h - p.Vert());
|
|
|
|
// Clip the text + caret to the content rect — once the
|
|
// string overflows we slide it left under scrollX_, and
|
|
// anything past the left/right edges must not draw outside
|
|
// the field.
|
|
dl.PushClip({originX, computedRect.y + p.top, visibleW, visibleH});
|
|
|
|
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 - scrollX_, originY);
|
|
|
|
// Caret bar at the cursor position when focused. Blink at
|
|
// ~530ms on / 530ms off (1.06s period) — standard rate
|
|
// across most desktop apps.
|
|
if (focused_) {
|
|
constexpr float kBlinkPeriod = 1.06f;
|
|
float phase = std::fmod(dl.time, kBlinkPeriod);
|
|
if (phase < kBlinkPeriod * 0.5f) {
|
|
std::string_view before(text_.data(), cursor_);
|
|
float prefixW = static_cast<float>(font_->GetLineWidth(before, devSize));
|
|
float caretX = originX - scrollX_ + prefixW;
|
|
dl.AddRect({caretX, originY, t, font_->LineHeight(devSize)}, focusBorderColor_);
|
|
}
|
|
}
|
|
|
|
dl.PopClip();
|
|
}
|
|
}
|
|
};
|
|
|
|
// ─────────────────────────── 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);
|
|
}
|
|
}
|
|
};
|
|
}
|