yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
tile_selector_widget.cc
Go to the documentation of this file.
2#include "util/i18n/tr.h"
3
4#include <algorithm>
5#include <cstdio>
6
7#include "absl/strings/str_format.h"
10#include "app/gui/core/icons.h"
11
12namespace yaze::gui {
13
14namespace {
15
16std::string_view TrimAsciiWhitespace(std::string_view s) {
17 while (!s.empty() && (s.front() == ' ' || s.front() == '\t' ||
18 s.front() == '\n' || s.front() == '\r')) {
19 s.remove_prefix(1);
20 }
21 while (!s.empty() && (s.back() == ' ' || s.back() == '\t' ||
22 s.back() == '\n' || s.back() == '\r')) {
23 s.remove_suffix(1);
24 }
25 return s;
26}
27
28// Parse tile-id text using hex by default, with explicit decimal support via
29// "d:<value>".
30bool ParseTileIdText(std::string_view input, int* out_value) {
31 if (!out_value) {
32 return false;
33 }
34
35 std::string_view trimmed = TrimAsciiWhitespace(input);
36 if (trimmed.empty()) {
37 return false;
38 }
39
40 bool decimal_mode = false;
41 if (trimmed.size() >= 2 && (trimmed[0] == 'd' || trimmed[0] == 'D') &&
42 trimmed[1] == ':') {
43 decimal_mode = true;
44 trimmed.remove_prefix(2);
45 } else if (trimmed.size() >= 2 && trimmed[0] == '0' &&
46 (trimmed[1] == 'x' || trimmed[1] == 'X')) {
47 trimmed.remove_prefix(2);
48 }
49
50 if (trimmed.empty()) {
51 return false;
52 }
53
54 std::string text(trimmed);
55 unsigned int parsed = 0;
56 char trailing = '\0';
57 const char* format = decimal_mode ? "%u%c" : "%x%c";
58 if (std::sscanf(text.c_str(), format, &parsed, &trailing) != 1) {
59 return false;
60 }
61
62 *out_value = static_cast<int>(parsed);
63 return true;
64}
65
66} // namespace
67
69 : config_(),
70 total_tiles_(config_.total_tiles),
71 widget_id_(std::move(widget_id)) {}
72
73TileSelectorWidget::TileSelectorWidget(std::string widget_id, Config config)
74 : config_(config),
75 total_tiles_(config.total_tiles),
76 widget_id_(std::move(widget_id)) {}
77
79 canvas_ = canvas;
80}
81
92
93void TileSelectorWidget::SetDisplayScale(float display_scale) {
94 if (display_scale > 0.0f) {
96 }
97}
98
99void TileSelectorWidget::SetTilesPerRow(int tiles_per_row) {
100 if (tiles_per_row > 0) {
102 }
103}
104
106 if (IsValidTileId(tile_id)) {
107 selected_tile_id_ = tile_id;
109 }
110}
111
112namespace {
113
115 const int tile_display_size =
116 static_cast<int>(config.tile_size * config.display_scale);
117 return config.tiles_per_row * tile_display_size + config.draw_offset.x * 2;
118}
119
120} // namespace
121
123 const int tile_display_size =
124 static_cast<int>(config_.tile_size * config_.display_scale);
125 const int num_rows =
127 return ImVec2(GridWidthFor(config_),
128 num_rows * tile_display_size + config_.draw_offset.y * 2);
129}
130
132 // ImGuiStyle's default; used when no context exists (layout unit tests).
133 constexpr float kImGuiDefaultScrollbarSize = 14.0f;
134 return ImGui::GetCurrentContext() ? ImGui::GetStyle().ScrollbarSize
135 : kImGuiDefaultScrollbarSize;
136}
137
139 float scrollbar_size) {
140 // Filter-bar controls wrap compactly when the available width is tight
141 // (see DrawFilterBar); they must not inflate the preferred width.
142 return GridWidthFor(config) + scrollbar_size;
143}
144
148
150 bool atlas_ready) {
151 RenderResult result;
152
153 if (!canvas_) {
154 return result;
155 }
156
157 const int tile_display_size =
158 static_cast<int>(config_.tile_size * config_.display_scale);
159 const ImVec2 content_size = GetGridContentSize();
160
161 // Set content size for ImGui child window (must be called before
162 // DrawBackground)
163 ImGui::SetCursorPos(ImVec2(0, 0));
164 ImGui::Dummy(content_size);
165 ImGui::SetCursorPos(ImVec2(0, 0));
166
167 // Handle pending scroll (deferred from ScrollToTile call outside render
168 // context)
169 if (pending_scroll_tile_id_ >= 0) {
171 const ImVec2 target = TileOrigin(pending_scroll_tile_id_);
173 const ImVec2 window_size = ImGui::GetWindowSize();
174 float scroll_x =
175 target.x - (window_size.x / 2.0f) + (tile_display_size / 2.0f);
176 float scroll_y =
177 target.y - (window_size.y / 2.0f) + (tile_display_size / 2.0f);
178 scroll_x = std::max(0.0f, scroll_x);
179 scroll_y = std::max(0.0f, scroll_y);
180 ImGui::SetScrollX(scroll_x);
181 ImGui::SetScrollY(scroll_y);
182 }
183 }
184 pending_scroll_tile_id_ = -1; // Clear pending scroll
185 }
186
188
189 if (atlas_ready && atlas.is_active()) {
190 canvas_->DrawBitmap(atlas, static_cast<int>(config_.draw_offset.x),
191 static_cast<int>(config_.draw_offset.y),
193
194 result = HandleInteraction(tile_display_size);
195
196 // Hover tooltip: show tile ID and zoomed preview
197 if (config_.show_hover_tooltip && ImGui::IsItemHovered()) {
198 int hovered_tile = ResolveTileAtCursor(tile_display_size);
199 if (IsValidTileId(hovered_tile)) {
200 ImGui::BeginTooltip();
201 ImGui::Text(tr("Tile %d (0x%03X)"), hovered_tile, hovered_tile);
202
203 // Extract and draw a zoomed preview of the hovered tile
204 int tile_col = hovered_tile % config_.tiles_per_row;
205 int tile_row = hovered_tile / config_.tiles_per_row;
206 int src_x = tile_col * config_.tile_size;
207 int src_y = tile_row * config_.tile_size;
208
209 // Use ImGui texture coords for the atlas to show the tile zoomed
210 auto* texture_id = atlas.texture();
211 if (texture_id != nullptr) {
212 float atlas_w = static_cast<float>(atlas.width());
213 float atlas_h = static_cast<float>(atlas.height());
214 if (atlas_w > 0 && atlas_h > 0) {
215 ImVec2 uv0(src_x / atlas_w, src_y / atlas_h);
216 ImVec2 uv1((src_x + config_.tile_size) / atlas_w,
217 (src_y + config_.tile_size) / atlas_h);
218 float preview_size = config_.tile_size * 4.0f;
219 ImGui::Image((ImTextureID)(intptr_t)texture_id,
220 ImVec2(preview_size, preview_size), uv0, uv1);
221 }
222 }
223
224 ImGui::EndTooltip();
225 }
226 }
227
229 DrawTileIdLabels(tile_display_size);
230 }
231
232 DrawHighlight(tile_display_size);
233 }
234
236 canvas_->DrawGrid();
238
239 return result;
240}
241
243 CanvasMenuItem item("Copy Tile ID", ICON_MD_CONTENT_COPY, [this]() {
245 ImGui::SetClipboardText(
246 absl::StrFormat("0x%03X", selected_tile_id_).c_str());
247 }
248 });
249 item.enabled_condition = [this]() {
251 };
252 return item;
253}
254
256 int tile_display_size) {
257 RenderResult result;
258
259 if (!ImGui::IsItemHovered()) {
260 return result;
261 }
262
263 const bool clicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
264 const bool double_clicked =
265 ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
266 const bool right_clicked = ImGui::IsMouseClicked(ImGuiMouseButton_Right);
267
268 if (clicked || double_clicked || right_clicked) {
269 const int hovered_tile = ResolveTileAtCursor(tile_display_size);
270 if (IsValidTileId(hovered_tile) && IsInFilterRange(hovered_tile)) {
271 result.tile_clicked = clicked;
272 result.tile_double_clicked = double_clicked;
273 result.tile_right_clicked = right_clicked;
274 if (hovered_tile != selected_tile_id_) {
275 selected_tile_id_ = hovered_tile;
276 result.selection_changed = true;
277 }
279 }
280 }
281
282 // Emit drag source for the selected tile when dragging is enabled
284 result.tile_dragging =
286 }
287
288 return result;
289}
290
291int TileSelectorWidget::ResolveTileAtCursor(int tile_display_size) const {
292 if (!canvas_) {
293 return -1;
294 }
295
296 const ImVec2 screen_pos = ImGui::GetIO().MousePos;
297 const ImVec2 origin = canvas_->zero_point();
298 const ImVec2 scroll = canvas_->scrolling();
299
300 // Convert screen position to canvas content position (accounting for scroll)
301 ImVec2 local =
302 ImVec2(screen_pos.x - origin.x - config_.draw_offset.x - scroll.x,
303 screen_pos.y - origin.y - config_.draw_offset.y - scroll.y);
304
305 if (local.x < 0.0f || local.y < 0.0f) {
306 return -1;
307 }
308
309 const int column = static_cast<int>(local.x / tile_display_size);
310 const int row = static_cast<int>(local.y / tile_display_size);
311
312 return row * config_.tiles_per_row + column;
313}
314
315void TileSelectorWidget::DrawHighlight(int tile_display_size) const {
317 return;
318 }
319
320 const int column = selected_tile_id_ % config_.tiles_per_row;
321 const int row = selected_tile_id_ / config_.tiles_per_row;
322
323 const float x = config_.draw_offset.x + column * tile_display_size;
324 const float y = config_.draw_offset.y + row * tile_display_size;
325
326 canvas_->DrawOutlineWithColor(static_cast<int>(x), static_cast<int>(y),
327 tile_display_size, tile_display_size,
329}
330
332 // Future enhancement: draw ImGui text overlay with tile indices.
333}
334
335void TileSelectorWidget::ScrollToTile(int tile_id, bool use_imgui_scroll) {
336 if (!canvas_ || !IsValidTileId(tile_id)) {
337 return;
338 }
339
340 // Defer scroll until next render (when we're in the correct ImGui window
341 // context)
342 pending_scroll_tile_id_ = tile_id;
343 pending_scroll_use_imgui_ = use_imgui_scroll;
344}
345
346ImVec2 TileSelectorWidget::TileOrigin(int tile_id) const {
347 if (!IsValidTileId(tile_id)) {
348 return ImVec2(-1, -1);
349 }
350 const int tile_display_size =
351 static_cast<int>(config_.tile_size * config_.display_scale);
352 const int column = tile_id % config_.tiles_per_row;
353 const int row = tile_id / config_.tiles_per_row;
354 return ImVec2(config_.draw_offset.x + column * tile_display_size,
355 config_.draw_offset.y + row * tile_display_size);
356}
357
359 std::string_view input) {
360 int tile_id = -1;
361 if (!ParseTileIdText(input, &tile_id)) {
363 return last_jump_result_;
364 }
365
366 if (!IsValidTileId(tile_id)) {
368 return last_jump_result_;
369 }
370
371 selected_tile_id_ = tile_id;
372 ScrollToTile(tile_id, true);
374 return last_jump_result_;
375}
376
378 ImGui::PushID(widget_id_.c_str());
379 const bool compact = ImGui::GetContentRegionAvail().x < 115.0f;
380 const char* label =
382 : (filter_range_active_ ? "Find / Filter*" : "Find / Filter");
383 if (ImGui::Button(label)) {
384 ImGui::OpenPopup("TileLookup");
385 }
387 "button", ImGui::GetItemID());
388 if (ImGui::IsItemHovered()) {
389 ImGui::SetTooltip("Jump to a tile ID or filter a range%s",
390 filter_range_active_ ? " (range filter active)" : "");
391 }
392 bool jumped = false;
393 ImGui::SetNextWindowSizeConstraints(ImVec2(300.0f, 0.0f),
394 ImVec2(380.0f, 500.0f));
395 if (ImGui::BeginPopup("TileLookup")) {
396 jumped = DrawFilterControls();
397 if (jumped) {
398 ImGui::CloseCurrentPopup();
399 }
400 ImGui::EndPopup();
401 }
402 ImGui::PopID();
403 return jumped;
404}
405
407 bool jumped = false;
408 const int max_tile_id = GetMaxTileId();
409 const float available_width =
410 std::max(ImGui::GetContentRegionAvail().x, 1.0f);
411 const bool compact_layout = available_width < 420.0f;
412 const float jump_input_width =
413 compact_layout ? std::clamp(available_width * 0.28f, 56.0f, 88.0f)
414 : 64.0f;
415 const float range_input_width =
416 compact_layout
417 ? std::clamp((available_width - 110.0f) * 0.5f, 56.0f, 88.0f)
418 : 64.0f;
419
420 constexpr ImGuiInputTextFlags kInputFlags =
421 ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll;
422
423 ImGui::PushID(widget_id_.c_str());
424
425 // Jump-to-ID input
426 ImGui::AlignTextToFramePadding();
427 ImGui::TextUnformatted(tr("Go:"));
428 ImGui::SameLine();
429
430 ImGui::SetNextItemWidth(jump_input_width);
431 if (ImGui::InputText("##TileFilterID", filter_buf_, sizeof(filter_buf_),
432 kInputFlags)) {
435 jumped = true;
436 break;
439 break;
440 }
441 }
443 "input", ImGui::GetItemID());
444 if (ImGui::IsItemHovered()) {
445 ImGui::SetTooltip(
446 tr("Enter tile ID and press Enter:\n"
447 "hex: 1A or 0x1A\n"
448 "decimal: d:26"));
449 }
450
451 ImGui::SameLine();
452 ImGui::TextDisabled(tr("/ 0x%03X"), max_tile_id);
453
455 ImGui::TextUnformatted(tr("Invalid tile ID (hex or d:decimal)"));
457 ImGui::Text(tr("Out of range (max: 0x%03X)"), max_tile_id);
458 }
459
460 // Range filter inputs
461 if (!compact_layout) {
462 ImGui::SameLine(0, 12.0f);
463 }
464 ImGui::AlignTextToFramePadding();
465 ImGui::TextUnformatted(tr("Range:"));
466 ImGui::SameLine();
467
468 bool range_changed = false;
469 ImGui::SetNextItemWidth(range_input_width);
470 if (ImGui::InputText("##RangeMin", filter_min_buf_, sizeof(filter_min_buf_),
471 kInputFlags)) {
472 range_changed = true;
473 }
475 "input", ImGui::GetItemID());
476 if (ImGui::IsItemHovered()) {
477 ImGui::SetTooltip(
478 tr("Min tile ID. Press Enter to apply.\n"
479 "hex: 1A or 0x1A\n"
480 "decimal: d:26"));
481 }
482 ImGui::SameLine();
483 ImGui::TextUnformatted("-");
484 ImGui::SameLine();
485 ImGui::SetNextItemWidth(range_input_width);
486 if (ImGui::InputText("##RangeMax", filter_max_buf_, sizeof(filter_max_buf_),
487 kInputFlags)) {
488 range_changed = true;
489 }
491 "input", ImGui::GetItemID());
492 if (ImGui::IsItemHovered()) {
493 ImGui::SetTooltip(
494 tr("Max tile ID. Press Enter to apply.\n"
495 "hex: 1A or 0x1A\n"
496 "decimal: d:26"));
497 }
498 if (!compact_layout) {
499 ImGui::SameLine();
500 ImGui::TextDisabled(tr("(hex, d:dec)"));
501 } // Compact: the input tooltips carry the format hint.
502
503 if (range_changed) {
504 int parsed_min = 0;
505 int parsed_max = 0;
506 bool has_min = ParseTileIdText(filter_min_buf_, &parsed_min);
507 bool has_max = ParseTileIdText(filter_max_buf_, &parsed_max);
508
509 if (has_min && has_max && parsed_min <= parsed_max) {
510 SetRangeFilter(parsed_min, parsed_max);
511 filter_range_error_ = false;
513 filter_out_of_range_ = false;
515 } else {
516 // SetRangeFilter returned early: both values exceeded total_tiles_.
518 }
519 } else if (!has_min && !has_max) {
521 filter_range_error_ = false;
522 filter_out_of_range_ = false;
523 } else if (has_min && has_max && parsed_min > parsed_max) {
524 // Invalid range: min must be ≤ max
525 filter_range_error_ = true;
526 filter_out_of_range_ = false;
527 }
528 }
529
530 // Clear button when range is active
532 if (!compact_layout) {
533 ImGui::SameLine();
534 }
535 if (ImGui::SmallButton(tr("X##ClearRange"))) {
537 filter_min_buf_[0] = '\0';
538 filter_max_buf_[0] = '\0';
539 filter_range_error_ = false;
540 }
541 if (ImGui::IsItemHovered()) {
542 ImGui::SetTooltip(tr("Clear range filter"));
543 }
544 }
545
546 // Validation feedback (shown inline after the filter inputs)
548 if (!compact_layout) {
549 ImGui::SameLine(0, 8.0f);
550 }
551 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
552 tr("Min must be <= Max"));
553 } else if (filter_out_of_range_) {
554 if (!compact_layout) {
555 ImGui::SameLine(0, 8.0f);
556 }
557 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
558 tr("Out of range (max: 0x%03X)"), GetMaxTileId());
559 } else if (filter_range_active_) {
560 int range_count = filter_range_max_ - filter_range_min_ + 1;
561 if (range_count <= 0) {
562 if (!compact_layout) {
563 ImGui::SameLine(0, 8.0f);
564 }
565 ImGui::TextDisabled(tr("(no tiles in range)"));
566 }
567 }
568
569 ImGui::PopID();
570
571 return jumped;
572}
573
574void TileSelectorWidget::SetRangeFilter(int min_id, int max_id) {
575 if (min_id < 0)
576 min_id = 0;
577 if (max_id >= total_tiles_)
578 max_id = total_tiles_ - 1;
579 if (min_id > max_id)
580 return;
581
583 filter_range_min_ = min_id;
584 filter_range_max_ = max_id;
585 filter_range_error_ = false;
586}
587
595
596bool TileSelectorWidget::IsValidTileId(int tile_id) const {
597 return tile_id >= 0 && tile_id < total_tiles_;
598}
599
600bool TileSelectorWidget::IsInFilterRange(int tile_id) const {
602 return true;
603 return tile_id >= filter_range_min_ && tile_id <= filter_range_max_;
604}
605
606} // namespace yaze::gui
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:70
TextureHandle texture() const
Definition bitmap.h:410
bool is_active() const
Definition bitmap.h:414
int height() const
Definition bitmap.h:399
int width() const
Definition bitmap.h:398
Modern, robust canvas for drawing and manipulating graphics.
Definition canvas.h:64
void DrawBitmap(Bitmap &bitmap, int border_offset, float scale)
Definition canvas.cc:1293
void DrawOutlineWithColor(int x, int y, int w, int h, ImVec4 color)
Definition canvas.cc:1365
void DrawContextMenu()
Definition canvas.cc:823
auto zero_point() const
Definition canvas.h:364
void DrawBackground(ImVec2 canvas_size=ImVec2(0, 0), std::optional< ImVec2 > viewport_size=std::nullopt)
Definition canvas.cc:702
auto scrolling() const
Definition canvas.h:366
void DrawGrid(float grid_step=64.0f, int tile_id_offset=8)
Definition canvas.cc:1620
static float PreferredViewportWidth(const Config &config, float scrollbar_size)
JumpToTileResult JumpToTileFromInput(std::string_view input)
int ResolveTileAtCursor(int tile_display_size) const
void SetDisplayScale(float display_scale)
RenderResult Render(gfx::Bitmap &atlas, bool atlas_ready)
static float CurrentScrollbarSize()
Current style scrollbar size, or ImGui's default without a context.
bool IsValidTileId(int tile_id) const
void SetRangeFilter(int min_id, int max_id)
ImVec2 TileOrigin(int tile_id) const
RenderResult HandleInteraction(int tile_display_size)
void ScrollToTile(int tile_id, bool use_imgui_scroll=true)
CanvasMenuItem CopyTileIdMenuItem() const
void DrawTileIdLabels(int tile_display_size) const
void DrawHighlight(int tile_display_size) const
bool IsInFilterRange(int tile_id) const
void SetTilesPerRow(int tiles_per_row)
TileSelectorWidget(std::string widget_id)
void RegisterWidget(const std::string &full_path, const std::string &type, ImGuiID imgui_id, const std::string &description="", const WidgetMetadata &metadata=WidgetMetadata())
static WidgetIdRegistry & Instance()
#define ICON_MD_SEARCH
Definition icons.h:1673
#define ICON_MD_CONTENT_COPY
Definition icons.h:465
#define ICON_MD_FILTER_ALT
Definition icons.h:761
bool ParseTileIdText(std::string_view input, int *out_value)
float GridWidthFor(const TileSelectorWidget::Config &config)
Graphical User Interface (GUI) components for the application.
bool BeginTileDragSource(int tile_id, int map_id)
Definition drag_drop.h:65
Declarative menu item definition.
Definition canvas_menu.h:64
std::function< bool()> enabled_condition
Definition canvas_menu.h:85