1235 lines
52 KiB
C++
1235 lines
52 KiB
C++
#include <algorithm>
|
|
#include <cmath>
|
|
#include <faset/core/io.hpp>
|
|
#include <faset/ui/ui.hpp>
|
|
#include <iomanip>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
|
|
namespace faset::ui {
|
|
bool Rect::contains(float px, float py) const noexcept {
|
|
return px >= x && py >= y && px < x + width && py < y + height;
|
|
}
|
|
Rect Rect::intersection(const Rect& b) const noexcept {
|
|
const auto left = std::max(x, b.x), top = std::max(y, b.y);
|
|
return {left, top, std::max(0.f, std::min(x + width, b.x + b.width) - left),
|
|
std::max(0.f, std::min(y + height, b.y + b.height) - top)};
|
|
}
|
|
namespace {
|
|
Color color(const Json& j, const char* key, Color fallback) {
|
|
if (!j.contains(key))
|
|
return fallback;
|
|
auto value = j.at(key).get<Color>();
|
|
for (float c : value)
|
|
if (!std::isfinite(c) || c < 0 || c > 1)
|
|
throw std::runtime_error("Theme colors require normalized RGBA");
|
|
return value;
|
|
}
|
|
float positive(const Json& j, const char* key, float fallback) {
|
|
float value = j.value(key, fallback);
|
|
if (!std::isfinite(value) || value <= 0 || value > 256)
|
|
throw std::runtime_error("Invalid theme metric");
|
|
return value;
|
|
}
|
|
std::string number(double value, int precision) {
|
|
std::ostringstream out;
|
|
out << std::fixed << std::setprecision(std::clamp(precision, 0, 9)) << value;
|
|
return out.str();
|
|
}
|
|
bool field(const Widget& w) {
|
|
return w.kind == Kind::TextField || w.kind == Kind::NumberField;
|
|
}
|
|
bool focusable(const Widget& w) {
|
|
for (auto* parent = w.parent; parent; parent = parent->parent)
|
|
if (!parent->visible || !parent->enabled)
|
|
return false;
|
|
return w.visible && w.enabled &&
|
|
(field(w) || w.kind == Kind::Button || w.kind == Kind::Checkbox || w.kind == Kind::Tab ||
|
|
w.kind == Kind::TreeRow);
|
|
}
|
|
void collect_focus(Widget& w, std::vector<std::string>& ids) {
|
|
if (!w.visible)
|
|
return;
|
|
if (focusable(w))
|
|
ids.push_back(w.id);
|
|
for (auto& child : w.children)
|
|
collect_focus(*child, ids);
|
|
}
|
|
void fill(render::Snapshot& frame, Rect rect, Color color, const Rect& clip) {
|
|
rect = rect.intersection(clip);
|
|
if (rect.width > 0 && rect.height > 0)
|
|
frame.ui_quads.push_back(
|
|
{rect.x, rect.y, rect.width, rect.height, color, {}, {0, 0, 1, 1}});
|
|
}
|
|
void outline(render::Snapshot& f, const Rect& r, Color c, const Rect& clip, float thickness = 1) {
|
|
fill(f, {r.x, r.y, r.width, thickness}, c, clip);
|
|
fill(f, {r.x, r.y + r.height - thickness, r.width, thickness}, c, clip);
|
|
fill(f, {r.x, r.y, thickness, r.height}, c, clip);
|
|
fill(f, {r.x + r.width - thickness, r.y, thickness, r.height}, c, clip);
|
|
}
|
|
Kind kind_from_string(const std::string& name) {
|
|
static const std::unordered_map<std::string, Kind> kinds = {{"panel", Kind::Panel},
|
|
{"row", Kind::Row},
|
|
{"column", Kind::Column},
|
|
{"label", Kind::Label},
|
|
{"button", Kind::Button},
|
|
{"tab", Kind::Tab},
|
|
{"tree_row", Kind::TreeRow},
|
|
{"text_field", Kind::TextField},
|
|
{"number_field", Kind::NumberField},
|
|
{"checkbox", Kind::Checkbox},
|
|
{"divider", Kind::Divider},
|
|
{"viewport", Kind::Viewport}};
|
|
auto found = kinds.find(name);
|
|
if (found == kinds.end())
|
|
throw std::runtime_error("Unknown widget kind: " + name);
|
|
return found->second;
|
|
}
|
|
Layout parse_layout(const Json& j, Layout l = {}) {
|
|
if (!j.is_object())
|
|
throw std::runtime_error("Layout must be an object");
|
|
#define UI_FLOAT(name) \
|
|
l.name = j.value(#name, l.name); \
|
|
if (!std::isfinite(l.name)) \
|
|
throw std::runtime_error("Non-finite layout metric: " #name)
|
|
UI_FLOAT(width);
|
|
UI_FLOAT(height);
|
|
UI_FLOAT(flex);
|
|
UI_FLOAT(min_width);
|
|
UI_FLOAT(min_height);
|
|
UI_FLOAT(max_width);
|
|
UI_FLOAT(max_height);
|
|
UI_FLOAT(padding);
|
|
UI_FLOAT(gap);
|
|
UI_FLOAT(x);
|
|
UI_FLOAT(y);
|
|
#undef UI_FLOAT
|
|
l.absolute = j.value("absolute", l.absolute);
|
|
l.scroll = j.value("scroll", l.scroll);
|
|
l.clip = j.value("clip", l.clip);
|
|
if (l.flex < 0 || l.padding < 0 || l.gap < 0 || l.min_width < 0 || l.min_height < 0 ||
|
|
l.max_width < l.min_width || l.max_height < l.min_height)
|
|
throw std::runtime_error("Invalid layout constraints");
|
|
return l;
|
|
}
|
|
} // namespace
|
|
Theme Theme::from_json(const Json& j) {
|
|
if (!j.is_object())
|
|
throw std::runtime_error("Theme must be an object");
|
|
Theme t;
|
|
#define UI_COLOR(name) t.name = color(j, #name, t.name)
|
|
UI_COLOR(background);
|
|
UI_COLOR(surface);
|
|
UI_COLOR(raised);
|
|
UI_COLOR(hover);
|
|
UI_COLOR(border);
|
|
UI_COLOR(text);
|
|
UI_COLOR(muted);
|
|
UI_COLOR(accent);
|
|
UI_COLOR(selection);
|
|
UI_COLOR(danger);
|
|
#undef UI_COLOR
|
|
t.font_size = positive(j, "font_size", t.font_size);
|
|
t.row_height = positive(j, "row_height", t.row_height);
|
|
t.padding = positive(j, "padding", t.padding);
|
|
t.gap = positive(j, "gap", t.gap);
|
|
return t;
|
|
}
|
|
Theme Theme::load(const std::filesystem::path& path) {
|
|
return from_json(faset::read_json(path));
|
|
}
|
|
Json Theme::to_json() const {
|
|
return {{"background", background}, {"surface", surface},
|
|
{"raised", raised}, {"hover", hover},
|
|
{"border", border}, {"text", text},
|
|
{"muted", muted}, {"accent", accent},
|
|
{"selection", selection}, {"danger", danger},
|
|
{"font_size", font_size}, {"row_height", row_height},
|
|
{"padding", padding}, {"gap", gap}};
|
|
}
|
|
Widget& Widget::add(Kind type, const std::string& stable_id, const std::string& label) {
|
|
for (auto& child : children)
|
|
if (child->id == stable_id) {
|
|
if (child->kind != type)
|
|
throw std::runtime_error("Widget ID reused with different kind: " + stable_id);
|
|
if (!label.empty())
|
|
child->text = label;
|
|
return *child;
|
|
}
|
|
auto child = std::make_unique<Widget>();
|
|
child->kind = type;
|
|
child->id = stable_id;
|
|
child->text = label;
|
|
child->parent = this;
|
|
children.push_back(std::move(child));
|
|
return *children.back();
|
|
}
|
|
Widget* Widget::find(std::string_view wanted) {
|
|
if (id == wanted)
|
|
return this;
|
|
for (auto& child : children)
|
|
if (auto* result = child->find(wanted))
|
|
return result;
|
|
return nullptr;
|
|
}
|
|
const Widget* Widget::find(std::string_view wanted) const {
|
|
if (id == wanted)
|
|
return this;
|
|
for (const auto& child : children)
|
|
if (auto* result = child->find(wanted))
|
|
return result;
|
|
return nullptr;
|
|
}
|
|
void Widget::remove(std::string_view wanted) {
|
|
std::erase_if(children, [&](const auto& child) { return child->id == wanted; });
|
|
}
|
|
void DockLayout::move(const std::string& panel, const std::string& area, std::size_t index) {
|
|
if (panel.empty() || area.empty())
|
|
throw std::runtime_error("Dock IDs cannot be empty");
|
|
for (auto& [name, panels] : areas_) {
|
|
(void)name;
|
|
std::erase(panels, panel);
|
|
}
|
|
auto& panels = areas_[area];
|
|
panels.insert(panels.begin() + static_cast<std::ptrdiff_t>(std::min(index, panels.size())),
|
|
panel);
|
|
}
|
|
std::vector<std::string> DockLayout::panels(const std::string& area) const {
|
|
auto found = areas_.find(area);
|
|
return found == areas_.end() ? std::vector<std::string>{} : found->second;
|
|
}
|
|
void DockLayout::set_size(const std::string& panel, float size) {
|
|
if (!std::isfinite(size) || size < 24 || size > 10000)
|
|
throw std::runtime_error("Invalid dock panel size");
|
|
sizes_[panel] = size;
|
|
}
|
|
float DockLayout::size(const std::string& panel, float fallback) const {
|
|
auto found = sizes_.find(panel);
|
|
return found == sizes_.end() ? fallback : found->second;
|
|
}
|
|
Json DockLayout::to_json() const {
|
|
return {{"version", 1}, {"areas", areas_}, {"sizes", sizes_}};
|
|
}
|
|
void DockLayout::from_json(const Json& j) {
|
|
if (j.at("version") != 1)
|
|
throw std::runtime_error("Unsupported dock layout version");
|
|
auto areas = j.at("areas").get<decltype(areas_)>();
|
|
auto sizes = j.at("sizes").get<decltype(sizes_)>();
|
|
std::set<std::string> seen;
|
|
for (const auto& [area, panels] : areas) {
|
|
if (area.empty())
|
|
throw std::runtime_error("Empty dock area");
|
|
for (const auto& panel : panels)
|
|
if (panel.empty() || !seen.insert(panel).second)
|
|
throw std::runtime_error("Duplicate dock panel");
|
|
}
|
|
for (const auto& [id, size] : sizes)
|
|
if (id.empty() || !std::isfinite(size) || size < 24 || size > 10000)
|
|
throw std::runtime_error("Invalid dock size");
|
|
areas_ = std::move(areas);
|
|
sizes_ = std::move(sizes);
|
|
}
|
|
void DockLayout::save(const std::filesystem::path& path) const {
|
|
faset::atomic_write_json(path, to_json());
|
|
}
|
|
void DockLayout::load(const std::filesystem::path& path) {
|
|
from_json(faset::read_json(path));
|
|
}
|
|
|
|
struct Context::Impl {
|
|
Widget root;
|
|
Theme theme;
|
|
FontAtlas font;
|
|
float width = 0, height = 0, scale = 1, mouse_x = 0, mouse_y = 0;
|
|
std::string focused, hovered, captured;
|
|
struct Edit {
|
|
TextBuffer buffer;
|
|
std::string original, composition;
|
|
double original_value = 0;
|
|
float scroll = 0;
|
|
int composition_start = 0, composition_length = 0;
|
|
};
|
|
std::unordered_map<std::string, Edit> edits;
|
|
float down_x = 0, down_y = 0;
|
|
double down_value = 0;
|
|
bool dragging = false;
|
|
Json payload;
|
|
float before_size = 0, after_size = 0;
|
|
Layout before_layout, after_layout;
|
|
std::function<std::string()> clipboard_read;
|
|
std::function<void(const std::string&)> clipboard_write;
|
|
std::function<void(bool)> ime_enabled;
|
|
std::function<void(Rect)> ime_rectangle;
|
|
DockLayout* docking = nullptr;
|
|
std::function<void()> docking_changed;
|
|
explicit Impl(const std::filesystem::path& file) : font(file) {
|
|
root.kind = Kind::Column;
|
|
root.id = "root";
|
|
root.layout.gap = 0;
|
|
}
|
|
Widget* find(std::string_view id) {
|
|
return root.find(id);
|
|
}
|
|
float intrinsic(Widget& w, bool horizontal) {
|
|
const auto fixed = horizontal ? w.layout.width : w.layout.height;
|
|
if (fixed >= 0)
|
|
return fixed * scale;
|
|
if (w.kind == Kind::Divider)
|
|
return 5 * scale;
|
|
if (horizontal) {
|
|
if (w.kind == Kind::Label || w.kind == Kind::Button || w.kind == Kind::Tab)
|
|
return font.measure(w.text,
|
|
(w.font_size > 0 ? w.font_size : theme.font_size) * scale) +
|
|
theme.padding * 2 * scale;
|
|
return 80 * scale;
|
|
}
|
|
if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) {
|
|
float total = 0;
|
|
std::size_t count = 0;
|
|
for (auto& child : w.children)
|
|
if (child->visible && !child->layout.absolute) {
|
|
const auto value = intrinsic(*child, false);
|
|
if (w.kind == Kind::Row)
|
|
total = std::max(total, value);
|
|
else
|
|
total += value;
|
|
++count;
|
|
}
|
|
if (w.kind != Kind::Row && count)
|
|
total += float(count - 1) * w.layout.gap * scale;
|
|
return total + w.layout.padding * 2 * scale;
|
|
}
|
|
return theme.row_height * scale;
|
|
}
|
|
void arrange(Widget& w, Rect rect, Rect clip) {
|
|
w.rect = rect;
|
|
w.clip = w.layout.clip ? clip.intersection(rect) : clip;
|
|
if (!w.visible)
|
|
return;
|
|
const auto pad = w.layout.padding * scale;
|
|
Rect inner{rect.x + pad, rect.y + pad, std::max(0.f, rect.width - 2 * pad),
|
|
std::max(0.f, rect.height - 2 * pad)};
|
|
const bool horizontal = w.kind == Kind::Row;
|
|
const float available = horizontal ? inner.width : inner.height;
|
|
std::vector<Widget*> flow;
|
|
float fixed = 0, flex = 0;
|
|
for (auto& child : w.children)
|
|
if (child->visible && !child->layout.absolute) {
|
|
flow.push_back(child.get());
|
|
if (child->layout.flex > 0) {
|
|
flex += child->layout.flex;
|
|
fixed +=
|
|
(horizontal ? child->layout.min_width : child->layout.min_height) * scale;
|
|
} else
|
|
fixed += intrinsic(*child, horizontal);
|
|
}
|
|
if (!flow.empty())
|
|
fixed += float(flow.size() - 1) * w.layout.gap * scale;
|
|
const auto extra = std::max(0.f, available - fixed);
|
|
std::vector<float> extents;
|
|
float content = 0;
|
|
for (auto* child : flow) {
|
|
auto extent =
|
|
child->layout.flex > 0
|
|
? (horizontal ? child->layout.min_width : child->layout.min_height) * scale +
|
|
extra * child->layout.flex / std::max(.001f, flex)
|
|
: intrinsic(*child, horizontal);
|
|
extent = std::clamp(
|
|
extent, (horizontal ? child->layout.min_width : child->layout.min_height) * scale,
|
|
(horizontal ? child->layout.max_width : child->layout.max_height) * scale);
|
|
extents.push_back(extent);
|
|
content += extent;
|
|
}
|
|
if (!flow.empty())
|
|
content += float(flow.size() - 1) * w.layout.gap * scale;
|
|
w.content_height = horizontal ? inner.height : content;
|
|
w.scroll_y = std::clamp(w.scroll_y, 0.f, std::max(0.f, w.content_height - inner.height));
|
|
float position = horizontal ? inner.x : inner.y - (w.layout.scroll ? w.scroll_y : 0);
|
|
for (std::size_t i = 0; i < flow.size(); ++i) {
|
|
auto& child = *flow[i];
|
|
float cross =
|
|
horizontal ? (child.layout.height >= 0 ? child.layout.height * scale : inner.height)
|
|
: (child.layout.width >= 0 ? child.layout.width * scale : inner.width);
|
|
cross = std::clamp(
|
|
cross, (horizontal ? child.layout.min_height : child.layout.min_width) * scale,
|
|
(horizontal ? child.layout.max_height : child.layout.max_width) * scale);
|
|
Rect target = horizontal ? Rect{position, inner.y, extents[i], cross}
|
|
: Rect{inner.x, position, cross, extents[i]};
|
|
arrange(child, target, w.clip.intersection(inner));
|
|
position += extents[i] + w.layout.gap * scale;
|
|
}
|
|
for (auto& child : w.children)
|
|
if (child->visible && child->layout.absolute)
|
|
arrange(*child,
|
|
{inner.x + child->layout.x * scale, inner.y + child->layout.y * scale,
|
|
child->layout.width >= 0 ? child->layout.width * scale : inner.width,
|
|
child->layout.height >= 0 ? child->layout.height * scale
|
|
: intrinsic(*child, false)},
|
|
w.clip.intersection(inner));
|
|
}
|
|
Widget* hit(Widget& w, float x, float y) {
|
|
if (!w.visible || !w.clip.contains(x, y))
|
|
return nullptr;
|
|
if (!w.enabled)
|
|
return w.rect.contains(x, y) ? &w : nullptr;
|
|
for (auto child = w.children.rbegin(); child != w.children.rend(); ++child)
|
|
if (auto* target = hit(**child, x, y))
|
|
return target;
|
|
return w.rect.contains(x, y) ? &w : nullptr;
|
|
}
|
|
bool commit() {
|
|
if (focused.empty())
|
|
return true;
|
|
auto* widget = find(focused);
|
|
auto found = edits.find(focused);
|
|
if (!widget || !field(*widget) || found == edits.end())
|
|
return true;
|
|
auto& edit = found->second;
|
|
if (!edit.composition.empty())
|
|
return false;
|
|
const bool changed = edit.buffer.text() != edit.original;
|
|
if (widget->kind == Kind::NumberField) {
|
|
try {
|
|
std::size_t end = 0;
|
|
const auto value = std::stod(edit.buffer.text(), &end);
|
|
if (end != edit.buffer.text().size() || !std::isfinite(value))
|
|
throw std::runtime_error("number");
|
|
widget->value = value;
|
|
} catch (...) {
|
|
widget->error = "Enter a finite number";
|
|
return false;
|
|
}
|
|
}
|
|
widget->error.clear();
|
|
widget->text = edit.buffer.text();
|
|
edit.original = edit.buffer.text();
|
|
edit.original_value = widget->value;
|
|
const auto cursor = edit.buffer.cursor();
|
|
edit.buffer.reset(edit.original);
|
|
edit.buffer.set_cursor(cursor);
|
|
auto callback = widget->on_commit;
|
|
if (changed && callback)
|
|
callback(*widget);
|
|
return true;
|
|
}
|
|
void unfocus(bool save) {
|
|
if (save && !commit())
|
|
return;
|
|
focused.clear();
|
|
if (ime_enabled)
|
|
ime_enabled(false);
|
|
}
|
|
void reveal(Widget& widget) {
|
|
// Reveal inside-out: an offscreen nested scroller still needs its own
|
|
// contents positioned before an outer scroller brings it into view.
|
|
auto* branch = &widget;
|
|
for (auto* parent = widget.parent; parent; branch = parent, parent = parent->parent) {
|
|
if (!parent->layout.scroll || parent->kind == Kind::Row || branch->layout.absolute)
|
|
continue;
|
|
const float padding = parent->layout.padding * scale;
|
|
const float top = parent->rect.y + padding;
|
|
const float extent = std::max(0.f, parent->rect.height - 2 * padding);
|
|
if (extent <= 0)
|
|
continue;
|
|
float delta = 0;
|
|
if (widget.rect.y < top || widget.rect.height > extent)
|
|
delta = widget.rect.y - top;
|
|
else if (widget.rect.y + widget.rect.height > top + extent)
|
|
delta = widget.rect.y + widget.rect.height - top - extent;
|
|
const float scroll = std::clamp(parent->scroll_y + delta, 0.f,
|
|
std::max(0.f, parent->content_height - extent));
|
|
if (scroll != parent->scroll_y) {
|
|
parent->scroll_y = scroll;
|
|
arrange(root, {0, 0, width, height}, {0, 0, width, height});
|
|
}
|
|
}
|
|
}
|
|
bool focus(const std::string& id) {
|
|
auto* widget = find(id);
|
|
if (!widget || !focusable(*widget))
|
|
return false;
|
|
if (focused == id) {
|
|
reveal(*widget);
|
|
if (field(*widget) && ime_rectangle)
|
|
ime_rectangle(widget->rect);
|
|
return true;
|
|
}
|
|
if (!commit())
|
|
return false;
|
|
// A commit callback may reconcile the retained tree.
|
|
widget = find(id);
|
|
if (!widget || !focusable(*widget))
|
|
return false;
|
|
focused = id;
|
|
reveal(*widget);
|
|
if (field(*widget)) {
|
|
auto& edit = edits[id];
|
|
const auto text = widget->kind == Kind::NumberField
|
|
? number(widget->value, widget->precision)
|
|
: widget->text;
|
|
edit.buffer.reset(text);
|
|
edit.original = text;
|
|
edit.original_value = widget->value;
|
|
edit.composition.clear();
|
|
edit.scroll = 0;
|
|
if (widget->kind == Kind::NumberField)
|
|
edit.buffer.select_all();
|
|
if (ime_enabled)
|
|
ime_enabled(true);
|
|
if (ime_rectangle)
|
|
ime_rectangle(widget->rect);
|
|
} else if (ime_enabled)
|
|
ime_enabled(false);
|
|
return true;
|
|
}
|
|
void preview() {
|
|
auto* widget = find(focused);
|
|
auto found = edits.find(focused);
|
|
if (!widget || found == edits.end())
|
|
return;
|
|
widget->text = found->second.buffer.text();
|
|
widget->error.clear();
|
|
auto callback = widget->on_preview;
|
|
if (callback)
|
|
callback(*widget);
|
|
}
|
|
std::size_t text_position(const Widget& w, const Edit& e, float x) {
|
|
const auto local = x - w.rect.x - theme.padding * scale + e.scroll;
|
|
float previous_width = 0;
|
|
std::size_t previous_byte = 0;
|
|
for (std::size_t i = 0; i < e.buffer.text().size();) {
|
|
++i;
|
|
while (i < e.buffer.text().size() &&
|
|
(static_cast<unsigned char>(e.buffer.text()[i]) & 0xc0) == 0x80)
|
|
++i;
|
|
const auto width =
|
|
font.measure(std::string_view(e.buffer.text()).substr(0, i),
|
|
(w.font_size > 0 ? w.font_size : theme.font_size) * scale);
|
|
if (local < (previous_width + width) * .5f)
|
|
return previous_byte;
|
|
previous_width = width;
|
|
previous_byte = i;
|
|
}
|
|
return e.buffer.text().size();
|
|
}
|
|
std::pair<Widget*, Widget*> neighbors(Widget& divider) {
|
|
if (!divider.parent)
|
|
return {};
|
|
auto& list = divider.parent->children;
|
|
for (std::size_t i = 1; i + 1 < list.size(); ++i)
|
|
if (list[i].get() == ÷r)
|
|
return {list[i - 1].get(), list[i + 1].get()};
|
|
return {};
|
|
}
|
|
void cancel_capture() {
|
|
if (auto* w = find(captured); w && w->kind == Kind::NumberField && dragging) {
|
|
w->value = down_value;
|
|
auto& edit = edits[w->id];
|
|
edit.buffer.reset(number(w->value, w->precision));
|
|
w->text = edit.buffer.text();
|
|
auto callback = w->on_preview;
|
|
if (callback)
|
|
callback(*w);
|
|
}
|
|
if (auto* w = find(captured); w && w->kind == Kind::Divider && dragging) {
|
|
auto [before, after] = neighbors(*w);
|
|
if (before && after) {
|
|
before->layout = before_layout;
|
|
after->layout = after_layout;
|
|
arrange(root, {0, 0, width, height}, {0, 0, width, height});
|
|
}
|
|
}
|
|
if (auto* w = find(captured); w && dragging) {
|
|
auto callback = w->on_cancel;
|
|
if (callback)
|
|
callback(*w);
|
|
}
|
|
captured.clear();
|
|
payload = nullptr;
|
|
dragging = false;
|
|
}
|
|
void draw_widget(Widget& w, render::Snapshot& frame) {
|
|
if (!w.visible || w.clip.width <= 0 || w.clip.height <= 0)
|
|
return;
|
|
const bool hover = w.id == hovered, focus = w.id == focused;
|
|
auto ink = w.enabled ? theme.text : theme.muted;
|
|
const auto& rect = w.rect;
|
|
if (w.kind == Kind::Panel) {
|
|
fill(frame, rect, theme.surface, w.clip);
|
|
outline(frame, rect, theme.border, w.clip);
|
|
} else if (w.kind == Kind::Button) {
|
|
fill(frame, rect,
|
|
hover && w.enabled ? theme.hover
|
|
: w.selected ? theme.selection
|
|
: theme.raised,
|
|
w.clip);
|
|
outline(frame, rect, focus ? theme.accent : theme.border, w.clip);
|
|
} else if (w.kind == Kind::Tab || w.kind == Kind::TreeRow) {
|
|
if (w.selected || hover)
|
|
fill(frame, rect, w.selected ? theme.selection : theme.hover, w.clip);
|
|
if (w.selected) {
|
|
if (w.kind == Kind::Tab)
|
|
fill(frame, {rect.x, rect.y + rect.height - 2 * scale, rect.width, 2 * scale},
|
|
theme.accent, w.clip);
|
|
else
|
|
fill(frame, {rect.x, rect.y, 2 * scale, rect.height}, theme.accent, w.clip);
|
|
}
|
|
if (focus)
|
|
outline(frame, rect, theme.accent, w.clip);
|
|
} else if (field(w)) {
|
|
fill(frame, rect, theme.background, w.clip);
|
|
outline(frame, rect,
|
|
!w.error.empty() ? theme.danger
|
|
: focus ? theme.accent
|
|
: theme.border,
|
|
w.clip);
|
|
} else if (w.kind == Kind::Divider) {
|
|
fill(frame, rect, hover || w.id == captured ? theme.accent : theme.background, w.clip);
|
|
}
|
|
float tx = rect.x + theme.padding * scale + w.indent * 14 * scale;
|
|
const auto font_size = (w.font_size > 0 ? w.font_size : theme.font_size) * scale;
|
|
const auto ty = rect.y + std::max(0.f, (rect.height - font_size * 1.45f) * .5f);
|
|
if (w.kind == Kind::Checkbox) {
|
|
Rect box{tx, rect.y + (rect.height - 14 * scale) / 2, 14 * scale, 14 * scale};
|
|
fill(frame, box, theme.background, w.clip);
|
|
outline(frame, box, focus ? theme.accent : theme.border, w.clip);
|
|
if (w.checked)
|
|
fill(frame, {box.x + 3 * scale, box.y + 3 * scale, 8 * scale, 8 * scale},
|
|
theme.accent, w.clip);
|
|
tx += 23 * scale;
|
|
}
|
|
if (field(w) && focus && edits.contains(w.id)) {
|
|
auto& edit = edits[w.id];
|
|
const auto available = std::max(1.f, rect.width - theme.padding * 2 * scale);
|
|
const auto selection_begin = std::min(edit.buffer.cursor(), edit.buffer.anchor());
|
|
const auto selection_end = std::max(edit.buffer.cursor(), edit.buffer.anchor());
|
|
auto composition_byte = [&](int codepoints) {
|
|
std::size_t byte = 0;
|
|
for (int i = 0; i < std::max(0, codepoints) && byte < edit.composition.size();
|
|
++i) {
|
|
++byte;
|
|
while (byte < edit.composition.size() &&
|
|
(static_cast<unsigned char>(edit.composition[byte]) & 0xc0) == 0x80)
|
|
++byte;
|
|
}
|
|
return byte;
|
|
};
|
|
const auto prefix = edit.buffer.text().substr(0, selection_begin);
|
|
const auto composition_left = font.measure(prefix, font_size);
|
|
const auto composition_cursor = composition_byte(edit.composition_start);
|
|
const auto caret =
|
|
edit.composition.empty()
|
|
? font.measure(
|
|
std::string_view(edit.buffer.text()).substr(0, edit.buffer.cursor()),
|
|
font_size)
|
|
: composition_left +
|
|
font.measure(
|
|
std::string_view(edit.composition).substr(0, composition_cursor),
|
|
font_size);
|
|
if (caret - edit.scroll > available - 2 * scale)
|
|
edit.scroll = caret - available + 2 * scale;
|
|
if (caret < edit.scroll)
|
|
edit.scroll = caret;
|
|
edit.scroll = std::max(0.f, edit.scroll);
|
|
const Rect text_clip =
|
|
w.clip.intersection({rect.x + 3 * scale, rect.y + 2 * scale, rect.width - 6 * scale,
|
|
rect.height - 4 * scale});
|
|
tx -= edit.scroll;
|
|
if (edit.buffer.has_selection() && edit.composition.empty()) {
|
|
const auto begin = std::min(edit.buffer.cursor(), edit.buffer.anchor()),
|
|
end = std::max(edit.buffer.cursor(), edit.buffer.anchor());
|
|
const auto left = font.measure(
|
|
std::string_view(edit.buffer.text()).substr(0, begin), font_size),
|
|
right = font.measure(std::string_view(edit.buffer.text()).substr(0, end),
|
|
font_size);
|
|
fill(frame, {tx + left, rect.y + 4 * scale, right - left, rect.height - 8 * scale},
|
|
theme.selection, text_clip);
|
|
}
|
|
if (edit.composition.empty()) {
|
|
font.draw(frame, edit.buffer.text(), tx, ty, font_size, ink, text_clip);
|
|
} else {
|
|
const auto composition_width = font.measure(edit.composition, font_size);
|
|
const auto selection_width = font.measure(
|
|
std::string_view(edit.composition)
|
|
.substr(composition_cursor,
|
|
composition_byte(edit.composition_start +
|
|
std::max(0, edit.composition_length)) -
|
|
composition_cursor),
|
|
font_size);
|
|
if (selection_width > 0)
|
|
fill(frame,
|
|
{tx + caret, rect.y + 4 * scale, selection_width, rect.height - 8 * scale},
|
|
theme.selection, text_clip);
|
|
font.draw(frame, prefix, tx, ty, font_size, ink, text_clip);
|
|
font.draw(frame, edit.composition, tx + composition_left, ty, font_size,
|
|
theme.accent, text_clip);
|
|
font.draw(frame, std::string_view(edit.buffer.text()).substr(selection_end),
|
|
tx + composition_left + composition_width, ty, font_size, ink, text_clip);
|
|
fill(frame,
|
|
{tx + composition_left, rect.y + rect.height - 4 * scale, composition_width,
|
|
scale},
|
|
theme.accent, text_clip);
|
|
}
|
|
fill(frame, {tx + caret, rect.y + 5 * scale, scale, rect.height - 10 * scale},
|
|
theme.accent, text_clip);
|
|
} else if (w.kind != Kind::Divider && w.kind != Kind::Viewport && !w.text.empty())
|
|
font.draw(frame, w.kind == Kind::NumberField ? number(w.value, w.precision) : w.text,
|
|
tx, ty, font_size, ink,
|
|
w.clip.intersection(
|
|
{rect.x + 2 * scale, rect.y, rect.width - 4 * scale, rect.height}));
|
|
else if (w.kind == Kind::NumberField)
|
|
font.draw(frame, number(w.value, w.precision), tx, ty, font_size, ink, w.clip);
|
|
for (auto& child : w.children)
|
|
draw_widget(*child, frame);
|
|
if (w.layout.scroll && w.content_height > rect.height) {
|
|
const float track = rect.height - 8 * scale,
|
|
thumb = std::max(16 * scale, track * rect.height / w.content_height),
|
|
offset = (track - thumb) * w.scroll_y /
|
|
std::max(1.f, w.content_height - rect.height);
|
|
fill(frame,
|
|
{rect.x + rect.width - 5 * scale, rect.y + 4 * scale + offset, 3 * scale, thumb},
|
|
theme.border, w.clip);
|
|
}
|
|
}
|
|
};
|
|
|
|
Context::Context(const std::filesystem::path& font) : impl_(std::make_unique<Impl>(font)) {}
|
|
Context::~Context() = default;
|
|
Widget& Context::root() {
|
|
return impl_->root;
|
|
}
|
|
Widget* Context::find(std::string_view id) {
|
|
return impl_->find(id);
|
|
}
|
|
void Context::set_theme(Theme theme) {
|
|
impl_->theme = theme;
|
|
}
|
|
const Theme& Context::theme() const {
|
|
return impl_->theme;
|
|
}
|
|
FontAtlas& Context::font() {
|
|
return impl_->font;
|
|
}
|
|
void Context::validate_layout(const Json& document) const {
|
|
if (!document.is_object())
|
|
throw std::runtime_error("Layout must be an object");
|
|
const auto& definition = document.contains("root") ? document.at("root") : document;
|
|
if (definition.at("id") != impl_->root.id)
|
|
throw std::runtime_error("Layout root ID must match retained root");
|
|
std::set<std::string> ids;
|
|
std::function<void(const Json&, const std::string&)> validate = [&](const Json& j,
|
|
const std::string& parent) {
|
|
const auto id = j.at("id").get<std::string>();
|
|
if (id.empty() || !ids.insert(id).second)
|
|
throw std::runtime_error("Duplicate layout widget ID");
|
|
const auto* existing = impl_->find(id);
|
|
if (existing && ((existing->parent ? existing->parent->id : std::string()) != parent))
|
|
throw std::runtime_error("Hot layout cannot reparent existing widget: " + id);
|
|
if (j.contains("kind")) {
|
|
const auto type = kind_from_string(j.at("kind"));
|
|
if (existing && existing->kind != type)
|
|
throw std::runtime_error("Hot layout cannot replace widget kind");
|
|
}
|
|
if (j.contains("text") &&
|
|
(!j.at("text").is_string() || !TextBuffer::valid_utf8(j.at("text").get<std::string>())))
|
|
throw std::runtime_error("Widget text must be valid UTF-8");
|
|
if (j.contains("layout"))
|
|
parse_layout(j.at("layout"), existing ? existing->layout : Layout{});
|
|
if (j.contains("font_size")) {
|
|
const auto size = j.at("font_size").get<float>();
|
|
if (!std::isfinite(size) || size < 0 || size > 128)
|
|
throw std::runtime_error("Invalid widget font size");
|
|
}
|
|
if (j.contains("children")) {
|
|
if (!j.at("children").is_array())
|
|
throw std::runtime_error("Layout children must be an array");
|
|
for (const auto& child : j.at("children"))
|
|
validate(child, id);
|
|
}
|
|
};
|
|
validate(definition, "");
|
|
}
|
|
void Context::apply_layout(const Json& document) {
|
|
validate_layout(document);
|
|
const auto& definition = document.contains("root") ? document.at("root") : document;
|
|
std::function<void(Widget&, const Json&)> apply = [&](Widget& w, const Json& j) {
|
|
if (j.contains("text"))
|
|
update_text(w.id, j.at("text"));
|
|
if (j.contains("layout"))
|
|
w.layout = parse_layout(j.at("layout"), w.layout);
|
|
if (j.contains("font_size"))
|
|
w.font_size = j.at("font_size");
|
|
if (j.contains("children"))
|
|
for (const auto& child : j.at("children")) {
|
|
const auto id = child.at("id").get<std::string>();
|
|
auto& target =
|
|
w.add(child.contains("kind") ? kind_from_string(child.at("kind"))
|
|
: (w.find(id) ? w.find(id)->kind : Kind::Panel),
|
|
id);
|
|
apply(target, child);
|
|
}
|
|
};
|
|
apply(root(), definition);
|
|
}
|
|
void Context::layout(float width, float height, float scale) {
|
|
if (!std::isfinite(width) || !std::isfinite(height) || !std::isfinite(scale) || scale <= 0)
|
|
throw std::invalid_argument("UI dimensions and display scale must be finite and valid");
|
|
scale = std::clamp(scale, .5f, 4.f);
|
|
if (impl_->scale != scale) {
|
|
// Pointer captures use the old drawable coordinate system. Keep edits,
|
|
// but cancel a drag rather than committing a jump after a monitor change.
|
|
impl_->cancel_capture();
|
|
const auto ratio = scale / impl_->scale;
|
|
std::function<void(Widget&)> rescale = [&](Widget& widget) {
|
|
widget.scroll_y *= ratio;
|
|
for (auto& child : widget.children)
|
|
rescale(*child);
|
|
};
|
|
rescale(root());
|
|
for (auto& [id, edit] : impl_->edits)
|
|
edit.scroll *= ratio;
|
|
}
|
|
impl_->width = std::max(0.f, width);
|
|
impl_->height = std::max(0.f, height);
|
|
impl_->scale = scale;
|
|
std::set<std::string> ids;
|
|
std::function<void(Widget&)> check = [&](Widget& w) {
|
|
if (w.id.empty() || !ids.insert(w.id).second)
|
|
throw std::runtime_error("Retained widget IDs must be unique");
|
|
for (auto& child : w.children) {
|
|
child->parent = &w;
|
|
check(*child);
|
|
}
|
|
};
|
|
check(root());
|
|
impl_->arrange(root(), {0, 0, impl_->width, impl_->height},
|
|
{0, 0, impl_->width, impl_->height});
|
|
std::erase_if(impl_->edits, [&](const auto& entry) { return !ids.contains(entry.first); });
|
|
auto* focused = impl_->find(impl_->focused);
|
|
if (!focused || !focusable(*focused))
|
|
impl_->unfocus(false);
|
|
else if (field(*focused) && impl_->ime_rectangle)
|
|
impl_->ime_rectangle(focused->rect);
|
|
}
|
|
void Context::draw(render::Snapshot& snapshot) {
|
|
impl_->draw_widget(root(), snapshot);
|
|
if (!impl_->payload.is_null()) {
|
|
const Rect viewport{0, 0, impl_->width, impl_->height};
|
|
const auto text =
|
|
impl_->payload.value("label", impl_->payload.value("panel", std::string("Move")));
|
|
const auto width =
|
|
impl_->font.measure(text, impl_->theme.font_size * impl_->scale) + 20 * impl_->scale;
|
|
const Rect rect{impl_->mouse_x + 12 * impl_->scale, impl_->mouse_y + 12 * impl_->scale,
|
|
width, 28 * impl_->scale};
|
|
fill(snapshot, rect, impl_->theme.raised, viewport);
|
|
outline(snapshot, rect, impl_->theme.accent, viewport);
|
|
impl_->font.draw(snapshot, text, rect.x + 8 * impl_->scale, rect.y + 3 * impl_->scale,
|
|
impl_->theme.font_size * impl_->scale, impl_->theme.text, viewport);
|
|
}
|
|
}
|
|
bool Context::update_text(const std::string& id, const std::string& value, bool force) {
|
|
auto* w = find(id);
|
|
if (!w)
|
|
return false;
|
|
if (!TextBuffer::valid_utf8(value))
|
|
return false;
|
|
if (impl_->focused == id && impl_->edits.contains(id)) {
|
|
auto& edit = impl_->edits[id];
|
|
if (!force && (edit.buffer.text() != edit.original || !edit.composition.empty()))
|
|
return false;
|
|
const auto cursor = edit.buffer.cursor();
|
|
edit.buffer.reset(value);
|
|
edit.buffer.set_cursor(cursor);
|
|
edit.original = value;
|
|
edit.composition.clear();
|
|
}
|
|
w->text = value;
|
|
return true;
|
|
}
|
|
bool Context::update_number(const std::string& id, double value, bool force) {
|
|
auto* widget = find(id);
|
|
if (!widget || widget->kind != Kind::NumberField || !std::isfinite(value))
|
|
return false;
|
|
if (!update_text(id, number(value, widget->precision), force))
|
|
return false;
|
|
widget->value = value;
|
|
if (impl_->focused == id && impl_->edits.contains(id))
|
|
impl_->edits[id].original_value = value;
|
|
return true;
|
|
}
|
|
bool Context::focus(const std::string& id) {
|
|
return impl_->focus(id);
|
|
}
|
|
const std::string& Context::focused_id() const {
|
|
return impl_->focused;
|
|
}
|
|
void Context::clear_focus(bool commit) {
|
|
impl_->unfocus(commit);
|
|
}
|
|
bool Context::editing() const {
|
|
const auto* w = impl_->find(impl_->focused);
|
|
return w && field(*w);
|
|
}
|
|
void Context::set_clipboard(std::function<std::string()> read,
|
|
std::function<void(const std::string&)> write) {
|
|
impl_->clipboard_read = std::move(read);
|
|
impl_->clipboard_write = std::move(write);
|
|
}
|
|
void Context::set_ime(std::function<void(bool)> enabled, std::function<void(Rect)> rectangle) {
|
|
impl_->ime_enabled = std::move(enabled);
|
|
impl_->ime_rectangle = std::move(rectangle);
|
|
}
|
|
void Context::set_docking(DockLayout* docking, std::function<void()> changed) {
|
|
impl_->docking = docking;
|
|
impl_->docking_changed = std::move(changed);
|
|
}
|
|
|
|
bool Context::handle(const render::Event& event) {
|
|
auto& p = *impl_;
|
|
using Type = render::Event::Type;
|
|
if (event.type == Type::FocusLost) {
|
|
p.cancel_capture();
|
|
if (auto found = p.edits.find(p.focused); found != p.edits.end())
|
|
found->second.composition.clear();
|
|
p.unfocus(true);
|
|
return false;
|
|
}
|
|
if (event.type == Type::MouseMove || event.type == Type::MouseDown ||
|
|
event.type == Type::MouseUp) {
|
|
p.mouse_x = event.x;
|
|
p.mouse_y = event.y;
|
|
auto* hit = p.hit(p.root, event.x, event.y);
|
|
p.hovered = hit ? hit->id : "";
|
|
}
|
|
if (event.type == Type::Wheel) {
|
|
auto* w = p.hit(p.root, p.mouse_x, p.mouse_y);
|
|
for (; w; w = w->parent)
|
|
if (w->layout.scroll) {
|
|
w->scroll_y =
|
|
std::clamp(w->scroll_y - event.y * p.theme.row_height * 3 * p.scale, 0.f,
|
|
std::max(0.f, w->content_height - w->rect.height +
|
|
2 * w->layout.padding * p.scale));
|
|
layout(p.width, p.height, p.scale);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (event.type == Type::MouseDown && event.button == 1) {
|
|
auto* w = p.hit(p.root, event.x, event.y);
|
|
if (!w || w->kind == Kind::Viewport) {
|
|
p.unfocus(true);
|
|
return false;
|
|
}
|
|
if (!w->enabled)
|
|
return true;
|
|
if (focusable(*w)) {
|
|
if (!p.focus(w->id))
|
|
return true;
|
|
} else {
|
|
if (!p.commit())
|
|
return true;
|
|
p.unfocus(false);
|
|
}
|
|
p.captured = w->id;
|
|
p.down_x = event.x;
|
|
p.down_y = event.y;
|
|
p.down_value = w->value;
|
|
p.dragging = false;
|
|
p.payload = nullptr;
|
|
if (w->kind == Kind::TextField) {
|
|
auto& edit = p.edits[w->id];
|
|
edit.buffer.set_cursor(p.text_position(*w, edit, event.x), event.shift);
|
|
}
|
|
if (w->kind == Kind::Divider) {
|
|
auto [before, after] = p.neighbors(*w);
|
|
if (before && after) {
|
|
const bool horizontal = w->parent->kind == Kind::Row;
|
|
p.before_size = (horizontal ? before->rect.width : before->rect.height) / p.scale;
|
|
p.after_size = (horizontal ? after->rect.width : after->rect.height) / p.scale;
|
|
p.before_layout = before->layout;
|
|
p.after_layout = after->layout;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
if (event.type == Type::MouseMove && !p.captured.empty()) {
|
|
auto* w = p.find(p.captured);
|
|
if (!w) {
|
|
p.cancel_capture();
|
|
return false;
|
|
}
|
|
const auto distance = std::hypot(event.x - p.down_x, event.y - p.down_y);
|
|
if (w->kind == Kind::NumberField && distance > 3 * p.scale) {
|
|
p.dragging = true;
|
|
w->value =
|
|
p.down_value + (event.x - p.down_x) / p.scale * w->step * (event.shift ? .1 : 1.0);
|
|
auto& edit = p.edits[w->id];
|
|
edit.buffer.reset(number(w->value, w->precision));
|
|
w->text = edit.buffer.text();
|
|
auto callback = w->on_preview;
|
|
if (callback)
|
|
callback(*w);
|
|
return true;
|
|
}
|
|
if (w->kind == Kind::TextField) {
|
|
auto& edit = p.edits[w->id];
|
|
edit.buffer.set_cursor(p.text_position(*w, edit, event.x), true);
|
|
return true;
|
|
}
|
|
if (w->kind == Kind::Divider) {
|
|
auto [before, after] = p.neighbors(*w);
|
|
if (before && after) {
|
|
const bool horizontal = w->parent->kind == Kind::Row;
|
|
const auto delta = (horizontal ? event.x - p.down_x : event.y - p.down_y) / p.scale;
|
|
const auto minimum_before = std::max(24.f, horizontal ? before->layout.min_width
|
|
: before->layout.min_height),
|
|
minimum_after = std::max(24.f, horizontal ? after->layout.min_width
|
|
: after->layout.min_height);
|
|
const auto low = minimum_before - p.before_size,
|
|
high = p.after_size - minimum_after;
|
|
const auto amount = low <= high ? std::clamp(delta, low, high) : 0.f;
|
|
if (before->layout.flex <= 0) {
|
|
if (horizontal)
|
|
before->layout.width = p.before_size + amount;
|
|
else
|
|
before->layout.height = p.before_size + amount;
|
|
}
|
|
if (after->layout.flex <= 0) {
|
|
if (horizontal)
|
|
after->layout.width = p.after_size - amount;
|
|
else
|
|
after->layout.height = p.after_size - amount;
|
|
}
|
|
if (before->layout.flex > 0 && after->layout.flex > 0) {
|
|
before->layout.flex = p.before_size + amount;
|
|
after->layout.flex = p.after_size - amount;
|
|
}
|
|
p.dragging = std::abs(amount) > .01f;
|
|
w->value = p.before_size + amount;
|
|
auto callback = w->on_preview;
|
|
if (callback)
|
|
callback(*w);
|
|
layout(p.width, p.height, p.scale);
|
|
}
|
|
return true;
|
|
}
|
|
if (distance > 5 * p.scale) {
|
|
if (!w->dock_panel.empty()) {
|
|
p.payload = {{"kind", "dock_panel"}, {"panel", w->dock_panel}, {"label", w->text}};
|
|
p.dragging = true;
|
|
} else if (!w->drag_payload.is_null()) {
|
|
p.payload = w->drag_payload;
|
|
p.dragging = true;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
if (event.type == Type::MouseUp && event.button == 1 && !p.captured.empty()) {
|
|
const auto id = p.captured;
|
|
auto* w = p.find(id);
|
|
const auto dragging = p.dragging;
|
|
auto payload = p.payload;
|
|
p.captured.clear();
|
|
p.payload = nullptr;
|
|
p.dragging = false;
|
|
if (!w)
|
|
return true;
|
|
if (!payload.is_null()) {
|
|
auto* target = p.hit(p.root, event.x, event.y);
|
|
for (; target; target = target->parent) {
|
|
if (p.docking && payload.value("kind", std::string()) == "dock_panel" &&
|
|
!target->dock_area.empty()) {
|
|
auto order = p.docking->panels(target->dock_area);
|
|
auto found = std::find(order.begin(), order.end(), target->dock_panel);
|
|
p.docking->move(payload.at("panel"), target->dock_area,
|
|
static_cast<std::size_t>(found - order.begin()));
|
|
if (p.docking_changed)
|
|
p.docking_changed();
|
|
break;
|
|
}
|
|
if (target->on_drop) {
|
|
auto callback = target->on_drop;
|
|
callback(*target, payload);
|
|
break;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
if (w->kind == Kind::NumberField && dragging) {
|
|
p.commit();
|
|
return true;
|
|
}
|
|
if (w->kind == Kind::Divider && dragging) {
|
|
auto callback = w->on_commit;
|
|
if (callback)
|
|
callback(*w);
|
|
return true;
|
|
}
|
|
if (!w->rect.contains(event.x, event.y))
|
|
return true;
|
|
if (w->kind == Kind::Checkbox) {
|
|
w->checked = !w->checked;
|
|
auto callback = w->on_commit;
|
|
if (callback)
|
|
callback(*w);
|
|
w = p.find(id);
|
|
if (!w)
|
|
return true;
|
|
}
|
|
if (w->kind == Kind::Button || w->kind == Kind::Tab || w->kind == Kind::TreeRow ||
|
|
w->kind == Kind::Checkbox) {
|
|
auto callback = w->on_click;
|
|
if (callback)
|
|
callback(*w);
|
|
}
|
|
return true;
|
|
}
|
|
if (event.type == Type::KeyDown && event.key == "Escape") {
|
|
if (!p.captured.empty()) {
|
|
p.cancel_capture();
|
|
return true;
|
|
}
|
|
if (auto* w = p.find(p.focused); w && field(*w)) {
|
|
auto& edit = p.edits[w->id];
|
|
w->text = edit.original;
|
|
w->value = edit.original_value;
|
|
w->error.clear();
|
|
edit.buffer.reset(edit.original);
|
|
edit.composition.clear();
|
|
auto callback = w->on_cancel;
|
|
if (callback)
|
|
callback(*w);
|
|
p.unfocus(false);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (event.type == Type::KeyDown && event.key == "Tab") {
|
|
std::vector<std::string> ids;
|
|
collect_focus(p.root, ids);
|
|
if (ids.empty())
|
|
return false;
|
|
auto current = std::find(ids.begin(), ids.end(), p.focused);
|
|
std::size_t index =
|
|
current == ids.end() ? 0 : static_cast<std::size_t>(current - ids.begin());
|
|
if (current != ids.end())
|
|
index = event.shift ? (index + ids.size() - 1) % ids.size() : (index + 1) % ids.size();
|
|
return p.focus(ids[index]);
|
|
}
|
|
auto* w = p.find(p.focused);
|
|
if (!w)
|
|
return false;
|
|
if (field(*w)) {
|
|
auto& edit = p.edits[w->id];
|
|
if (event.type == Type::TextEditing) {
|
|
if (TextBuffer::valid_utf8(event.text)) {
|
|
edit.composition = event.text;
|
|
edit.composition_start = event.edit_start;
|
|
edit.composition_length = event.edit_length;
|
|
}
|
|
return true;
|
|
}
|
|
if (event.type == Type::TextInput) {
|
|
auto text = event.text;
|
|
std::replace(text.begin(), text.end(), '\n', ' ');
|
|
std::replace(text.begin(), text.end(), '\r', ' ');
|
|
edit.composition.clear();
|
|
if (edit.buffer.insert(text))
|
|
p.preview();
|
|
return true;
|
|
}
|
|
if (event.type != Type::KeyDown)
|
|
return false;
|
|
if (event.control && (event.key == "A" || event.key == "a")) {
|
|
edit.buffer.select_all();
|
|
return true;
|
|
}
|
|
if (event.control &&
|
|
(event.key == "C" || event.key == "c" || event.key == "X" || event.key == "x")) {
|
|
if (p.clipboard_write && edit.buffer.has_selection())
|
|
p.clipboard_write(edit.buffer.selected_text());
|
|
if ((event.key == "X" || event.key == "x") && edit.buffer.has_selection()) {
|
|
edit.buffer.insert("");
|
|
p.preview();
|
|
}
|
|
return true;
|
|
}
|
|
if (event.control && (event.key == "V" || event.key == "v")) {
|
|
if (p.clipboard_read) {
|
|
auto text = p.clipboard_read();
|
|
std::replace(text.begin(), text.end(), '\n', ' ');
|
|
std::replace(text.begin(), text.end(), '\r', ' ');
|
|
if (edit.buffer.insert(text))
|
|
p.preview();
|
|
}
|
|
return true;
|
|
}
|
|
if (event.control && (event.key == "Z" || event.key == "z")) {
|
|
const auto changed = event.shift ? edit.buffer.redo() : edit.buffer.undo();
|
|
if (changed)
|
|
p.preview();
|
|
return changed;
|
|
}
|
|
if (event.control && (event.key == "Y" || event.key == "y")) {
|
|
const auto changed = edit.buffer.redo();
|
|
if (changed)
|
|
p.preview();
|
|
return changed;
|
|
}
|
|
if (event.key == "Left") {
|
|
edit.buffer.left(event.shift, event.control);
|
|
return true;
|
|
}
|
|
if (event.key == "Right") {
|
|
edit.buffer.right(event.shift, event.control);
|
|
return true;
|
|
}
|
|
if (event.key == "Home") {
|
|
edit.buffer.home(event.shift);
|
|
return true;
|
|
}
|
|
if (event.key == "End") {
|
|
edit.buffer.end(event.shift);
|
|
return true;
|
|
}
|
|
if (event.key == "Backspace") {
|
|
if (edit.buffer.backspace())
|
|
p.preview();
|
|
return true;
|
|
}
|
|
if (event.key == "Delete") {
|
|
if (edit.buffer.delete_forward())
|
|
p.preview();
|
|
return true;
|
|
}
|
|
if (event.key == "Return" || event.key == "Enter") {
|
|
if (p.commit()) {
|
|
auto* current = p.find(p.focused);
|
|
if (current && current->kind == Kind::NumberField)
|
|
p.edits[current->id].buffer.select_all();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (event.type == Type::KeyDown && (event.key == "Return" || event.key == "Space")) {
|
|
const auto id = w->id;
|
|
if (w->kind == Kind::Checkbox) {
|
|
w->checked = !w->checked;
|
|
auto callback = w->on_commit;
|
|
if (callback)
|
|
callback(*w);
|
|
w = p.find(id);
|
|
}
|
|
if (w) {
|
|
auto callback = w->on_click;
|
|
if (callback)
|
|
callback(*w);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
} // namespace faset::ui
|