new UI system
This commit is contained in:
parent
d840a81448
commit
216972e73a
82 changed files with 4837 additions and 3243 deletions
989
interfaces/Crafter.Graphics-UIWidgets.cppm
Normal file
989
interfaces/Crafter.Graphics-UIWidgets.cppm
Normal file
|
|
@ -0,0 +1,989 @@
|
|||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue