yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
widget_id_registry.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <chrono>
5#include <cmath>
6#include <cstdio>
7#include <ctime>
8#include <fstream>
9#include <sstream>
10#include <utility>
11
12#include "absl/strings/ascii.h"
13#include "absl/strings/str_cat.h"
14#include "absl/strings/str_format.h"
15#include "absl/strings/str_join.h"
16#include "absl/strings/str_split.h"
17#include "absl/strings/strip.h"
18#include "absl/time/clock.h"
19#include "imgui/imgui_internal.h" // For ImGuiContext internals
20
21#if defined(YAZE_WITH_JSON)
22#include "nlohmann/json.hpp"
23#endif
24
25namespace yaze {
26namespace gui {
27
28// Thread-local storage for ID stack
29thread_local std::vector<std::string> WidgetIdScope::id_stack_;
30
31WidgetIdScope::WidgetIdScope(const std::string& name) : name_(name) {
32 // Only push ID if we're in an active ImGui frame with a valid window
33 // This prevents crashes during editor initialization before ImGui begins its
34 // frame
35 ImGuiContext* ctx = ImGui::GetCurrentContext();
36 if (ctx && ctx->CurrentWindow && !ctx->Windows.empty()) {
37 ImGui::PushID(name.c_str());
38 id_stack_.push_back(name);
39 }
40}
41
43 // Only pop if we successfully pushed
44 if (!id_stack_.empty() && id_stack_.back() == name_) {
45 ImGui::PopID();
46 id_stack_.pop_back();
47 }
48}
49
50std::string WidgetIdScope::GetFullPath() const {
51 return absl::StrJoin(id_stack_, "/");
52}
53
54std::string WidgetIdScope::GetWidgetPath(const std::string& widget_type,
55 const std::string& widget_name) const {
56 std::string path = GetFullPath();
57 if (!path.empty()) {
58 path += "/";
59 }
60 return absl::StrCat(path, widget_type, ":", widget_name);
61}
62
63// WidgetIdRegistry implementation
64
66 static WidgetIdRegistry instance;
67 return instance;
68}
69
71 absl::MutexLock lock(&mutex_);
72 ImGuiContext* ctx = ImGui::GetCurrentContext();
73 if (ctx) {
74 current_frame_ = ctx->FrameCount;
75 } else if (current_frame_ >= 0) {
76 ++current_frame_;
77 } else {
78 current_frame_ = 0;
79 }
80 frame_time_ = absl::Now();
81 for (auto& [_, info] : widgets_) {
82 info.seen_in_current_frame = false;
83 }
84}
85
87 absl::MutexLock lock(&mutex_);
88 for (auto& [_, info] : widgets_) {
89 if (!info.seen_in_current_frame) {
90 info.visible = false;
91 info.enabled = false;
92 info.bounds.valid = false;
93 info.stale_frame_count += 1;
94 } else {
95 info.seen_in_current_frame = false;
96 info.stale_frame_count = 0;
97 }
98 }
100}
101
102namespace {
103
104std::string ExtractWindowFromPath(absl::string_view path) {
105 size_t slash = path.find('/');
106 if (slash == absl::string_view::npos) {
107 return std::string(path);
108 }
109 return std::string(path.substr(0, slash));
110}
111
112std::string ExtractLabelFromPath(absl::string_view path) {
113 size_t colon = path.rfind(':');
114 if (colon == absl::string_view::npos) {
115 size_t slash = path.rfind('/');
116 if (slash == absl::string_view::npos) {
117 return std::string(path);
118 }
119 return std::string(path.substr(slash + 1));
120 }
121 return std::string(path.substr(colon + 1));
122}
123
124std::string FormatTimestampUTC(const absl::Time& timestamp) {
125 if (timestamp == absl::Time()) {
126 return "";
127 }
128
129 std::chrono::system_clock::time_point chrono_time =
130 absl::ToChronoTime(timestamp);
131 std::time_t time_value = std::chrono::system_clock::to_time_t(chrono_time);
132
133 std::tm tm_buffer;
134#if defined(_WIN32)
135 if (gmtime_s(&tm_buffer, &time_value) != 0) {
136 return "";
137 }
138#else
139 if (gmtime_r(&time_value, &tm_buffer) == nullptr) {
140 return "";
141 }
142#endif
143
144 char buffer[32];
145 if (std::snprintf(buffer, sizeof(buffer), "%04d-%02d-%02dT%02d:%02d:%02dZ",
146 tm_buffer.tm_year + 1900, tm_buffer.tm_mon + 1,
147 tm_buffer.tm_mday, tm_buffer.tm_hour, tm_buffer.tm_min,
148 tm_buffer.tm_sec) <= 0) {
149 return "";
150 }
151
152 return std::string(buffer);
153}
154
157 bounds.min_x = rect.Min.x;
158 bounds.min_y = rect.Min.y;
159 bounds.max_x = rect.Max.x;
160 bounds.max_y = rect.Max.y;
161 bounds.valid = true;
162 return bounds;
163}
164
165// Returns the byte length of the UTF-8 character that starts at `pos`, so '?'
166// and '*' step over whole characters in non-ASCII widget names.
167size_t Utf8CharLength(absl::string_view text, size_t pos) {
168 size_t end = pos + 1;
169 while (end < text.size() &&
170 (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
171 ++end;
172 }
173 return end - pos;
174}
175
176// Matches all of `text` against `pattern`. '*' matches any run of characters,
177// '?' matches one character, and every other byte matches itself. On a
178// mismatch the most recent '*' takes one more character and matching resumes
179// after it; that backtracking is complete for patterns without classes.
180bool GlobMatch(absl::string_view pattern, absl::string_view text) {
181 size_t p = 0;
182 size_t t = 0;
183 size_t star = absl::string_view::npos; // pattern index of the last '*'
184 size_t star_text = 0; // text index where that '*' ends
185 while (t < text.size()) {
186 if (p < pattern.size() && pattern[p] == '*') {
187 star = p++;
188 star_text = t;
189 } else if (p < pattern.size() && pattern[p] == '?') {
190 ++p;
191 t += Utf8CharLength(text, t);
192 } else if (p < pattern.size() && pattern[p] == text[t]) {
193 ++p;
194 ++t;
195 } else if (star != absl::string_view::npos) {
196 p = star + 1;
197 star_text += Utf8CharLength(text, star_text);
198 t = star_text;
199 } else {
200 return false;
201 }
202 }
203 while (p < pattern.size() && pattern[p] == '*') {
204 ++p;
205 }
206 return p == pattern.size();
207}
208
209// The catalog keeps bounds to one decimal place, as the earlier hand-written
210// "%0.1f" output did.
211double RoundToTenth(float value) {
212 return std::round(static_cast<double>(value) * 10.0) / 10.0;
213}
214
215#if defined(YAZE_WITH_JSON)
216// ordered_json keeps the field order of the earlier hand-written catalog.
217nlohmann::ordered_json WidgetToJson(const std::string& path,
218 const WidgetIdRegistry::WidgetInfo& info) {
219 nlohmann::ordered_json widget;
220 widget["path"] = path;
221 widget["type"] = info.type;
222 widget["imgui_id"] = info.imgui_id;
223 widget["label"] = info.label;
224 widget["window"] = info.window_name;
225 widget["visible"] = info.visible;
226 widget["enabled"] = info.enabled;
227 if (info.bounds.valid) {
228 // nlohmann writes NaN and infinity as null, so odd ImGui rects stay valid.
229 widget["bounds"] = {
230 {"min",
231 {RoundToTenth(info.bounds.min_x), RoundToTenth(info.bounds.min_y)}},
232 {"max",
233 {RoundToTenth(info.bounds.max_x), RoundToTenth(info.bounds.max_y)}}};
234 } else {
235 widget["bounds"] = nullptr;
236 }
237 widget["last_seen_frame"] = info.last_seen_frame;
238 widget["last_seen_at"] = FormatTimestampUTC(info.last_seen_time);
239 widget["stale"] = info.stale_frame_count > 0;
240 if (!info.description.empty()) {
241 widget["description"] = info.description;
242 }
243 return widget;
244}
245#else
246// Quotes a string as a JSON string literal for builds without nlohmann::json.
247// Bytes 0x80 and above pass through, so UTF-8 names stay intact.
248std::string QuoteJsonString(absl::string_view value) {
249 std::string quoted;
250 quoted.reserve(value.size() + 2);
251 quoted.push_back('"');
252 for (unsigned char c : value) {
253 switch (c) {
254 case '"':
255 quoted.append("\\\"");
256 break;
257 case '\\':
258 quoted.append("\\\\");
259 break;
260 case '\b':
261 quoted.append("\\b");
262 break;
263 case '\f':
264 quoted.append("\\f");
265 break;
266 case '\n':
267 quoted.append("\\n");
268 break;
269 case '\r':
270 quoted.append("\\r");
271 break;
272 case '\t':
273 quoted.append("\\t");
274 break;
275 default:
276 if (c < 0x20) {
277 quoted.append(absl::StrFormat("\\u%04X", static_cast<int>(c)));
278 } else {
279 quoted.push_back(static_cast<char>(c));
280 }
281 break;
282 }
283 }
284 quoted.push_back('"');
285 return quoted;
286}
287
288// JSON has no NaN or infinity literal; write null as nlohmann::json does.
289std::string JsonNumber(double value) {
290 if (!std::isfinite(value)) {
291 return "null";
292 }
293 return absl::StrFormat("%.1f", value);
294}
295
296std::string WidgetToJson(const std::string& path,
297 const WidgetIdRegistry::WidgetInfo& info) {
298 std::vector<std::string> fields;
299 fields.push_back(absl::StrCat("\"path\": ", QuoteJsonString(path)));
300 fields.push_back(absl::StrCat("\"type\": ", QuoteJsonString(info.type)));
301 fields.push_back(absl::StrCat("\"imgui_id\": ", info.imgui_id));
302 fields.push_back(absl::StrCat("\"label\": ", QuoteJsonString(info.label)));
303 fields.push_back(
304 absl::StrCat("\"window\": ", QuoteJsonString(info.window_name)));
305 fields.push_back(
306 absl::StrCat("\"visible\": ", info.visible ? "true" : "false"));
307 fields.push_back(
308 absl::StrCat("\"enabled\": ", info.enabled ? "true" : "false"));
309 if (info.bounds.valid) {
310 fields.push_back(absl::StrCat(
311 "\"bounds\": {\"min\": [", JsonNumber(RoundToTenth(info.bounds.min_x)),
312 ", ", JsonNumber(RoundToTenth(info.bounds.min_y)), "], \"max\": [",
313 JsonNumber(RoundToTenth(info.bounds.max_x)), ", ",
314 JsonNumber(RoundToTenth(info.bounds.max_y)), "]}"));
315 } else {
316 fields.push_back("\"bounds\": null");
317 }
318 fields.push_back(absl::StrCat("\"last_seen_frame\": ", info.last_seen_frame));
319 fields.push_back(
320 absl::StrCat("\"last_seen_at\": ",
322 fields.push_back(absl::StrCat("\"stale\": ",
323 info.stale_frame_count > 0 ? "true" : "false"));
324 if (!info.description.empty()) {
325 fields.push_back(
326 absl::StrCat("\"description\": ", QuoteJsonString(info.description)));
327 }
328 return absl::StrCat(" {\n ", absl::StrJoin(fields, ",\n "),
329 "\n }");
330}
331#endif // defined(YAZE_WITH_JSON)
332
333} // namespace
334
335void WidgetIdRegistry::RegisterWidget(const std::string& full_path,
336 const std::string& type, ImGuiID imgui_id,
337 const std::string& description,
338 const WidgetMetadata& metadata) {
339 absl::MutexLock lock(&mutex_);
340 WidgetInfo& info = widgets_[full_path];
341 info.full_path = full_path;
342 info.type = type;
343 info.imgui_id = imgui_id;
344 info.description = description;
345
346 if (metadata.label.has_value()) {
347 info.label = NormalizeLabel(*metadata.label);
348 } else {
349 info.label = NormalizeLabel(ExtractLabelFromPath(full_path));
350 }
351 if (info.label.empty()) {
352 info.label = ExtractLabelFromPath(full_path);
353 }
354
355 if (metadata.window_name.has_value()) {
356 info.window_name = NormalizeLabel(*metadata.window_name);
357 } else {
358 info.window_name = NormalizePathSegment(ExtractWindowFromPath(full_path));
359 }
360 if (info.window_name.empty()) {
361 info.window_name = ExtractWindowFromPath(full_path);
362 }
363
364 ImGuiContext* ctx = ImGui::GetCurrentContext();
365 absl::Time observed_at = absl::Now();
366
367 if (ctx) {
368 const ImGuiLastItemData& last = ctx->LastItemData;
369 if (metadata.visible.has_value()) {
370 info.visible = *metadata.visible;
371 } else {
372 info.visible = (last.StatusFlags & ImGuiItemStatusFlags_Visible) != 0;
373 }
374
375 if (metadata.enabled.has_value()) {
376 info.enabled = *metadata.enabled;
377 } else {
378 info.enabled = (last.ItemFlags & ImGuiItemFlags_Disabled) == 0;
379 }
380
381 if (metadata.bounds.has_value()) {
382 info.bounds = *metadata.bounds;
383 } else {
384 info.bounds = BoundsFromImGui(last.Rect);
385 }
386
387 info.last_seen_frame = ctx->FrameCount;
388 } else {
389 info.visible = metadata.visible.value_or(true);
390 info.enabled = metadata.enabled.value_or(true);
391 if (metadata.bounds.has_value()) {
392 info.bounds = *metadata.bounds;
393 } else {
394 info.bounds.valid = false;
395 }
396 if (current_frame_ >= 0) {
397 info.last_seen_frame = current_frame_;
398 }
399 }
400
401 info.last_seen_time = observed_at;
402 info.seen_in_current_frame = true;
403 info.stale_frame_count = 0;
404}
405
406std::vector<std::string> WidgetIdRegistry::FindWidgets(
407 const std::string& pattern) const {
408 absl::MutexLock lock(&mutex_);
409 std::vector<std::string> matches;
410
411 for (const auto& entry : widgets_) {
412 if (GlobMatch(pattern, entry.first)) {
413 matches.push_back(entry.first);
414 }
415 }
416
417 // Sort for consistent ordering
418 std::sort(matches.begin(), matches.end());
419 return matches;
420}
421
422ImGuiID WidgetIdRegistry::GetWidgetId(const std::string& full_path) const {
423 absl::MutexLock lock(&mutex_);
424 auto it = widgets_.find(full_path);
425 if (it != widgets_.end()) {
426 return it->second.imgui_id;
427 }
428 return 0;
429}
430
431std::optional<WidgetIdRegistry::WidgetInfo> WidgetIdRegistry::GetWidgetInfo(
432 const std::string& full_path) const {
433 absl::MutexLock lock(&mutex_);
434 auto it = widgets_.find(full_path);
435 if (it != widgets_.end()) {
436 return it->second;
437 }
438 return std::nullopt;
439}
440
441std::unordered_map<std::string, WidgetIdRegistry::WidgetInfo>
443 absl::MutexLock lock(&mutex_);
444 return widgets_;
445}
446
448 absl::MutexLock lock(&mutex_);
449 widgets_.clear();
450 current_frame_ = -1;
451}
452
453std::string WidgetIdRegistry::ExportCatalog(const std::string& format) const {
454 absl::MutexLock lock(&mutex_);
455 std::ostringstream ss;
456
457 if (format == "json") {
458 // widgets_ is unordered; sort by path so repeated exports diff cleanly.
459 using Entry = std::unordered_map<std::string, WidgetInfo>::value_type;
460 std::vector<const Entry*> entries;
461 entries.reserve(widgets_.size());
462 for (const Entry& entry : widgets_) {
463 entries.push_back(&entry);
464 }
465 std::sort(
466 entries.begin(), entries.end(),
467 [](const Entry* a, const Entry* b) { return a->first < b->first; });
468
469#if defined(YAZE_WITH_JSON)
470 nlohmann::ordered_json widgets = nlohmann::ordered_json::array();
471 for (const Entry* entry : entries) {
472 widgets.push_back(WidgetToJson(entry->first, entry->second));
473 }
474 nlohmann::ordered_json catalog;
475 catalog["widgets"] = std::move(widgets);
476 // Paths and labels come from UI strings. Replace invalid UTF-8 instead of
477 // letting dump() throw; valid UTF-8 is written as-is.
478 ss << catalog.dump(2, ' ', false,
479 nlohmann::ordered_json::error_handler_t::replace)
480 << "\n";
481#else
482 std::vector<std::string> objects;
483 objects.reserve(entries.size());
484 for (const Entry* entry : entries) {
485 objects.push_back(WidgetToJson(entry->first, entry->second));
486 }
487 if (objects.empty()) {
488 ss << "{\n \"widgets\": []\n}\n";
489 } else {
490 ss << "{\n \"widgets\": [\n"
491 << absl::StrJoin(objects, ",\n") << "\n ]\n}\n";
492 }
493#endif // defined(YAZE_WITH_JSON)
494 } else {
495 // YAML format (default)
496 ss << "widgets:\n";
497
498 for (const auto& [path, info] : widgets_) {
499 ss << absl::StrFormat(" - path: \"%s\"\n", path);
500 ss << absl::StrFormat(" type: %s\n", info.type);
501 ss << absl::StrFormat(" imgui_id: %u\n", info.imgui_id);
502 ss << absl::StrFormat(" label: \"%s\"\n", info.label);
503 ss << absl::StrFormat(" window: \"%s\"\n", info.window_name);
504 ss << absl::StrFormat(" visible: %s\n",
505 info.visible ? "true" : "false");
506 ss << absl::StrFormat(" enabled: %s\n",
507 info.enabled ? "true" : "false");
508 if (info.bounds.valid) {
509 ss << " bounds:\n";
510 ss << absl::StrFormat(" min: [%0.1f, %0.1f]\n", info.bounds.min_x,
511 info.bounds.min_y);
512 ss << absl::StrFormat(" max: [%0.1f, %0.1f]\n", info.bounds.max_x,
513 info.bounds.max_y);
514 }
515 ss << absl::StrFormat(" last_seen_frame: %d\n", info.last_seen_frame);
516 std::string iso_timestamp = FormatTimestampUTC(info.last_seen_time);
517 ss << absl::StrFormat(" last_seen_at: %s\n", iso_timestamp);
518 ss << absl::StrFormat(" stale: %s\n",
519 info.stale_frame_count > 0 ? "true" : "false");
520
521 // Parse hierarchical context from path
522 std::vector<std::string> segments = absl::StrSplit(path, '/');
523 if (!segments.empty()) {
524 ss << " context:\n";
525 if (segments.size() > 0) {
526 ss << absl::StrFormat(" editor: %s\n", segments[0]);
527 }
528 if (segments.size() > 1) {
529 ss << absl::StrFormat(" tab: %s\n", segments[1]);
530 }
531 if (segments.size() > 2) {
532 ss << absl::StrFormat(" section: %s\n", segments[2]);
533 }
534 }
535
536 if (!info.description.empty()) {
537 ss << absl::StrFormat(" description: %s\n", info.description);
538 }
539
540 // Add suggested actions based on widget type
541 ss << " actions: [";
542 if (info.type == "button") {
543 ss << "click";
544 } else if (info.type == "input") {
545 ss << "type, clear";
546 } else if (info.type == "canvas") {
547 ss << "click, drag, scroll";
548 } else if (info.type == "checkbox") {
549 ss << "toggle";
550 } else if (info.type == "slider") {
551 ss << "drag, set";
552 } else {
553 ss << "interact";
554 }
555 ss << "]\n";
556 }
557 }
558
559 return ss.str();
560}
561
562void WidgetIdRegistry::ExportCatalogToFile(const std::string& output_file,
563 const std::string& format) const {
564 std::string content = ExportCatalog(format);
565 std::ofstream file(output_file);
566 if (file.is_open()) {
567 file << content;
568 file.close();
569 }
570}
571
572std::string WidgetIdRegistry::NormalizeLabel(absl::string_view label) {
573 size_t pos = label.find("##");
574 if (pos != absl::string_view::npos) {
575 label = label.substr(0, pos);
576 }
577 std::string sanitized = std::string(absl::StripAsciiWhitespace(label));
578 return sanitized;
579}
580
581std::string WidgetIdRegistry::NormalizePathSegment(absl::string_view segment) {
582 return NormalizeLabel(segment);
583}
584
586 auto it = widgets_.begin();
587 while (it != widgets_.end()) {
588 if (ShouldPrune(it->second)) {
589 it = widgets_.erase(it);
590 } else {
591 ++it;
592 }
593 }
594}
595
597 if (info.last_seen_frame < 0 || stale_frame_limit_ <= 0) {
598 return false;
599 }
600 if (current_frame_ < 0) {
601 return false;
602 }
603 return (current_frame_ - info.last_seen_frame) > stale_frame_limit_;
604}
605
606} // namespace gui
607} // namespace yaze
Centralized registry for discoverable GUI widgets.
std::optional< WidgetInfo > GetWidgetInfo(const std::string &full_path) const
void ExportCatalogToFile(const std::string &output_file, const std::string &format="yaml") const
static std::string NormalizePathSegment(absl::string_view segment)
std::string ExportCatalog(const std::string &format="yaml") const
bool ShouldPrune(const WidgetInfo &info) const
ImGuiID GetWidgetId(const std::string &full_path) const
static std::string NormalizeLabel(absl::string_view label)
void RegisterWidget(const std::string &full_path, const std::string &type, ImGuiID imgui_id, const std::string &description="", const WidgetMetadata &metadata=WidgetMetadata())
std::unordered_map< std::string, WidgetInfo > GetAllWidgets() const
static WidgetIdRegistry & Instance()
std::vector< std::string > FindWidgets(const std::string &pattern) const
WidgetIdScope(const std::string &name)
std::string GetFullPath() const
static thread_local std::vector< std::string > id_stack_
std::string GetWidgetPath(const std::string &widget_type, const std::string &widget_name) const
std::string ExtractWindowFromPath(absl::string_view path)
std::string ExtractLabelFromPath(absl::string_view path)
size_t Utf8CharLength(absl::string_view text, size_t pos)
std::string WidgetToJson(const std::string &path, const WidgetIdRegistry::WidgetInfo &info)
bool GlobMatch(absl::string_view pattern, absl::string_view text)
std::string FormatTimestampUTC(const absl::Time &timestamp)
WidgetIdRegistry::WidgetBounds BoundsFromImGui(const ImRect &rect)