animated example

This commit is contained in:
Jorijn van der Graaf 2026-05-02 00:03:24 +02:00
commit c9fd1b1585
17 changed files with 576 additions and 465 deletions

View file

@ -30,9 +30,26 @@ module;
#endif
export module Crafter.Graphics:Device;
import std;
import :Types; // CrafterKeys for keyboard repeat state
export namespace Crafter {
struct Window;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
// Wayland's wl_keyboard.key only fires on real press/release — the
// compositor expects the application to synthesize repeat events
// itself using the rate/delay it advertises via wl_keyboard.repeat_info.
struct KeyRepeatState {
int rate = 25; // chars/sec
int delay = 500; // ms before first repeat
bool active = false;
CrafterKeys key{};
std::string utf8; // UTF-8 to re-emit as onTextInput, if any
std::chrono::time_point<std::chrono::steady_clock> pressTime;
std::chrono::time_point<std::chrono::steady_clock> lastFireTime;
};
#endif
struct Device {
static void Initialize();
@ -131,5 +148,18 @@ export namespace Crafter {
static void CheckVkResult(VkResult result);
static std::uint32_t GetMemoryType(std::uint32_t typeBits, VkMemoryPropertyFlags properties);
// ─── Wayland key repeat ────────────────────────────────────────
// TickKeyRepeats walks the held-key state and fires onKeyDown /
// onTextInput accordingly. Called once per frame from
// Window::Render. KeyRepeatState lives at namespace scope so its
// member initializers don't trip C++'s "complete-type-needed"
// rule for the inline static below.
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
inline static KeyRepeatState keyRepeat;
static void TickKeyRepeats();
#else
static void TickKeyRepeats() {}
#endif
};
}

View file

@ -74,6 +74,7 @@ export namespace Crafter::UI {
FontAtlas* atlas = nullptr;
std::uint32_t bindlessBaseHeapIdx = 0; // base heap slot for Image widgets
float scale = 1.0f; // device scale (mirrors LayoutContext::scale)
float time = 0.0f; // seconds since scene init (drives blink etc.)
void Reset() { items.clear(); }

View file

@ -104,6 +104,7 @@ export namespace Crafter::UI {
std::unique_ptr<EventListener<const std::string_view>> textListener_;
std::unique_ptr<EventListener<CrafterKeys>> keyListener_;
Widget* focused_ = nullptr;
float elapsedSec_ = 0.0f;
float WindowScale() const;
void RebuildFrame();

View file

@ -376,6 +376,7 @@ export namespace Crafter::UI {
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)); }
@ -385,6 +386,11 @@ export namespace Crafter::UI {
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> && ...)
@ -398,6 +404,9 @@ export namespace Crafter::UI {
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;
@ -407,6 +416,9 @@ export namespace Crafter::UI {
});
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_) {
@ -425,6 +437,12 @@ export namespace Crafter::UI {
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;
@ -574,6 +592,7 @@ export namespace Crafter::UI {
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_;
@ -618,8 +637,32 @@ export namespace Crafter::UI {
return desiredSize;
}
void Arrange(Rect rect, const LayoutContext& /*ctx*/) override {
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
@ -709,19 +752,36 @@ export namespace Crafter::UI {
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, originY);
detail::EmitText(dl, font_, show, devSize, col, originX - scrollX_, originY);
// Caret bar at the cursor position when focused.
// 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_) {
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_);
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();
}
}
};