yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_workbench_content.cc
Go to the documentation of this file.
2#include "util/i18n/tr.h"
3
4#include <algorithm>
5#include <array>
6#include <cctype>
7#include <cstdint>
8#include <cstdio>
9#include <cstring>
10#include <fstream>
11#include <sstream>
12#include <utility>
13#include <vector>
14
15#include "absl/strings/str_format.h"
40#include "app/gui/core/icons.h"
41#include "app/gui/core/input.h"
47#include "core/features.h"
48#include "core/project.h"
49#include "imgui/imgui.h"
50#include "rom/rom.h"
51#include "util/file_util.h"
61
62namespace yaze::editor {
63
64namespace {
65
66// Object type category names based on ID range
67const char* GetObjectCategory(int object_id) {
68 if (object_id < 0x100)
69 return "Standard";
70 if (object_id < 0x200)
71 return "Extended";
72 if (object_id >= 0xF80)
73 return "Special";
74 return "Unknown";
75}
76
77const char* GetObjectStreamName(int layer_value) {
78 switch (layer_value) {
79 case 0:
80 return "Primary";
81 case 1:
82 return "BG2 overlay";
83 case 2:
84 return "BG1 overlay";
85 default:
86 return "Unknown";
87 }
88}
89
90const char* GetSpecialLayerName(int layer_value) {
91 switch (layer_value) {
92 case 0:
93 return "Upper layer (BG1)";
94 case 1:
95 return "Lower layer (BG2)";
96 default:
97 return "Unknown";
98 }
99}
100
101float ClampWorkbenchPaneWidth(float desired_width, float min_width,
102 float max_width) {
103 return std::clamp(desired_width, min_width, std::max(min_width, max_width));
104}
105
106constexpr float kCompactLeftSidebarMinWidth = 224.0f;
107constexpr float kCompactRightSidebarMinWidth = 272.0f;
108constexpr float kCompactLeftSidebarScale = 0.72f;
109constexpr float kCompactRightSidebarScale = 0.84f;
110float GetCompactSidebarWidth(bool right_sidebar, float min_sidebar_width) {
111 return std::max(
112 right_sidebar ? kCompactRightSidebarMinWidth
114 min_sidebar_width * (right_sidebar ? kCompactRightSidebarScale
116}
117
120 rect.visible = true;
121 const ImVec2 min = ImGui::GetItemRectMin();
122 const ImVec2 max = ImGui::GetItemRectMax();
123 rect.min_x = min.x;
124 rect.min_y = min.y;
125 rect.max_x = max.x;
126 rect.max_y = max.y;
127 return rect;
128}
129
130} // namespace
131
138
140 float total_width, float min_canvas_width, float min_sidebar_width,
141 float splitter_width, bool want_left, bool want_right) {
143 result.show_left = want_left;
144 result.show_right = want_right;
145
146 auto required_width = [&](bool left, bool right, bool compact_left,
147 bool compact_right) {
148 float required = min_canvas_width;
149 required +=
150 left ? (compact_left ? GetCompactSidebarWidth(false, min_sidebar_width)
151 : min_sidebar_width)
152 : 0.0f;
153 required +=
154 right ? (compact_right ? GetCompactSidebarWidth(true, min_sidebar_width)
155 : min_sidebar_width)
156 : 0.0f;
157 if (left) {
158 required += splitter_width;
159 }
160 if (right) {
161 required += splitter_width;
162 }
163 return required;
164 };
165
166 if (result.show_left &&
167 total_width <
168 required_width(result.show_left, result.show_right, false, false)) {
169 result.compact_left = true;
170 }
171 if (result.show_right &&
172 total_width < required_width(result.show_left, result.show_right,
173 result.compact_left, false)) {
174 result.compact_right = true;
175 }
176 // Keep the entrance selector, object browser, and room navigation available
177 // when only one pane fits. Toolbar pane toggles still allow either alone.
178 if (result.show_right &&
179 total_width < required_width(result.show_left, result.show_right,
180 result.compact_left, result.compact_right)) {
181 result.show_right = false;
182 result.compact_right = false;
183 }
184 if (result.show_left &&
185 total_width < required_width(result.show_left, result.show_right,
186 result.compact_left, result.compact_right)) {
187 result.show_left = false;
188 result.compact_left = false;
189 }
190
191 return result;
192}
193
195 float total_width, float min_canvas_width, float min_sidebar_width,
196 float splitter_width, float stored_left_width, float stored_right_width,
197 bool want_left, bool want_right) {
200 total_width, min_canvas_width, min_sidebar_width, splitter_width,
201 want_left, want_right);
202
203 const float compact_left_width =
204 GetCompactSidebarWidth(false, min_sidebar_width);
205 const float compact_right_width =
206 GetCompactSidebarWidth(true, min_sidebar_width);
207 layout.min_left_width =
208 layout.responsive.compact_left ? compact_left_width : min_sidebar_width;
209 layout.min_right_width =
210 layout.responsive.compact_right ? compact_right_width : min_sidebar_width;
211
212 float left_width = layout.responsive.show_left
213 ? (layout.responsive.compact_left ? compact_left_width
214 : stored_left_width)
215 : 0.0f;
216 float right_width =
218 ? (layout.responsive.compact_right ? compact_right_width
219 : stored_right_width)
220 : 0.0f;
221
222 const float max_left_width =
223 total_width - right_width - min_canvas_width -
224 (layout.responsive.show_left ? splitter_width : 0.0f) -
225 (layout.responsive.show_right ? splitter_width : 0.0f);
226 const float max_right_width =
227 total_width - left_width - min_canvas_width -
228 (layout.responsive.show_left ? splitter_width : 0.0f) -
229 (layout.responsive.show_right ? splitter_width : 0.0f);
230 if (layout.responsive.show_left) {
231 left_width = ClampWorkbenchPaneWidth(
232 left_width, layout.min_left_width,
233 std::max(layout.min_left_width, max_left_width));
234 } else {
235 left_width = 0.0f;
236 }
237 if (layout.responsive.show_right) {
238 right_width = ClampWorkbenchPaneWidth(
239 right_width, layout.min_right_width,
240 std::max(layout.min_right_width, max_right_width));
241 } else {
242 right_width = 0.0f;
243 }
244
245 float center_width = total_width - left_width - right_width;
246 if (layout.responsive.show_left) {
247 center_width -= splitter_width;
248 }
249 if (layout.responsive.show_right) {
250 center_width -= splitter_width;
251 }
252 if (center_width < min_canvas_width) {
253 float deficit = min_canvas_width - center_width;
254 if (layout.responsive.show_left) {
255 const float shrink =
256 std::min(deficit, left_width - layout.min_left_width);
257 left_width -= shrink;
258 deficit -= shrink;
259 }
260 if (deficit > 0.0f && layout.responsive.show_right) {
261 const float shrink =
262 std::min(deficit, right_width - layout.min_right_width);
263 right_width -= shrink;
264 deficit -= shrink;
265 }
266 center_width = std::max(
267 1.0f, total_width - left_width - right_width -
268 (layout.responsive.show_left ? splitter_width : 0.0f) -
269 (layout.responsive.show_right ? splitter_width : 0.0f));
270 }
271
272 layout.left_width = left_width;
273 layout.center_width = center_width;
274 layout.right_width = right_width;
275 return layout;
276}
277
279 DungeonRoomSelector* room_selector, int* current_room_id,
280 std::function<void(int)> on_room_selected,
281 std::function<void(int, RoomSelectionIntent)> on_room_selected_with_intent,
282 std::function<void(int)> on_save_room,
283 std::function<void()> on_save_all_rooms,
284 std::function<DungeonCanvasViewer*()> get_viewer,
285 std::function<DungeonCanvasViewer*(int)> get_compare_viewer,
286 std::function<const std::deque<int>&()> get_recent_rooms,
287 std::function<void(int)> forget_recent_room,
288 std::function<void(bool)> set_workflow_mode, Rom* rom)
289 : room_selector_(room_selector),
290 current_room_id_(current_room_id),
291 on_room_selected_(std::move(on_room_selected)),
292 on_room_selected_with_intent_(std::move(on_room_selected_with_intent)),
293 on_save_room_(std::move(on_save_room)),
294 on_save_all_rooms_(std::move(on_save_all_rooms)),
295 get_viewer_(std::move(get_viewer)),
296 get_compare_viewer_(std::move(get_compare_viewer)),
297 get_recent_rooms_(std::move(get_recent_rooms)),
298 forget_recent_room_(std::move(forget_recent_room)),
299 set_workflow_mode_(std::move(set_workflow_mode)),
300 rom_(rom) {}
301
303
305 return "dungeon.workbench";
306}
308 return "Dungeon Workbench";
309}
311 return ICON_MD_WORKSPACES;
312}
314 return "Dungeon";
315}
317 return 10;
318}
319
321 rom_ = rom;
322 room_dungeon_cache_.clear();
324}
325
327 RoomTagEditorPanel* room_tags, CustomCollisionPanel* custom_collision,
328 WaterFillPanel* water_fill, MinecartTrackEditorPanel* minecart_tracks) {
329 room_tag_panel_ = room_tags;
330 custom_collision_panel_ = custom_collision;
331 water_fill_panel_ = water_fill;
332 minecart_track_panel_ = minecart_tracks;
333}
334
336 WindowContent* object_selector, WindowContent* door_editor,
337 WindowContent* sprite_editor, WindowContent* item_editor,
338 WindowContent* room_graphics, WindowContent* palette_editor) {
339 object_selector_content_ = object_selector;
340 door_editor_content_ = door_editor;
341 sprite_editor_content_ = sprite_editor;
342 item_editor_content_ = item_editor;
343 room_graphics_content_ = room_graphics;
344 palette_editor_content_ = palette_editor;
345}
346
351
355
360
364
369
373
377
378bool DungeonWorkbenchContent::LoadProposalOverlay(const std::string& path) {
379 std::snprintf(proposal_path_, sizeof(proposal_path_), "%s", path.c_str());
380 std::ifstream file(path, std::ios::binary);
381 if (!file) {
382 proposal_error_ = absl::StrFormat("Cannot open %s", path);
383 return false;
384 }
385 std::stringstream buffer;
386 buffer << file.rdbuf();
387 auto overlay = ParseDungeonProposalOverlay(buffer.str());
388 if (!overlay.ok()) {
389 // Keep the previously loaded overlay visible; show why the new one failed.
390 proposal_error_ = std::string(overlay.status().message());
391 return false;
392 }
394 for (const auto& layer : overlay->layers) {
395 proposal_layer_visible_.push_back(layer.visible ? 1 : 0);
396 }
397 proposal_overlay_ = std::move(*overlay);
398 proposal_error_.clear();
399 return true;
400}
401
403 DungeonCanvasViewer& viewer) {
405 return;
406 }
407 ImGui::SetNextWindowSize(ImVec2(1120.0f, 700.0f), ImGuiCond_FirstUseEver);
408 if (!ImGui::Begin(ICON_MD_PREVIEW " Proposal Preview##DungeonProposalPreview",
410 ImGui::End();
411 return;
412 }
413
414 // File row.
415 util::FileDialogOptions json_options;
416 json_options.filters.push_back({"Proposal Overlay", "json"});
417 json_options.filters.push_back({"All Files", "*"});
418 if (ImGui::Button(ICON_MD_FOLDER_OPEN " Open...")) {
419 const std::string path =
421 if (!path.empty()) {
422 (void)LoadProposalOverlay(path);
423 }
424 }
425 ImGui::SameLine();
426 ImGui::BeginDisabled(proposal_path_[0] == '\0');
427 if (ImGui::Button(ICON_MD_REFRESH " Reload")) {
429 }
430 ImGui::EndDisabled();
431 ImGui::SameLine();
432 ImGui::SetNextItemWidth(-1.0f);
433 if (ImGui::InputTextWithHint("##ProposalPath", tr("overlay .json path"),
435 ImGuiInputTextFlags_EnterReturnsTrue)) {
437 }
438 if (!proposal_error_.empty()) {
439 gui::StyleColorGuard error_color(ImGuiCol_Text,
440 ImVec4(1.0f, 0.45f, 0.4f, 1.0f));
441 ImGui::TextWrapped("%s", proposal_error_.c_str());
442 }
443
444 if (!proposal_overlay_.has_value()) {
445 ImGui::Spacing();
446 ImGui::TextWrapped(
447 "%s",
448 tr("Open a proposal overlay (.json) to render its rooms from the "
449 "loaded ROM with the proposed changes drawn on top. This window "
450 "is read-only: it never edits rooms or the ROM."));
451 ImGui::End();
452 return;
453 }
455
456 if (!overlay.title.empty()) {
457 ImGui::TextUnformatted(overlay.title.c_str());
458 }
459 if (!overlay.status.empty()) {
460 ImGui::SameLine();
461 ImGui::TextDisabled("(%s)", overlay.status.c_str());
462 }
463 ImGui::TextDisabled("%s",
464 tr("Read-only preview. Nothing here edits the ROM."));
465
466 // Layer and view controls.
467 ImGui::Checkbox(tr("Show proposal"), &proposal_show_overlay_);
468 ImGui::BeginDisabled(!proposal_show_overlay_);
469 for (size_t i = 0; i < overlay.layers.size(); ++i) {
470 const auto& layer = overlay.layers[i];
471 ImGui::SameLine();
472 const uint32_t c = layer.color_rgba;
473 ImGui::ColorButton(
474 absl::StrFormat("##ProposalLayerSwatch%d", i).c_str(),
475 ImVec4(((c >> 24) & 0xFF) / 255.0f, ((c >> 16) & 0xFF) / 255.0f,
476 ((c >> 8) & 0xFF) / 255.0f, 1.0f),
477 ImGuiColorEditFlags_NoTooltip, ImVec2(12, 12));
478 ImGui::SameLine(0, 4);
479 bool visible =
481 if (ImGui::Checkbox(
482 absl::StrFormat("%s##ProposalLayer%d", layer.label, i).c_str(),
483 &visible) &&
484 i < proposal_layer_visible_.size()) {
485 proposal_layer_visible_[i] = visible ? 1 : 0;
486 }
487 }
488 ImGui::EndDisabled();
489
490 ImGui::SetNextItemWidth(160.0f);
491 ImGui::SliderFloat(tr("Zoom"), &proposal_scale_, 0.5f, 2.0f, "%.2fx");
492 if (on_room_selected_) {
493 for (const auto& room : overlay.rooms) {
494 ImGui::SameLine();
495 if (ImGui::SmallButton(
496 absl::StrFormat("Open $%02X##ProposalOpenRoom", room.room_id)
497 .c_str())) {
498 on_room_selected_(room.room_id);
499 }
500 }
501 }
502
503 // Rooms with the overlay. Reserve space for notes below.
504 const float notes_height =
505 overlay.notes.empty()
506 ? 0.0f
507 : std::min(140.0f, ImGui::GetTextLineHeightWithSpacing() *
508 (overlay.notes.size() + 1.5f));
509 if (ImGui::BeginChild("##ProposalPreviewBody", ImVec2(0.0f, -notes_height),
510 true, ImGuiWindowFlags_HorizontalScrollbar)) {
513 }
514 ImGui::EndChild();
515
516 if (!overlay.notes.empty() &&
517 ImGui::BeginChild("##ProposalPreviewNotes", ImVec2(0.0f, 0.0f), false)) {
518 for (const auto& note : overlay.notes) {
519 ImGui::BulletText("%s", note.c_str());
520 }
521 }
522 if (!overlay.notes.empty()) {
523 ImGui::EndChild();
524 }
525 ImGui::End();
526}
527
531
535
539
543
547
551
555
559
563
567
571
575
577 switch (inspector_mode_) {
579 return "inspect";
581 return "tools";
582 }
583 return "unknown";
584}
585
589
591 switch (sidebar_mode_) {
593 return "objects";
595 return "sprites";
597 return "doors";
599 return "items";
600 }
601 return "unknown";
602}
603
604void DungeonWorkbenchContent::DrawSidebarPane(float width, float height,
605 float button_size, bool compact) {
606 const bool sidebar_open = ImGui::BeginChild("##DungeonWorkbenchSidebar",
607 ImVec2(width, height), false);
608 if (sidebar_open) {
609 DrawSidebarHeader(button_size, compact);
611 }
612 ImGui::EndChild();
613}
614
616 bool compact) {
617 const bool can_open_overview = true;
618 const float collapse_w =
620 const float menu_w = can_open_overview ? workbench::CalcIconButtonWidth(
621 ICON_MD_MORE_HORIZ, button_size)
622 : 0.0f;
623 const float spacing = ImGui::GetStyle().ItemSpacing.x;
624 const float action_cluster_w =
625 collapse_w + (can_open_overview ? (spacing + menu_w) : 0.0f);
626
628 "##DungeonWorkbenchSidebarHeader", ICON_MD_WIDGETS, "Place", "Place",
629 nullptr, compact, action_cluster_w, [&]() {
630 if (can_open_overview) {
631 if (workbench::DrawHeaderIconAction("SidebarQuickActions",
632 ICON_MD_MORE_HORIZ, button_size,
633 "Open room review tools", true)) {
634 ImGui::OpenPopup("##WorkbenchSidebarQuickActions");
635 }
636 if (ImGui::BeginPopup("##WorkbenchSidebarQuickActions")) {
637 if (ImGui::MenuItem(ICON_MD_VIEW_QUILT " Dungeon overview")) {
639 }
640 if (ImGui::MenuItem(ICON_MD_MAP " Dungeon Map")) {
642 }
643 if (ImGui::MenuItem(ICON_MD_PREVIEW " Proposal Preview...")) {
645 }
646 ImGui::Separator();
647 const bool can_open_shortcuts =
648 static_cast<bool>(open_keyboard_shortcuts_);
649 // Default "Keyboard Shortcuts" chord: platform Ctrl/Cmd+Shift+/.
650 const std::string keyboard_shortcuts =
651 gui::FormatCtrlShiftShortcut(ImGuiKey_Slash);
652 if (ImGui::MenuItem(ICON_MD_KEYBOARD " Keyboard Shortcuts",
653 keyboard_shortcuts.c_str(), false,
654 can_open_shortcuts) &&
657 }
658 ImGui::EndPopup();
659 }
660 ImGui::SameLine();
661 }
662 if (workbench::DrawHeaderIconAction("CollapseRooms",
663 ICON_MD_CHEVRON_LEFT, button_size,
664 "Collapse navigation pane")) {
666 }
667 });
668
670 ImGui::Separator();
671}
672
674 struct PlacementChoice {
675 const char* label;
676 WorkbenchTool tool;
677 SidebarMode mode;
678 };
679 constexpr PlacementChoice choices[] = {
684 float choice_width = 0.0f;
685 for (const auto& choice : choices)
686 choice_width = std::max(choice_width, ImGui::CalcTextSize(choice.label).x);
687 choice_width += ImGui::GetStyle().FramePadding.x * 2.0f +
688 ImGui::GetStyle().CellPadding.x * 2.0f;
689 const int columns =
690 ImGui::GetContentRegionAvail().x >= choice_width * 4.0f ? 4 : 2;
691 if (ImGui::BeginTable("##PlacementKinds", columns,
692 ImGuiTableFlags_SizingStretchSame |
693 ImGuiTableFlags_NoPadInnerX |
694 ImGuiTableFlags_NoPadOuterX)) {
695 for (const auto& choice : choices) {
696 ImGui::TableNextColumn();
697 gui::StyleColorGuard selected_guard(
698 ImGuiCol_Button, ImGui::GetStyleColorVec4(sidebar_mode_ == choice.mode
699 ? ImGuiCol_ButtonActive
700 : ImGuiCol_Button));
701 const bool pressed = ImGui::Button(
702 choice.label,
703 ImVec2(ImGui::GetContentRegionAvail().x, ImGui::GetFrameHeight()));
705 absl::StrFormat("Place %s", choice.label));
706 if (sidebar_mode_ == choice.mode) {
707 const ImVec2 min = ImGui::GetItemRectMin();
708 const ImVec2 max = ImGui::GetItemRectMax();
709 ImGui::GetWindowDrawList()->AddLine(
710 ImVec2(min.x, max.y - 1), ImVec2(max.x, max.y - 1),
711 ImGui::GetColorU32(ImGuiCol_CheckMark), 2.0f);
712 }
713 if (pressed)
714 OpenTool(choice.tool);
715 }
716 ImGui::EndTable();
717 }
718}
719
721 ImGui::PushID("WorkbenchSidebarMode");
722 const auto draw_external = [this](const char* id) {
724 return false;
725 ImGui::TextWrapped("Open in a separate window.");
726 if (ImGui::Button("Focus Window") && open_and_focus_standalone_tool_)
728 return true;
729 };
730 const float available = std::max(1.0f, ImGui::GetContentRegionAvail().y);
731 const float separator = 6.0f;
732 const float upper =
733 std::max(1.0f, (available - separator) * navigator_tools_fraction_);
734 if (ImGui::BeginChild("##NavigatorTools", ImVec2(0, upper), false)) {
735 WindowContent* content = nullptr;
736 switch (sidebar_mode_) {
738 content = object_selector_content_;
739 break;
741 content = sprite_editor_content_;
742 break;
744 content = door_editor_content_;
745 break;
747 content = item_editor_content_;
748 break;
749 }
750 if (!content) {
751 ImGui::TextDisabled("Placement library unavailable");
752 } else if (!draw_external(content->GetId().c_str())) {
753 if (auto* selector = dynamic_cast<ObjectSelectorContent*>(content))
754 selector->DrawInWorkbench();
755 else if (auto* sprites = dynamic_cast<SpriteEditorPanel*>(content))
756 sprites->DrawInWorkbench();
757 else if (auto* doors = dynamic_cast<DoorEditorContent*>(content))
758 doors->DrawInWorkbench();
759 else if (auto* items = dynamic_cast<ItemEditorPanel*>(content))
760 items->DrawInWorkbench();
761 else
762 content->Draw(nullptr);
763 }
764 }
765 ImGui::EndChild();
766 const ImVec2 divider = ImGui::GetCursorScreenPos();
767 ImGui::InvisibleButton("##NavigatorSplit", ImVec2(-1, separator));
768 if (ImGui::IsItemHovered() || ImGui::IsItemActive())
769 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS);
770 if (ImGui::IsItemActive())
771 navigator_tools_fraction_ = std::clamp(
772 navigator_tools_fraction_ + ImGui::GetIO().MouseDelta.y / available,
773 0.20f, 0.65f);
774 ImGui::GetWindowDrawList()->AddLine(
775 ImVec2(divider.x, divider.y + separator * 0.5f),
776 ImVec2(ImGui::GetItemRectMax().x, divider.y + separator * 0.5f),
777 ImGui::GetColorU32(ImGuiCol_Separator));
778 if (ImGui::BeginChild("##NavigatorMatrix", ImVec2(0, 0), false)) {
779 if (!draw_external("dungeon.room_matrix")) {
781 room_matrix_content_->Draw(nullptr);
782 else
783 ImGui::TextDisabled("Room Matrix unavailable");
784 }
785 }
786 ImGui::EndChild();
787 ImGui::PopID();
788}
789
791 (void)p_open;
792 const auto& theme = AgentUI::GetTheme();
793
794 if (!rom_ || !rom_->is_loaded()) {
795 ImGui::TextDisabled(ICON_MD_INFO " Load a ROM to edit dungeon rooms.");
796 return;
797 }
799 ImGui::TextColored(theme.text_error_red, tr("Dungeon Workbench not wired"));
800 return;
801 }
802
803 DungeonCanvasViewer* primary_viewer = get_viewer_ ? get_viewer_() : nullptr;
804 DungeonCanvasViewer* compare_viewer =
806 const float splitter_w = gui::UIConfig::kSplitterWidth;
807 const float total_w = std::max(ImGui::GetContentRegionAvail().x, 1.0f);
808 // Relaxed from 320 px to 260 px so the Inspect pane can breathe at widths
809 // where the previous floor forced a too-wide right rail. Room browser on the
810 // left also benefits since its matrix cells scale down gracefully.
811 const float min_sidebar_w =
813 const float min_canvas_w = std::max(420.0f, min_sidebar_w + 96.0f);
814 const DungeonWorkbenchPaneLayout pane_layout =
816 total_w, min_canvas_w, min_sidebar_w, splitter_w,
819 const bool show_left = pane_layout.responsive.show_left;
820 const bool show_right = pane_layout.responsive.show_right;
821
822 if (current_room_id_) {
824 params.layout = &layout_state_;
829 params.primary_viewer = primary_viewer;
830 params.compare_viewer = compare_viewer;
834 params.on_open_room_panel = [this](int room_id) {
837 };
838 }
842 params.draw_tools = [this]() {
844 };
845 params.draw_save_content = [this]() {
847 };
848 params.on_request_dungeon_map = [this]() {
850 };
853 params.draw_entrance_context = [this](float width) {
856 };
857 const bool request_panel_workflow = DungeonWorkbenchToolbar::Draw(params);
858 if (request_panel_workflow && set_workflow_mode_) {
859 // Defer panel visibility mutation until toolbar child/table scopes closed.
860 set_workflow_mode_(false);
861 return;
862 }
863 }
864
866 const float total_h = std::max(ImGui::GetContentRegionAvail().y, 1.0f);
867 const float left_w = pane_layout.left_width;
868 const float right_w = pane_layout.right_width;
869 const float center_w = pane_layout.center_width;
870 if (show_left && !pane_layout.responsive.compact_left) {
871 layout_state_.left_width = left_w;
872 }
873 if (show_right && !pane_layout.responsive.compact_right) {
874 layout_state_.right_width = right_w;
875 }
876
877 // Mirror toggle: when inspector_on_left_, swap render order so the inspector
878 // visually sits on the left of the canvas (ZScream-style). Width state is
879 // preserved (right_width is always the inspector's, left_width always the
880 // sidebar's) so splitter drags still affect the correct pane regardless of
881 // which side it's on.
882 if (inspector_on_left_) {
883 if (show_right) {
884 DrawInspectorPane(right_w, total_h, btn,
885 pane_layout.responsive.compact_right, primary_viewer);
886 ImGui::SameLine(0.0f, 0.0f);
888 "##DungeonWorkbenchLeftSplitter", total_h,
890 total_w - left_w - min_canvas_w - (show_left ? splitter_w : 0.0f),
891 false)) {
893 }
894 ImGui::SameLine(0.0f, 0.0f);
895 }
896 DrawCanvasPane(center_w, total_h, primary_viewer);
897 if (show_left) {
898 ImGui::SameLine(0.0f, 0.0f);
900 "##DungeonWorkbenchRightSplitter", total_h,
902 total_w - right_w - min_canvas_w -
903 (show_right ? splitter_w : 0.0f),
904 true)) {
906 }
907 ImGui::SameLine(0.0f, 0.0f);
908 DrawSidebarPane(left_w, total_h, btn,
909 pane_layout.responsive.compact_left);
910 }
911 } else {
912 if (show_left) {
913 DrawSidebarPane(left_w, total_h, btn,
914 pane_layout.responsive.compact_left);
915 }
916 if (show_left) {
917 ImGui::SameLine(0.0f, 0.0f);
919 "##DungeonWorkbenchLeftSplitter", total_h,
921 total_w - right_w - min_canvas_w -
922 (show_right ? splitter_w : 0.0f),
923 false)) {
925 }
926 }
927
928 if (show_left) {
929 ImGui::SameLine(0.0f, 0.0f);
930 }
931 DrawCanvasPane(center_w, total_h, primary_viewer);
932
933 if (show_right) {
934 ImGui::SameLine(0.0f, 0.0f);
936 "##DungeonWorkbenchRightSplitter", total_h,
938 total_w - left_w - min_canvas_w - (show_left ? splitter_w : 0.0f),
939 true)) {
941 }
942 ImGui::SameLine(0.0f, 0.0f);
943 }
944 if (show_right) {
945 DrawInspectorPane(right_w, total_h, btn,
946 pane_layout.responsive.compact_right, primary_viewer);
947 }
948 }
949 if (primary_viewer) {
950 DrawDungeonMapPopup(*primary_viewer);
951 }
952}
953
955 float width, float height, DungeonCanvasViewer* primary_viewer) {
956 const bool canvas_open = ImGui::BeginChild(
957 "##DungeonWorkbenchCanvas", ImVec2(width, height), false,
958 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
959 if (canvas_open) {
960 if (primary_viewer) {
961 // Reserve a fixed strip at the bottom for the status bar so neither
962 // single-room, split, nor connected-mode rendering can run past it and
963 // clip against the outer window chrome. Inner scrolling (wheel zoom in
964 // connected mode, canvas pan in single-room) happens inside this body
965 // child — the outer ##DungeonWorkbenchCanvas never needs a scrollbar.
966 const float status_bar_reserved =
967 std::max(
968 ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f,
970 ImGui::GetStyle().ItemSpacing.y;
971
972 const bool connected_mode = layout_state_.show_connected_canvas_view;
973 ImGuiWindowFlags body_flags =
974 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
975 if (connected_mode) {
976 // Body child owns the connected-mode scroll container; matrix no
977 // longer nests its own BeginChild for scrolling.
978 const ImVec2 content_size =
980 const float scale = primary_viewer->ConnectedCanvasScale();
981 ImGui::SetNextWindowContentSize(
982 ImVec2(content_size.x * scale, content_size.y * scale));
983 body_flags = ImGuiWindowFlags_HorizontalScrollbar |
984 ImGuiWindowFlags_NoScrollWithMouse;
985 }
986 const bool body_open = ImGui::BeginChild(
987 "##DungeonCanvasBody", ImVec2(0.0f, -status_bar_reserved), false,
988 body_flags);
989 if (body_open) {
990 if (connected_mode) {
991 split_view_enabled_ = false;
992 if (primary_viewer->DrawConnectedRoomMatrix(*current_room_id_)
993 .has_value()) {
995 }
996 } else if (split_view_enabled_) {
997 DrawSplitView(*primary_viewer);
998 } else {
999 primary_viewer->DrawDungeonCanvas(*current_room_id_);
1000 }
1002 on_primary_canvas_drawn_(*primary_viewer);
1003 }
1004 }
1005 ImGui::EndChild();
1006
1007 const char* tool_mode =
1008 primary_viewer->object_interaction().mode_manager().GetModeName();
1009 bool room_dirty = false;
1010 if (auto* rooms = primary_viewer->rooms();
1011 rooms && current_room_id_ && *current_room_id_ >= 0) {
1012 if (const auto* room = rooms->GetIfMaterialized(*current_room_id_)) {
1013 room_dirty = room->HasUnsavedChanges();
1014 }
1015 }
1016 auto status =
1017 DungeonStatusBar::BuildState(*primary_viewer, tool_mode, room_dirty);
1018 status.workflow_mode = layout_state_.show_connected_canvas_view
1020 : nullptr;
1021 status.workflow_primary = true;
1022 if (can_undo_)
1023 status.can_undo = can_undo_();
1024 if (can_redo_)
1025 status.can_redo = can_redo_();
1026 if (undo_desc_) {
1027 static std::string s_undo_desc;
1028 s_undo_desc = undo_desc_();
1029 status.undo_desc = s_undo_desc.empty() ? nullptr : s_undo_desc.c_str();
1030 }
1031 if (redo_desc_) {
1032 static std::string s_redo_desc;
1033 s_redo_desc = redo_desc_();
1034 status.redo_desc = s_redo_desc.empty() ? nullptr : s_redo_desc.c_str();
1035 }
1036 if (undo_depth_)
1037 status.undo_depth = undo_depth_();
1038 status.on_undo = on_undo_;
1039 status.on_redo = on_redo_;
1041 status.on_selection = [this]() {
1043 };
1044 }
1045 DungeonStatusBar::Draw(status);
1046 } else {
1047 ImGui::TextDisabled(tr("No active viewer"));
1048 }
1049 }
1050 ImGui::EndChild();
1051 // Floating, independent window; drawn here so it stays available while the
1052 // navigation sidebar is collapsed.
1053 if (primary_viewer) {
1054 DrawProposalPreviewWindow(*primary_viewer);
1055 }
1056}
1057
1058void DungeonWorkbenchContent::DrawInspectorPane(float width, float height,
1059 float button_size, bool compact,
1060 DungeonCanvasViewer* viewer) {
1061 const bool inspector_open = ImGui::BeginChild("##DungeonWorkbenchInspector",
1062 ImVec2(width, height), true);
1063 if (inspector_open) {
1064 DrawInspectorHeader(button_size, compact);
1065 if (viewer) {
1066 DrawInspector(*viewer, compact);
1067 } else {
1068 ImGui::TextDisabled(tr("No active viewer"));
1069 }
1070 }
1071 ImGui::EndChild();
1072}
1073
1075 bool compact) {
1076 // Chevron flips with mirror state so the collapse arrow always points
1077 // toward the inspector's exit edge (off-right when on right, off-left when
1078 // on left).
1079 const char* collapse_icon =
1081 const float collapse_w =
1082 workbench::CalcIconButtonWidth(collapse_icon, button_size);
1083 const float swap_w =
1085 const float spacing = ImGui::GetStyle().ItemSpacing.x;
1086 const float action_cluster_w = swap_w + collapse_w + spacing;
1087
1088 const bool tools_mode = inspector_mode_ == InspectorMode::Tools;
1090 "##DungeonWorkbenchInspectorHeader",
1091 tools_mode ? ICON_MD_BUILD : ICON_MD_TUNE,
1092 tools_mode ? "Tools" : "Inspect",
1093 tools_mode ? GetWorkbenchToolShortLabel(active_tool_) : "Inspect",
1094 nullptr, compact, action_cluster_w, [&]() {
1096 "SwapInspectorSide", ICON_MD_SWAP_HORIZ, button_size,
1097 inspector_on_left_ ? "Move inspector to the right side"
1098 : "Move inspector to the left side "
1099 "(ZScream layout)")) {
1103 }
1104 }
1105 ImGui::SameLine(0.0f, spacing);
1106 if (workbench::DrawHeaderIconAction("CollapseInspector", collapse_icon,
1107 button_size,
1108 "Collapse inspector")) {
1110 }
1111 });
1112}
1113
1115 DungeonCanvasViewer& primary_viewer) {
1117 if (split_view_enabled_) {
1118 split_view_enabled_ = false;
1119 }
1120 return;
1121 }
1122
1123 // Choose a sensible default compare room (most-recent non-current).
1125 if (get_recent_rooms_) {
1126 for (int rid : get_recent_rooms_()) {
1127 if (rid != *current_room_id_) {
1128 compare_room_id_ = rid;
1129 break;
1130 }
1131 }
1132 }
1133 }
1134
1135 if (compare_room_id_ < 0) {
1136 // Nothing to compare yet.
1137 split_view_enabled_ = false;
1138 primary_viewer.DrawDungeonCanvas(*current_room_id_);
1139 return;
1140 }
1141
1142 constexpr ImGuiTableFlags kSplitFlags =
1143 ImGuiTableFlags_Resizable | ImGuiTableFlags_NoPadOuterX |
1144 ImGuiTableFlags_NoPadInnerX | ImGuiTableFlags_BordersInnerV;
1145
1146 if (!ImGui::BeginTable("##DungeonWorkbenchSplit", 2, kSplitFlags)) {
1147 primary_viewer.DrawDungeonCanvas(*current_room_id_);
1148 return;
1149 }
1150
1151 ImGui::TableSetupColumn("Active", ImGuiTableColumnFlags_WidthStretch);
1152 ImGui::TableSetupColumn("Compare", ImGuiTableColumnFlags_WidthStretch);
1153 ImGui::TableNextRow();
1154
1155 DungeonCanvasViewer* compare_viewer =
1157
1158 // Active pane (minimum height so canvas never collapses)
1159 ImGui::TableNextColumn();
1160 ImGui::AlignTextToFramePadding();
1161 const project::YazeProject* active_project = primary_viewer.project();
1162 ImGui::TextDisabled(
1163 ICON_MD_CROP_FREE " Active [%03X] %s", *current_room_id_,
1165 .c_str());
1166 ImGui::Separator();
1167 const bool split_active_open = gui::LayoutHelpers::BeginContentChild(
1168 "##SplitActive", ImVec2(1.0f, 1.0f), false,
1169 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
1170 if (split_active_open) {
1171 primary_viewer.DrawDungeonCanvas(*current_room_id_);
1172 }
1174
1175 // Compare pane
1176 ImGui::TableNextColumn();
1177 ImGui::AlignTextToFramePadding();
1178 const project::YazeProject* compare_project =
1179 compare_viewer ? compare_viewer->project() : active_project;
1180 ImGui::TextDisabled(
1181 ICON_MD_COMPARE_ARROWS " Compare [%03X] %s", compare_room_id_,
1183 .c_str());
1184 ImGui::Separator();
1185 const bool split_compare_open = gui::LayoutHelpers::BeginContentChild(
1186 "##SplitCompare", ImVec2(1.0f, 1.0f), false,
1187 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
1188 if (split_compare_open) {
1189 if (compare_viewer) {
1191 compare_viewer->canvas().ApplyScaleSnapshot(
1192 primary_viewer.canvas().GetConfig());
1193 }
1194 compare_viewer->DrawDungeonCanvas(compare_room_id_);
1195 } else {
1196 ImGui::TextDisabled(tr("No compare viewer"));
1197 }
1198 }
1200
1201 ImGui::EndTable();
1202}
1203
1205 room_dungeon_cache_.clear();
1206 room_dungeon_cache_built_ = true; // Always set, even if ROM missing.
1207 if (!rom_ || !rom_->is_loaded() ||
1209 return;
1210
1211 // Short dungeon names for display in the inspector badge.
1212 // Indices 0-13 = vanilla ALTTP dungeons; higher indices = custom/Oracle.
1213 static const char* const kShortNames[] = {
1214 "Sewers", "HC", "Eastern", "Desert", "A-Tower", "Swamp", "PoD",
1215 "Misery", "Skull", "Ice", "Hera", "Thieves", "Turtle", "GT",
1216 };
1217 constexpr int kVanillaCount =
1218 static_cast<int>(sizeof(kShortNames) / sizeof(kShortNames[0]));
1219
1220 auto AddRoom = [&](int room_id, int dungeon_id) {
1221 if (room_id < 0 || room_id >= zelda3::kNumberOfRooms)
1222 return;
1223 if (room_dungeon_cache_.contains(room_id))
1224 return; // Entrance wins over spawn.
1225 if (dungeon_id >= 0 && dungeon_id < kVanillaCount) {
1226 room_dungeon_cache_[room_id] = kShortNames[dungeon_id];
1227 } else {
1228 char buf[16];
1229 snprintf(buf, sizeof(buf), "Dungeon %02X", dungeon_id);
1230 room_dungeon_cache_[room_id] = buf;
1231 }
1232 };
1233
1234 // Standard entrances — authoritative dungeon assignment.
1235 for (int i = 0; i < zelda3::kNumRegularDungeonEntrances; ++i) {
1236 zelda3::RoomEntrance ent(rom_, static_cast<uint8_t>(i), false);
1237 if (!ent.load_status().ok() || ent.room_ < 0 ||
1239 continue;
1240 int did = ent.dungeon_id_;
1241 if (did >= 0 && did < kVanillaCount) {
1242 room_dungeon_cache_[ent.room_] = kShortNames[did];
1243 } else {
1244 char buf[16];
1245 snprintf(buf, sizeof(buf), "Dungeon %02X", did);
1246 room_dungeon_cache_[ent.room_] = buf;
1247 }
1248 }
1249
1250 // Spawn points — fill in rooms not covered by entrances.
1251 for (int i = 0; i < zelda3::kNumDungeonSpawnPoints; ++i) {
1252 zelda3::RoomEntrance ent(rom_, static_cast<uint8_t>(i), true);
1253 if (ent.load_status().ok())
1254 AddRoom(static_cast<int>(ent.room_), static_cast<int>(ent.dungeon_id_));
1255 }
1256}
1257
1259 bool compact) {
1260 // Gentle compaction only; inspector sections own their own internal
1261 // spacing (collapsing-header headers, action buttons), so let the theme
1262 // defaults shape inter-section breathing room here.
1263 gui::StyleVarGuard item_spacing_guard(
1264 ImGuiStyleVar_ItemSpacing,
1265 ImVec2(std::max(ImGui::GetStyle().ItemSpacing.x, 4.0f),
1266 std::max(4.0f, ImGui::GetStyle().ItemSpacing.y - 1.0f)));
1267 DrawInspectorShelf(viewer, compact);
1268}
1269
1271 auto& flags = core::FeatureFlags::get().dungeon;
1272 flags.kSaveObjects = value;
1273 flags.kSaveSprites = value;
1274 flags.kSaveRoomHeaders = value;
1275 flags.kSaveChests = value;
1276 flags.kSavePotItems = value;
1277 flags.kSaveEntrances = value;
1278 flags.kSavePalettes = value;
1279 flags.kSaveCollision = value;
1280 flags.kSaveBlocks = value;
1281 flags.kSaveTorches = value;
1282 flags.kSavePits = value;
1283}
1284
1286 auto& flags = core::FeatureFlags::get().dungeon;
1287 ImGui::TextUnformatted(tr("Room content to apply"));
1288 constexpr ImGuiTableFlags kFlags =
1289 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX;
1290 if (ImGui::BeginTable("##WorkbenchSaveContentFlags", 2, kFlags,
1291 ImVec2(320.0f, 0.0f))) {
1292 auto draw_checkbox = [](const char* label, bool* value) {
1293 ImGui::TableNextColumn();
1294 ImGui::Checkbox(label, value);
1295 };
1296 ImGui::TableNextRow();
1297 draw_checkbox("Room objects", &flags.kSaveObjects);
1298 draw_checkbox("Room sprites", &flags.kSaveSprites);
1299 ImGui::TableNextRow();
1300 draw_checkbox("Room properties", &flags.kSaveRoomHeaders);
1301 draw_checkbox("Chest contents", &flags.kSaveChests);
1302 ImGui::TableNextRow();
1303 draw_checkbox("Items under pots", &flags.kSavePotItems);
1304 draw_checkbox("Palettes", &flags.kSavePalettes);
1305 ImGui::TableNextRow();
1306 draw_checkbox("Custom collision", &flags.kSaveCollision);
1307 ImGui::TableNextColumn();
1308 ImGui::BeginDisabled();
1309 ImGui::Checkbox("Water Fill", &flags.kSaveWaterFillZones);
1310 ImGui::EndDisabled();
1311 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1312 ImGui::SetTooltip(
1313 "Water Fill saving is fixed when the project opens. Change "
1314 "save_dungeon_water_fill_zones in the .yaze file, then reopen the "
1315 "project.");
1316 }
1317 ImGui::TableNextRow();
1318 draw_checkbox("Push blocks", &flags.kSaveBlocks);
1319 draw_checkbox("Torches", &flags.kSaveTorches);
1320 ImGui::TableNextRow();
1321 draw_checkbox("Pit damage", &flags.kSavePits);
1322 draw_checkbox("Entrances", &flags.kSaveEntrances);
1323 ImGui::EndTable();
1324 }
1325
1326 if (ImGui::SmallButton(tr("Select all##WorkbenchSaveContent"))) {
1327 SetAllSaveFlags(true);
1328 }
1329 ImGui::SameLine();
1330 if (ImGui::SmallButton(tr("Select none##WorkbenchSaveContent"))) {
1331 SetAllSaveFlags(false);
1332 }
1333
1334 ImGui::Separator();
1335 const auto apply_room_label =
1336 absl::StrFormat(ICON_MD_SAVE " Apply room 0x%03X", room_id);
1337 if (on_save_room_ && room_id >= 0 &&
1338 workbench::DrawActionButton(apply_room_label.c_str(), ImVec2(-1, 0))) {
1339 on_save_room_(room_id);
1340 }
1341 if (on_save_all_rooms_ &&
1342 workbench::DrawActionButton(ICON_MD_SAVE_ALT " Apply all loaded rooms",
1343 ImVec2(-1, 0))) {
1345 }
1346 ImGui::TextWrapped(
1347 tr("Apply uses the checked content. File > Save ROM "
1348 "writes the applied changes to disk."));
1349}
1350
1353 if (pit_damage_chooser_room_id_ >= 0 &&
1354 pit_damage_chooser_room_id_ != room_id) {
1355 if (ImGui::BeginPopup("##PitDamageSlotChooser")) {
1356 ImGui::CloseCurrentPopup();
1357 ImGui::EndPopup();
1358 }
1360 }
1361 const auto& theme = AgentUI::GetTheme();
1362 zelda3::PitDamageTable* table =
1364 if (!table) {
1365 bool enabled = false;
1366 ImGui::BeginDisabled();
1367 ImGui::Checkbox(tr("Pit damage##PitDamageEnabled"), &enabled);
1368 ImGui::EndDisabled();
1369 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1370 ImGui::SetTooltip(tr("Available after room data loads."));
1371 }
1372 return;
1373 }
1374 const auto membership = BuildPitDamageMembershipState(
1375 table, room_id, pit_damage_replacement_room_id_,
1377 if (!membership.room_valid) {
1378 ImGui::TextDisabled(tr("No valid room selected."));
1379 return;
1380 }
1381
1382 const uint16_t current_room = membership.room_id;
1383 bool room_deals_damage = membership.deals_damage;
1384 if (ImGui::Checkbox(tr("Pit damage##PitDamageEnabled"), &room_deals_damage)) {
1386 ImGui::OpenPopup("##PitDamageSlotChooser");
1387 }
1388 {
1389 gui::AutoWidgetScope automation_scope("Dungeon/Workbench");
1390 gui::AutoRegisterLastItem("checkbox", "PitDamageEnabled");
1391 }
1392 if (ImGui::IsItemHovered()) {
1393 ImGui::SetTooltip(
1394 "Choose which room gives up or receives a pit-damage slot when "
1395 "changing this room.");
1396 }
1397
1398 if (membership.dirty) {
1399 ImGui::TextColored(theme.status_warning,
1400 ICON_MD_EDIT " Pending pit damage change");
1401 }
1402
1403 auto show_status = [&]() {
1404 if (pit_damage_status_message_.empty()) {
1405 return;
1406 }
1407 ImGui::PushTextWrapPos(0.0f);
1408 ImGui::TextColored(
1409 pit_damage_status_error_ ? theme.status_error : theme.status_success,
1410 "%s %s",
1413 ImGui::PopTextWrapPos();
1414 };
1415
1416 if (!ImGui::BeginPopup("##PitDamageSlotChooser")) {
1417 show_status();
1418 return;
1419 }
1420
1421 // Toggling requests a membership change. Only the explicit confirmation
1422 // transfers a fixed-capacity slot; closing the chooser leaves it untouched.
1423 if (membership.deals_damage) {
1424 if (!membership.suggested_replacement_room.has_value()) {
1425 ImGui::PushTextWrapPos(0.0f);
1426 ImGui::TextDisabled(tr("No available non-damaging room slot."));
1427 ImGui::PopTextWrapPos();
1428 show_status();
1429 ImGui::EndPopup();
1430 return;
1431 }
1432 pit_damage_replacement_room_id_ = *membership.suggested_replacement_room;
1433
1434 ImGui::TextUnformatted(tr("Move pit damage to room:"));
1435 ImGui::SetNextItemWidth(88.0f);
1436 auto edit_value = pit_damage_replacement_room_id_;
1437 if (auto res = gui::InputHexWordEx("##PitDamageReplacementRoom",
1438 &edit_value, 88.0f, true);
1439 res.ShouldApply()) {
1441 std::min<uint16_t>(edit_value, zelda3::kNumberOfRooms - 1);
1442 }
1443 if (ImGui::IsItemHovered()) {
1444 ImGui::SetTooltip(
1445 tr("Replacement room must not already be in the table."));
1446 }
1447 const bool replace_clicked =
1448 ImGui::Button(tr("Disable pit damage##PitDamageReplaceCurrent"));
1449 pit_damage_control_rects_.replace_current = CaptureLastItemRectForTesting();
1450 if (replace_clicked) {
1451 auto status = RemoveCurrentRoomFromPitDamage(
1452 table, current_room, pit_damage_replacement_room_id_);
1453 pit_damage_status_error_ = !status.ok();
1455 status.ok()
1456 ? absl::StrFormat("Room 0x%03X removed; slot now 0x%03X",
1457 current_room, pit_damage_replacement_room_id_)
1458 : std::string(status.message());
1459 if (status.ok()) {
1461 ImGui::CloseCurrentPopup();
1462 }
1463 }
1464 } else {
1465 if (!membership.suggested_victim_room.has_value()) {
1466 ImGui::PushTextWrapPos(0.0f);
1467 ImGui::TextDisabled(tr("No existing pit-damage slot is available."));
1468 ImGui::PopTextWrapPos();
1469 show_status();
1470 ImGui::EndPopup();
1471 return;
1472 }
1473 pit_damage_victim_room_id_ = *membership.suggested_victim_room;
1474
1475 ImGui::TextUnformatted(tr("Take pit damage from room:"));
1476 ImGui::SetNextItemWidth(88.0f);
1477 auto edit_value = pit_damage_victim_room_id_;
1478 if (auto res = gui::InputHexWordEx("##PitDamageVictimRoom", &edit_value,
1479 88.0f, true);
1480 res.ShouldApply()) {
1482 std::min<uint16_t>(edit_value, zelda3::kNumberOfRooms - 1);
1483 }
1484 if (ImGui::IsItemHovered()) {
1485 ImGui::SetTooltip(tr("This room will stop dealing pit damage."));
1486 }
1487 const bool add_clicked =
1488 ImGui::Button(tr("Enable pit damage##PitDamageAddCurrent"));
1489 pit_damage_control_rects_.add_current = CaptureLastItemRectForTesting();
1490 if (add_clicked) {
1491 auto status = AddCurrentRoomToPitDamage(table, current_room,
1493 pit_damage_status_error_ = !status.ok();
1495 status.ok()
1496 ? absl::StrFormat("Room 0x%03X added; replaced 0x%03X",
1497 current_room, pit_damage_victim_room_id_)
1498 : std::string(status.message());
1499 if (status.ok()) {
1501 ImGui::CloseCurrentPopup();
1502 }
1503 }
1504 }
1505
1506 ImGui::SameLine();
1507 if (ImGui::Button(tr("Cancel##PitDamageCancel"))) {
1509 ImGui::CloseCurrentPopup();
1510 }
1511 {
1512 gui::AutoWidgetScope automation_scope("Dungeon/Workbench");
1513 gui::AutoRegisterLastItem("button", "PitDamageCancel");
1514 }
1515 ImGui::EndPopup();
1516 show_status();
1517 ImGui::TextDisabled(tr("Include Pit damage under Save content."));
1518}
1519
1521 DungeonCanvasViewer& viewer) {
1523 return nullptr;
1524 }
1525 if (!embedded_dungeon_map_) {
1526 embedded_dungeon_map_ = std::make_unique<DungeonMapPanel>(
1528 on_room_selected_, viewer.rooms());
1530 if (*current_room_id_ >= 0) {
1532 }
1533 }
1534 embedded_dungeon_map_->SetRooms(viewer.rooms());
1535 embedded_dungeon_map_->SetRenderContextCallback(prepare_room_render_context_);
1536 if (const auto* project = viewer.project()) {
1537 embedded_dungeon_map_->SetHackManifest(&project->hack_manifest);
1538 } else {
1539 embedded_dungeon_map_->SetHackManifest(nullptr);
1540 }
1541 return embedded_dungeon_map_.get();
1542}
1543
1545 constexpr const char* kPopupId = "Dungeon Map##WorkbenchDungeonMapPopup";
1547 const ImVec2 viewport = ImGui::GetMainViewport()->WorkSize;
1548 ImGui::SetNextWindowSize(ImVec2(std::min(1040.0f, viewport.x * 0.85f),
1549 std::min(780.0f, viewport.y * 0.85f)),
1550 ImGuiCond_Appearing);
1551 ImGui::OpenPopup(kPopupId);
1553 }
1554
1555 bool popup_open = true;
1556 if (ImGui::BeginPopupModal(kPopupId, &popup_open,
1557 ImGuiWindowFlags_NoSavedSettings)) {
1558 if (!popup_open) {
1559 ImGui::CloseCurrentPopup();
1560 ImGui::EndPopup();
1561 return;
1562 }
1563 if (auto* map = GetEmbeddedDungeonMap(viewer)) {
1564 const ImVec2 map_size =
1565 ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x),
1566 std::max(1.0f, ImGui::GetContentRegionAvail().y -
1567 ImGui::GetFrameHeightWithSpacing()));
1568 if (ImGui::BeginChild("##WorkbenchDungeonMapBody", map_size, false)) {
1569 map->Draw(nullptr);
1570 }
1571 ImGui::EndChild();
1572 } else {
1573 ImGui::TextDisabled(tr("Dungeon map unavailable"));
1574 }
1575
1576 if (workbench::DrawActionButton(ICON_MD_CLOSE " Close", ImVec2(-1, 0))) {
1577 ImGui::CloseCurrentPopup();
1578 }
1579 ImGui::EndPopup();
1580 }
1581}
1582
1584 if (tool == WorkbenchTool::None) {
1585 return;
1586 }
1587 active_tool_ = tool;
1592 }
1593
1596 // A pinned or explicitly popped-out tool remains the presentation owner.
1597 // Re-open/focus it, and never draw the same WindowContent twice.
1600 if (WindowContent* content = GetWorkbenchToolContent(tool)) {
1601 (void)open_and_focus_standalone_tool_(content->GetId());
1602 }
1603 }
1604 return;
1605 }
1606
1607 if (tool == WorkbenchTool::ObjectSelector ||
1617 return;
1618 }
1619
1622}
1623
1654
1656 WorkbenchTool tool) const {
1657 return GetWorkbenchToolContent(tool) != nullptr;
1658}
1659
1661 WindowContent* content = GetWorkbenchToolContent(tool);
1662 if (!content || !is_standalone_tool_open_) {
1663 return false;
1664 }
1665 const std::string standalone_id = content->GetId();
1666 return !standalone_id.empty() && is_standalone_tool_open_(standalone_id);
1667}
1668
1674 }
1675 if (get_viewer_) {
1676 if (auto* viewer = get_viewer_()) {
1677 viewer->object_interaction().HandleMouseRelease();
1678 viewer->object_interaction().CancelPlacement();
1679 viewer->object_interaction().mode_manager().CancelCurrentMode();
1680 }
1681 }
1682 }
1684}
1685
1688 if (!content || !open_and_focus_standalone_tool_) {
1689 return false;
1690 }
1691
1692 const std::string standalone_id = content->GetId();
1693 if (standalone_id.empty() ||
1694 !open_and_focus_standalone_tool_(standalone_id)) {
1695 return false;
1696 }
1697
1699 return true;
1700}
1701
1703 WorkbenchTool tool) const {
1704 switch (tool) {
1706 return "room_tags";
1708 return "custom_collision";
1710 return "water_fill";
1712 return "minecart";
1714 return "object_coverage";
1716 return "object_selector";
1718 return "door";
1720 return "sprite";
1722 return "item";
1724 return "room_graphics";
1726 return "palette";
1728 return "none";
1729 }
1730 return "unknown";
1731}
1732
1734 WorkbenchTool tool) const {
1735 switch (tool) {
1737 return "Room Tags";
1739 return "Custom Collision";
1741 return "Water Fill";
1743 return "Minecart Tracks";
1745 return "Object Coverage";
1747 return "Object Selector";
1749 return "Door Tools";
1751 return "Sprite Tools";
1753 return "Item Tools";
1755 return "Room Graphics";
1757 return "Palette";
1759 return "Tool";
1760 }
1761 return "Tool";
1762}
1763
1765 WorkbenchTool tool) const {
1766 switch (tool) {
1768 return "Room tag tools are not available.";
1770 return "Custom collision tools are not available.";
1772 return "Water fill tools are not available.";
1774 return "Minecart track tools are not available.";
1776 return "Object coverage is not available.";
1778 return "Object selector is not available.";
1780 return "Door tools are not available.";
1782 return "Sprite tools are not available.";
1784 return "Item tools are not available.";
1786 return "Room graphics tools are not available.";
1788 return "Palette tools are not available.";
1790 return "No Workbench tool selected.";
1791 }
1792 return "Tool is not available.";
1793}
1794
1796 WorkbenchTool tool) {
1797 const int room_id = viewer.current_room_id();
1798 auto draw_window_content = [](WindowContent* content,
1799 const char* unavailable_message) {
1800 if (!content) {
1801 ImGui::TextDisabled("%s", unavailable_message);
1802 return;
1803 }
1804 content->Draw(nullptr);
1805 };
1806
1807 ImGui::PushID(GetWorkbenchToolId(tool));
1808 switch (tool) {
1810 if (!room_tag_panel_) {
1811 ImGui::TextDisabled("%s", GetWorkbenchToolUnavailableMessage(tool));
1812 break;
1813 }
1815 room_tag_panel_->Draw(nullptr);
1816 break;
1819 ImGui::TextDisabled("%s", GetWorkbenchToolUnavailableMessage(tool));
1820 break;
1821 }
1824 custom_collision_panel_->Draw(nullptr);
1825 break;
1827 if (!water_fill_panel_) {
1828 ImGui::TextDisabled("%s", GetWorkbenchToolUnavailableMessage(tool));
1829 break;
1830 }
1833 water_fill_panel_->Draw(nullptr);
1834 break;
1836 if (!minecart_track_panel_) {
1837 ImGui::TextDisabled("%s", GetWorkbenchToolUnavailableMessage(tool));
1838 break;
1839 }
1840 minecart_track_panel_->Draw(nullptr);
1841 break;
1843 draw_window_content(object_coverage_content_,
1845 break;
1847 if (auto* selector =
1849 selector->DrawInWorkbench();
1850 } else {
1851 draw_window_content(object_selector_content_,
1853 }
1854 break;
1856 draw_window_content(door_editor_content_,
1858 break;
1860 draw_window_content(sprite_editor_content_,
1862 break;
1864 draw_window_content(item_editor_content_,
1866 break;
1868 draw_window_content(room_graphics_content_,
1870 break;
1872 draw_window_content(palette_editor_content_,
1874 break;
1876 ImGui::TextDisabled("%s", GetWorkbenchToolUnavailableMessage(tool));
1877 break;
1878 }
1879 ImGui::PopID();
1880}
1881
1883 constexpr WorkbenchTool tools[] = {
1888 const bool pressed = ImGui::Button("Room tools");
1889 {
1890 gui::AutoWidgetScope scope("Dungeon/Workbench");
1891 gui::AutoRegisterLastItem("button", "tool_picker",
1892 "Specialized room tools");
1893 }
1894 if (pressed)
1895 ImGui::OpenPopup("##WorkbenchToolPickerPopup");
1896 if (ImGui::BeginPopup("##WorkbenchToolPickerPopup")) {
1897 for (const auto tool : tools) {
1898 const bool available = IsWorkbenchToolAvailable(tool);
1899 const bool selected = ImGui::MenuItem(
1900 GetWorkbenchToolShortLabel(tool), nullptr,
1902 available);
1903 {
1904 gui::AutoWidgetScope scope("Dungeon/Workbench");
1906 "menu_item", absl::StrFormat("tool_%s", GetWorkbenchToolId(tool)));
1907 }
1908 if (selected)
1909 OpenTool(tool);
1910 }
1911 ImGui::EndPopup();
1912 }
1913}
1914
1916 DungeonCanvasViewer& viewer) {
1917 const bool back = ImGui::Button("Back to Inspect");
1918 {
1919 gui::AutoWidgetScope scope("Dungeon/Workbench");
1920 gui::AutoRegisterLastItem("button", "back_to_inspect");
1921 }
1922 if (back) {
1924 return;
1925 }
1926 ImGui::SameLine();
1927 const bool can_pop_out =
1929 ImGui::BeginDisabled(!can_pop_out);
1930 const bool pop_out = ImGui::Button("Pop out");
1931 {
1932 gui::AutoWidgetScope scope("Dungeon/Workbench");
1933 gui::AutoRegisterLastItem("button", "pop_out_tool");
1934 }
1935 ImGui::EndDisabled();
1936 if (pop_out && can_pop_out) {
1937 (void)PopOutActiveTool();
1938 return;
1939 }
1941 ImGui::TextDisabled("%s is open in a separate window.",
1943 return;
1944 }
1946 ImGui::TextDisabled("%s", GetWorkbenchToolUnavailableMessage(active_tool_));
1947 return;
1948 }
1949 ImGui::SeparatorText(GetWorkbenchToolShortLabel(active_tool_));
1950 const bool body_open = ImGui::BeginChild("##WorkbenchToolInspectorBody",
1951 ImVec2(0.0f, 0.0f), false);
1952 if (body_open)
1954 ImGui::EndChild();
1955}
1956
1958 bool /*compact*/) {
1960 DrawInspectorToolPanel(viewer);
1961 return;
1962 }
1963
1964 auto& interaction = viewer.object_interaction();
1965 const int room_id = viewer.current_room_id();
1966 ImGui::PushID("InspectedSelection");
1967 const bool placing_object = DrawObjectPlacementInspector(viewer);
1968 if (!placing_object && interaction.mode_manager().IsPlacementActive()) {
1969 ImGui::TextUnformatted(interaction.mode_manager().GetModeName());
1970 if (ImGui::Button("Done placing (Esc)")) {
1971 interaction.CancelPlacement();
1972 }
1973 {
1974 gui::AutoWidgetScope scope("Dungeon/Workbench");
1975 gui::AutoRegisterLastItem("button", "finish_placement");
1976 }
1977 } else if (!placing_object && (interaction.GetSelectionCount() > 0 ||
1978 interaction.HasEntitySelection())) {
1980 }
1981 ImGui::PopID();
1982 // A selected-element action may navigate or rebind the room.
1983 if (viewer.current_room_id() != room_id)
1984 return;
1985 ImGui::PushID("InspectedRoom");
1986 DrawInspectorShelfRoom(viewer);
1987 ImGui::PopID();
1988}
1989
1991 DungeonCanvasViewer& viewer) {
1992 auto& interaction = viewer.object_interaction();
1993 const auto* active_preview = interaction.GetPlacementPreview();
1994 if (!active_preview) {
1995 return false;
1996 }
1997 // Setters may invalidate preview caches; draw from a stable value this frame.
1998 const auto object = *active_preview;
1999 const auto description = workbench::DescribeObjectSize(object);
2000 const auto& theme = AgentUI::GetTheme();
2001 ImGui::PushID("PlacementInspector");
2003 {
2004 gui::StyleColorGuard name_color(ImGuiCol_Text, theme.text_primary);
2005 ImGui::TextWrapped("%s", zelda3::GetObjectName(object.id_).c_str());
2006 }
2007 ImGui::TextDisabled("0x%03X", object.id_);
2008
2009 constexpr ImGuiTableFlags kFlags = ImGuiTableFlags_BordersInnerV |
2010 ImGuiTableFlags_RowBg |
2011 ImGuiTableFlags_NoPadOuterX;
2012 if (ImGui::BeginTable("Properties", 2, kFlags)) {
2013 ImGui::TableSetupColumn("Property", ImGuiTableColumnFlags_WidthFixed,
2014 64.0f);
2015 ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch);
2016 uint8_t requested_size = object.size_;
2017 if (workbench::DrawObjectSizeControls(object, &requested_size)) {
2018 interaction.SetPlacementPreviewSize(requested_size);
2019 }
2020 const bool uses_stream = zelda3::UsesRoomObjectStream(object);
2021 gui::LayoutHelpers::PropertyRow(uses_stream ? "Stream" : "Layer", [&]() {
2022 int layer = object.GetLayerValue();
2023 const char* stream_names[] = {"Primary", "BG2 overlay", "BG1 overlay"};
2024 const char* layer_names[] = {"Upper layer (BG1)", "Lower layer (BG2)"};
2025 ImGui::SetNextItemWidth(-1);
2026 if (ImGui::Combo("##Layer", &layer,
2027 uses_stream ? stream_names : layer_names,
2028 uses_stream ? 3 : 2)) {
2029 interaction.SetPlacementPreviewLayer(layer);
2030 }
2031 gui::AutoWidgetScope scope("Dungeon/Workbench");
2032 gui::AutoRegisterLastItem("combo", "placement_layer",
2033 "Layer for the next placed object");
2034 });
2035 ImGui::EndTable();
2036 }
2037
2038 bool repeat = interaction.GetPlacementPolicy() ==
2039 DungeonObjectInteraction::PlacementPolicy::kRepeat;
2040 if (ImGui::Checkbox(tr("Keep placing copies"), &repeat)) {
2041 interaction.SetPlacementPolicy(
2042 repeat ? DungeonObjectInteraction::PlacementPolicy::kRepeat
2043 : DungeonObjectInteraction::PlacementPolicy::kOnce);
2044 }
2045 {
2046 gui::AutoWidgetScope scope("Dungeon/Workbench");
2047 gui::AutoRegisterLastItem("checkbox", "repeat_placement",
2048 "Keep placing copies after each click");
2049 }
2050 ImGui::TextWrapped("%s", repeat
2051 ? tr("Click the canvas to place each copy.")
2052 : tr("Click the canvas to place, then edit."));
2053 if (!description.wheel_hint.empty()) {
2054 ImGui::TextWrapped("%s", description.wheel_hint.c_str());
2055 }
2056 if (workbench::DrawActionButton(ICON_MD_CHECK " Done placing (Esc)",
2057 ImVec2(-1, 0))) {
2058 interaction.CancelPlacement();
2059 if (interaction.GetSelectionCount() > 0) {
2061 }
2062 }
2063 {
2064 gui::AutoWidgetScope scope("Dungeon/Workbench");
2066 "button", "finish_placement",
2067 "Finish placement and inspect the selected object");
2068 }
2069 ImGui::Separator();
2070 ImGui::PopID();
2071 return true;
2072}
2073
2075 DungeonCanvasViewer& viewer, size_t index) {
2076 auto& interaction = viewer.object_interaction();
2077 auto& handler = interaction.entity_coordinator().tile_handler();
2078 const int room_id = viewer.current_room_id();
2079 const std::vector<size_t> indices = {index};
2080 ImGui::Spacing();
2082 ImVec2(-1, 0))) {
2083 if (interaction.BeginPlacementFromSelection()) {
2085 }
2086 }
2087 {
2088 gui::AutoWidgetScope scope("Dungeon/Workbench");
2089 gui::AutoRegisterLastItem("button", "place_another",
2090 "Place another object with this size and layer");
2091 }
2092 constexpr ImGuiTableFlags kFlags =
2093 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX;
2094 if (ImGui::BeginTable("##SelectedObjectActions", 2, kFlags)) {
2095 auto action = [](const char* label, const char* id) {
2096 ImGui::TableNextColumn();
2097 const bool clicked = workbench::DrawActionButton(label, ImVec2(-1, 0));
2098 gui::AutoWidgetScope scope("Dungeon/Workbench");
2099 gui::AutoRegisterLastItem("button", id, label);
2100 return clicked;
2101 };
2102 if (action(ICON_MD_CONTENT_COPY " Duplicate", "duplicate_object")) {
2103 (void)interaction.HandleDuplicateSelected();
2104 }
2105 if (action(ICON_MD_DELETE " Delete", "delete_object")) {
2106 handler.DeleteObjects(room_id, indices);
2107 }
2108 if (action(ICON_MD_FLIP_TO_FRONT " To front", "object_to_front")) {
2109 handler.SendToFront(room_id, indices);
2110 }
2111 if (action(ICON_MD_FLIP_TO_BACK " To back", "object_to_back")) {
2112 handler.SendToBack(room_id, indices);
2113 }
2114 ImGui::EndTable();
2115 }
2116}
2117
2119 DungeonCanvasViewer& viewer) {
2120 auto& interaction = viewer.object_interaction();
2121 const auto& theme = AgentUI::GetTheme();
2122
2123 const int room_id = viewer.current_room_id();
2124 const size_t obj_count = interaction.GetSelectionCount();
2125 const bool has_entity = interaction.HasEntitySelection();
2126
2127 if (!has_entity && obj_count == 0) {
2129 ImGui::TextDisabled(tr("Click an object or entity to inspect"));
2130 return;
2131 }
2132
2133 // ── Tile Object Selection ──
2134 if (obj_count > 0) {
2136 ImGui::TextColored(theme.text_primary, ICON_MD_WIDGETS " %zu object%s",
2137 obj_count, obj_count == 1 ? "" : "s");
2138 if (obj_count == 1) {
2139 ImGui::SameLine();
2140 ImGui::TextDisabled(tr("Focused selection"));
2141 }
2142
2143 const auto indices = interaction.GetSelectedObjectIndices();
2144
2145 // Multi-object summary + inline bulk editor (O1)
2146 if (indices.size() > 1 && room_id >= 0 && viewer.rooms()) {
2147 auto& room = (*viewer.rooms())[room_id];
2148 auto& objects = room.GetTileObjects();
2150 " Multi-selection");
2151 for (size_t i = 0; i < indices.size() && i < 8; ++i) {
2152 size_t idx = indices[i];
2153 if (idx < objects.size()) {
2154 auto& obj = objects[idx];
2155 std::string name = zelda3::GetObjectName(obj.id_);
2156 ImGui::BulletText(tr("0x%03X %s"), obj.id_, name.c_str());
2157 }
2158 }
2159 if (indices.size() > 8) {
2160 ImGui::TextDisabled(tr(" ... and %zu more"), indices.size() - 8);
2161 }
2162
2163 // Bulk editor: nudge/layer/stacking/size + destructive duplicate/delete.
2164 // Operations route through tile_handler / interaction, so every action
2165 // captures its own undo snapshot.
2166 auto& tile_handler = interaction.entity_coordinator().tile_handler();
2167 const std::vector<size_t> selection_copy(indices.begin(), indices.end());
2168 bool has_room_stream_object = false;
2169 bool has_special_layer_object = false;
2170 for (const size_t index : selection_copy) {
2171 if (index >= objects.size()) {
2172 continue;
2173 }
2174 if (zelda3::UsesRoomObjectStream(objects[index])) {
2175 has_room_stream_object = true;
2176 } else {
2177 has_special_layer_object = true;
2178 }
2179 }
2180 const bool has_editable_size =
2181 workbench::HasEditableRoomObjectSize(objects, selection_copy);
2182
2184
2185 // Nudge grid (arrow buttons around a tile-delta drag).
2186 static int bulk_nudge_dx = 0;
2187 static int bulk_nudge_dy = 0;
2188 ImGui::TextDisabled(
2189 tr("Nudge selection (%d px steps)"),
2190 interaction.entity_coordinator().SelectionMoveStepPixels());
2191 ImGui::PushButtonRepeat(true);
2192 if (ImGui::Button(ICON_MD_ARROW_UPWARD "##BulkNudgeUp"))
2193 interaction.NudgeSelected(0, -1);
2194 ImGui::SameLine();
2195 if (ImGui::Button(ICON_MD_ARROW_DOWNWARD "##BulkNudgeDown"))
2196 interaction.NudgeSelected(0, 1);
2197 ImGui::SameLine();
2198 if (ImGui::Button(ICON_MD_ARROW_BACK "##BulkNudgeLeft"))
2199 interaction.NudgeSelected(-1, 0);
2200 ImGui::SameLine();
2201 if (ImGui::Button(ICON_MD_ARROW_FORWARD "##BulkNudgeRight"))
2202 interaction.NudgeSelected(1, 0);
2203 ImGui::PopButtonRepeat();
2204
2205 ImGui::SetNextItemWidth(60);
2206 ImGui::DragInt("##BulkNudgeDx", &bulk_nudge_dx, 0.25f, -63, 63, "Δx:%d");
2207 ImGui::SameLine();
2208 ImGui::SetNextItemWidth(60);
2209 ImGui::DragInt("##BulkNudgeDy", &bulk_nudge_dy, 0.25f, -63, 63, "Δy:%d");
2210 ImGui::SameLine();
2211 if (ImGui::Button(tr("Apply##BulkNudgeApply")) &&
2212 (bulk_nudge_dx != 0 || bulk_nudge_dy != 0)) {
2213 interaction.NudgeSelected(bulk_nudge_dx, bulk_nudge_dy);
2214 bulk_nudge_dx = 0;
2215 bulk_nudge_dy = 0;
2216 }
2217
2218 ImGui::Spacing();
2219 if (has_room_stream_object && !has_special_layer_object) {
2220 ImGui::TextDisabled(tr("Object stream (set all)"));
2221 if (ImGui::SmallButton(tr("Primary##BulkPlacement0")))
2222 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 0);
2223 ImGui::SameLine();
2224 if (ImGui::SmallButton(tr("BG2 overlay##BulkPlacement1")))
2225 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 1);
2226 ImGui::SameLine();
2227 if (ImGui::SmallButton(tr("BG1 overlay##BulkPlacement2")))
2228 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 2);
2229 } else if (has_special_layer_object && !has_room_stream_object) {
2230 ImGui::TextDisabled(tr("Special layer (set all)"));
2231 if (ImGui::SmallButton(tr("Upper (BG1)##BulkPlacement0")))
2232 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 0);
2233 ImGui::SameLine();
2234 if (ImGui::SmallButton(tr("Lower (BG2)##BulkPlacement1")))
2235 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 1);
2236 } else {
2237 ImGui::TextDisabled(tr("Stored placement (mixed selection)"));
2238 if (ImGui::SmallButton(tr("Primary / Upper (BG1)##BulkPlacement0")))
2239 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 0);
2240 ImGui::SameLine();
2241 if (ImGui::SmallButton(tr("BG2 overlay / Lower (BG2)##BulkPlacement1")))
2242 tile_handler.UpdateObjectsLayer(room_id, selection_copy, 1);
2243 }
2244
2245 ImGui::Spacing();
2246 ImGui::TextDisabled(tr("Stacking"));
2247 if (ImGui::SmallButton(ICON_MD_FLIP_TO_FRONT " Front##BulkFront"))
2248 tile_handler.SendToFront(room_id, selection_copy);
2249 ImGui::SameLine();
2250 if (ImGui::SmallButton(ICON_MD_FLIP_TO_BACK " Back##BulkBack"))
2251 tile_handler.SendToBack(room_id, selection_copy);
2252 ImGui::SameLine();
2253 if (ImGui::SmallButton(ICON_MD_KEYBOARD_ARROW_UP "##BulkFwd"))
2254 tile_handler.MoveForward(room_id, selection_copy);
2255 ImGui::SameLine();
2256 if (ImGui::SmallButton(ICON_MD_KEYBOARD_ARROW_DOWN "##BulkBwd"))
2257 tile_handler.MoveBackward(room_id, selection_copy);
2258
2259 ImGui::Spacing();
2260 ImGui::TextDisabled(tr("Size"));
2261 if (!has_editable_size) {
2262 ImGui::BeginDisabled();
2263 }
2264 if (ImGui::SmallButton(ICON_MD_REMOVE "##BulkSizeDec"))
2265 tile_handler.ResizeObjects(room_id, selection_copy, -1);
2266 ImGui::SameLine();
2267 if (ImGui::SmallButton(ICON_MD_ADD "##BulkSizeInc"))
2268 tile_handler.ResizeObjects(room_id, selection_copy, 1);
2269 if (!has_editable_size) {
2270 ImGui::EndDisabled();
2271 }
2272
2273 ImGui::Spacing();
2274 if (ImGui::SmallButton(ICON_MD_CONTENT_COPY " Duplicate##BulkDup"))
2275 (void)interaction.HandleDuplicateSelected();
2276 ImGui::SameLine();
2277 {
2278 gui::StyleColorGuard danger_colors({
2279 {ImGuiCol_Button, theme.status_error},
2280 {ImGuiCol_ButtonHovered,
2281 ImVec4(theme.status_error.x + 0.1f, theme.status_error.y + 0.05f,
2282 theme.status_error.z + 0.05f, 1.0f)},
2283 });
2284 if (ImGui::SmallButton(ICON_MD_DELETE " Delete##BulkDel"))
2285 ImGui::OpenPopup("##BulkDeleteConfirm");
2286 }
2287
2288 if (ImGui::BeginPopup("##BulkDeleteConfirm")) {
2289 ImGui::TextColored(
2290 theme.status_error, ICON_MD_WARNING " Delete %zu object%s?",
2291 selection_copy.size(), selection_copy.size() == 1 ? "" : "s");
2292 ImGui::Separator();
2293 if (ImGui::Button(tr("Cancel##BulkDelCancel"))) {
2294 ImGui::CloseCurrentPopup();
2295 }
2296 ImGui::SameLine();
2297 {
2298 gui::StyleColorGuard confirm_colors({
2299 {ImGuiCol_Button, theme.status_error},
2300 });
2301 if (ImGui::Button(ICON_MD_DELETE " Confirm##BulkDelConfirm")) {
2302 tile_handler.DeleteObjects(room_id, selection_copy);
2303 ImGui::CloseCurrentPopup();
2304 }
2305 }
2306 ImGui::EndPopup();
2307 }
2308 }
2309
2310 // Single-object detailed inspector
2311 if (indices.size() == 1 && room_id >= 0 && viewer.rooms()) {
2312 auto& room = (*viewer.rooms())[room_id];
2313 auto& objects = room.GetTileObjects();
2314 const size_t idx = indices.front();
2315 if (idx < objects.size()) {
2316 // Stream changes can reorder and replace the room's object vector.
2317 // Keep this frame's property values independent of that storage.
2318 const auto obj = objects[idx];
2319 const std::string obj_name = zelda3::GetObjectName(obj.id_);
2320 const int subtype = zelda3::GetObjectSubtype(obj.id_);
2321 const bool uses_room_stream = zelda3::UsesRoomObjectStream(obj);
2322 int displayed_layer = obj.GetLayerValue();
2323 const int pixel_x = obj.x_ * 8;
2324 const int pixel_y = obj.y_ * 8;
2325
2326 // Name + category header
2328 " Focused Object");
2329 ImGui::TextColored(theme.text_primary, "%s", obj_name.c_str());
2330 ImGui::TextDisabled(tr("%s (Type %d) #%zu in list"),
2331 GetObjectCategory(obj.id_), subtype, idx);
2332
2333 // Property table
2334 constexpr ImGuiTableFlags kPropsFlags = ImGuiTableFlags_BordersInnerV |
2335 ImGuiTableFlags_RowBg |
2336 ImGuiTableFlags_NoPadOuterX;
2337 if (ImGui::BeginTable("##SelObjProps", 2, kPropsFlags)) {
2338 ImGui::TableSetupColumn("Prop", ImGuiTableColumnFlags_WidthFixed,
2339 56.0f);
2340 ImGui::TableSetupColumn("Val", ImGuiTableColumnFlags_WidthStretch);
2341
2342 // ID
2344 uint16_t obj_id = static_cast<uint16_t>(obj.id_ & 0x0FFF);
2345 if (auto res =
2346 gui::InputHexWordEx("##SelObjId", &obj_id, 80.0f, true);
2347 res.ShouldApply()) {
2348 obj_id &= 0x0FFF;
2349 interaction.SetObjectId(idx, static_cast<int16_t>(obj_id));
2350 }
2351 });
2352
2353 // Position
2354 gui::LayoutHelpers::PropertyRow("Pos", [&]() {
2355 int pos_x = obj.x_;
2356 int pos_y = obj.y_;
2357 ImGui::SetNextItemWidth(60);
2358 bool x_changed =
2359 ImGui::DragInt("##SelObjX", &pos_x, 0.1f, 0, 63, "X:%d");
2360 {
2361 gui::AutoWidgetScope automation_scope("Dungeon/Workbench");
2362 gui::AutoRegisterLastItem("drag_int", "selected_object_x",
2363 "Selected room object X coordinate");
2364 }
2365 ImGui::SameLine();
2366 ImGui::SetNextItemWidth(60);
2367 bool y_changed =
2368 ImGui::DragInt("##SelObjY", &pos_y, 0.1f, 0, 63, "Y:%d");
2369 {
2370 gui::AutoWidgetScope automation_scope("Dungeon/Workbench");
2371 gui::AutoRegisterLastItem("drag_int", "selected_object_y",
2372 "Selected room object Y coordinate");
2373 }
2374 if (x_changed || y_changed) {
2375 int delta_x = pos_x - obj.x_;
2376 int delta_y = pos_y - obj.y_;
2377 interaction.entity_coordinator().tile_handler().MoveObjects(
2378 room_id, {idx}, delta_x, delta_y);
2379 }
2380 });
2381
2382 uint8_t requested_size = obj.size_;
2383 if (workbench::DrawObjectSizeControls(obj, &requested_size)) {
2384 interaction.SetObjectSize(idx, requested_size);
2385 }
2386
2388 uses_room_stream ? "Stream" : "Layer", [&]() {
2389 int layer = displayed_layer;
2390 const char* stream_names[] = {"Primary", "BG2 overlay",
2391 "BG1 overlay"};
2392 const char* special_layer_names[] = {"Upper layer (BG1)",
2393 "Lower layer (BG2)"};
2394 ImGui::SetNextItemWidth(-1);
2395 if (ImGui::Combo(
2396 "##SelObjLayer", &layer,
2397 uses_room_stream ? stream_names : special_layer_names,
2398 uses_room_stream ? IM_ARRAYSIZE(stream_names)
2399 : IM_ARRAYSIZE(special_layer_names))) {
2400 const int max_layer = uses_room_stream ? 2 : 1;
2401 layer = std::clamp(layer, 0, max_layer);
2402 if (interaction.SetObjectLayer(
2403 idx,
2404 static_cast<zelda3::RoomObject::LayerType>(layer))) {
2405 displayed_layer = layer;
2406 }
2407 }
2408 });
2409 gui::LayoutHelpers::PropertyRow("Route", [&]() {
2410 ImGui::TextDisabled(
2411 "%s", uses_room_stream ? GetObjectStreamName(displayed_layer)
2412 : GetSpecialLayerName(displayed_layer));
2413 });
2414
2415 // Pixel coords (read-only info)
2416 gui::LayoutHelpers::PropertyRow("Pixel", [&]() {
2417 ImGui::TextDisabled("(%d, %d)", pixel_x, pixel_y);
2418 });
2419
2420 ImGui::EndTable();
2421 }
2422 const auto description = workbench::DescribeObjectSize(obj);
2423 if (!description.wheel_hint.empty()) {
2424 ImGui::TextWrapped("%s", description.wheel_hint.c_str());
2425 }
2426 // Actions can reallocate the room's object vector; no object references
2427 // are used after this point.
2428 const auto current_indices = interaction.GetSelectedObjectIndices();
2429 if (current_indices.size() == 1) {
2430 DrawSelectedObjectActions(viewer, current_indices.front());
2431 }
2432 }
2433 }
2434 }
2435
2436 if (has_entity) {
2438 " Entity Selection");
2440 }
2441}
2442
2443} // namespace yaze::editor
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:49
const auto & vector() const
Definition rom.h:205
bool is_loaded() const
Definition rom.h:187
static Flags & get()
Definition features.h:119
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetInteraction(DungeonObjectInteraction *interaction)
void Draw(bool *p_open) override
Draw the panel content.
std::optional< int > DrawConnectedRoomMatrix(int center_room_id)
DungeonObjectInteraction & object_interaction()
const project::YazeProject * project() const
void DrawProposalPreview(const DungeonProposalOverlay &overlay, const std::vector< char > &layer_visible, bool show_overlay, float scale)
WindowContent for displaying multiple rooms in a spatial dungeon layout.
const zelda3::RoomObject * GetPlacementPreview() const
InteractionCoordinator & entity_coordinator()
Get the interaction coordinator for entity handling.
Handles room and entrance selection UI.
static void Draw(const DungeonStatusBarState &state)
static DungeonStatusBarState BuildState(const DungeonCanvasViewer &viewer, const char *tool_mode, bool room_dirty)
void DrawInspector(DungeonCanvasViewer &viewer, bool compact)
void DrawInspectorShelfSelection(DungeonCanvasViewer &viewer)
bool IsStandaloneToolOpen(WorkbenchTool tool) const
DungeonWorkbenchContent(DungeonRoomSelector *room_selector, int *current_room_id, std::function< void(int)> on_room_selected, std::function< void(int, RoomSelectionIntent)> on_room_selected_with_intent, std::function< void(int)> on_save_room, std::function< void()> on_save_all_rooms, std::function< DungeonCanvasViewer *()> get_viewer, std::function< DungeonCanvasViewer *(int)> get_compare_viewer, std::function< const std::deque< int > &()> get_recent_rooms, std::function< void(int)> forget_recent_room, std::function< void(bool)> set_workflow_mode, Rom *rom=nullptr)
void DrawProposalPreviewWindow(DungeonCanvasViewer &viewer)
std::function< bool(const std::string &) is_standalone_tool_open_)
int GetPriority() const override
Get display priority for menu ordering.
DungeonWorkbenchPitDamageControlRects pit_damage_control_rects_
void DrawSelectedObjectActions(DungeonCanvasViewer &viewer, size_t index)
bool IsWorkbenchToolAvailable(WorkbenchTool tool) const
void DrawInspectorHeader(float button_size, bool compact)
void DrawSidebarPane(float width, float height, float button_size, bool compact)
DungeonMapPanel * GetEmbeddedDungeonMap(DungeonCanvasViewer &viewer)
void DrawInspectorPane(float width, float height, float button_size, bool compact, DungeonCanvasViewer *viewer)
void DrawInspectorShelfRoom(DungeonCanvasViewer &viewer)
WindowContent * GetWorkbenchToolContent(WorkbenchTool tool) const
std::string GetEditorCategory() const override
Editor category this panel belongs to.
std::optional< DungeonProposalOverlay > proposal_overlay_
void DrawWorkbenchTool(DungeonCanvasViewer &viewer, WorkbenchTool tool)
void DrawSidebarHeader(float button_size, bool compact)
const char * GetWorkbenchToolShortLabel(WorkbenchTool tool) const
void DrawDungeonMapPopup(DungeonCanvasViewer &viewer)
std::unique_ptr< DungeonMapPanel > embedded_dungeon_map_
void DrawInspectorToolPanel(DungeonCanvasViewer &viewer)
std::function< void(int, RoomSelectionIntent)> on_room_selected_with_intent_
void SetEmbeddedEditorPanels(WindowContent *object_selector, WindowContent *door_editor, WindowContent *sprite_editor, WindowContent *item_editor, WindowContent *room_graphics, WindowContent *palette_editor)
void SetEmbeddedToolPanels(RoomTagEditorPanel *room_tags, CustomCollisionPanel *custom_collision, WaterFillPanel *water_fill, MinecartTrackEditorPanel *minecart_tracks)
void DrawCanvasPane(float width, float height, DungeonCanvasViewer *primary_viewer)
std::function< void(DungeonCanvasViewer &) on_primary_canvas_drawn_)
const char * GetWorkbenchToolId(WorkbenchTool tool) const
const char * GetWorkbenchToolUnavailableMessage(WorkbenchTool tool) const
bool DrawObjectPlacementInspector(DungeonCanvasViewer &viewer)
std::string GetId() const override
Unique identifier for this panel.
bool LoadProposalOverlay(const std::string &path)
void DrawSplitView(DungeonCanvasViewer &primary_viewer)
std::function< zelda3::PitDamageTable *()> get_pit_damage_table_
std::string GetIcon() const override
Material Design icon for this panel.
std::function< DungeonCanvasViewer *(int)> get_compare_viewer_
std::unordered_map< int, std::string > room_dungeon_cache_
std::string GetDisplayName() const override
Human-readable name shown in menus and title bars.
std::function< void(bool)> on_inspector_side_changed_
void Draw(bool *p_open) override
Draw the panel content.
std::function< bool(const std::string &) open_and_focus_standalone_tool_)
std::function< const std::deque< int > &()> get_recent_rooms_
void DrawInspectorShelf(DungeonCanvasViewer &viewer, bool compact)
std::function< DungeonCanvasViewer *()> get_viewer_
static bool Draw(const DungeonWorkbenchToolbarParams &params)
const char * GetModeName() const
Get mode name for debugging/UI.
WindowContent for placing and managing dungeon pot items.
void Draw(bool *p_open) override
Draw the panel content.
Browse and place dungeon objects.
void Draw(bool *p_open) override
Draw the panel content.
WindowContent showing all room tag slots and their usage across rooms.
void Draw(bool *p_open) override
Draw the panel content.
WindowContent for placing and managing dungeon sprites.
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetInteraction(DungeonObjectInteraction *interaction)
void Draw(bool *p_open) override
Draw the panel content.
Base interface for all logical window content components.
virtual void Draw(bool *p_open)=0
Draw the panel content.
virtual std::string GetId() const =0
Unique identifier for this panel.
RAII scope that enables automatic widget registration.
void ApplyScaleSnapshot(const CanvasConfig &snapshot)
Definition canvas.cc:1062
CanvasConfig & GetConfig()
Definition canvas.h:243
static float GetTouchSafeWidgetHeight()
static bool BeginContentChild(const char *id, const ImVec2 &min_size, bool border=false, ImGuiWindowFlags flags=0)
static void EndContentChild()
static void PropertyRow(const char *label, std::function< void()> widget_callback)
RAII guard for ImGui style colors.
Definition style_guard.h:27
RAII guard for ImGui style vars.
Definition style_guard.h:68
static std::string ShowOpenFileDialog()
ShowOpenFileDialog opens a file dialog and returns the selected filepath. Uses global feature flag to...
Dungeon Room Entrance or Spawn Point.
const absl::Status & load_status() const
#define ICON_MD_FOLDER_OPEN
Definition icons.h:813
#define ICON_MD_SAVE_ALT
Definition icons.h:1645
#define ICON_MD_SUMMARIZE
Definition icons.h:1885
#define ICON_MD_VIEW_QUILT
Definition icons.h:2094
#define ICON_MD_INFO
Definition icons.h:993
#define ICON_MD_WARNING
Definition icons.h:2123
#define ICON_MD_ARROW_FORWARD
Definition icons.h:184
#define ICON_MD_CHECK
Definition icons.h:397
#define ICON_MD_SWAP_HORIZ
Definition icons.h:1896
#define ICON_MD_COMPARE_ARROWS
Definition icons.h:448
#define ICON_MD_TUNE
Definition icons.h:2022
#define ICON_MD_OPEN_WITH
Definition icons.h:1356
#define ICON_MD_REFRESH
Definition icons.h:1572
#define ICON_MD_MAP
Definition icons.h:1173
#define ICON_MD_FLIP_TO_FRONT
Definition icons.h:802
#define ICON_MD_ARROW_DOWNWARD
Definition icons.h:180
#define ICON_MD_WIDGETS
Definition icons.h:2156
#define ICON_MD_EDIT
Definition icons.h:645
#define ICON_MD_MORE_HORIZ
Definition icons.h:1241
#define ICON_MD_KEYBOARD_ARROW_DOWN
Definition icons.h:1030
#define ICON_MD_ADD
Definition icons.h:86
#define ICON_MD_KEYBOARD
Definition icons.h:1028
#define ICON_MD_REMOVE
Definition icons.h:1574
#define ICON_MD_CHEVRON_LEFT
Definition icons.h:405
#define ICON_MD_ARROW_UPWARD
Definition icons.h:189
#define ICON_MD_CHECK_CIRCLE
Definition icons.h:400
#define ICON_MD_PREVIEW
Definition icons.h:1512
#define ICON_MD_BUILD
Definition icons.h:328
#define ICON_MD_KEYBOARD_ARROW_UP
Definition icons.h:1033
#define ICON_MD_ARROW_BACK
Definition icons.h:173
#define ICON_MD_SAVE
Definition icons.h:1644
#define ICON_MD_SELECT_ALL
Definition icons.h:1680
#define ICON_MD_DELETE
Definition icons.h:530
#define ICON_MD_CONTENT_COPY
Definition icons.h:465
#define ICON_MD_CLOSE
Definition icons.h:418
#define ICON_MD_CATEGORY
Definition icons.h:382
#define ICON_MD_CHEVRON_RIGHT
Definition icons.h:406
#define ICON_MD_FLIP_TO_BACK
Definition icons.h:801
#define ICON_MD_WORKSPACES
Definition icons.h:2186
#define ICON_MD_ADD_CIRCLE
Definition icons.h:95
#define ICON_MD_CROP_FREE
Definition icons.h:495
const AgentUITheme & GetTheme()
float GetCompactSidebarWidth(bool right_sidebar, float min_sidebar_width)
float ClampWorkbenchPaneWidth(float desired_width, float min_width, float max_width)
std::string GetRoomLabel(const project::YazeProject *project, int room_id)
void DrawPaneHeader(const char *table_id, const char *icon, const char *title, const char *compact_title, const char *subtitle, bool compact, float action_width, const std::function< void()> &draw_actions)
float CalcIconButtonWidth(const char *icon, float button_height)
ObjectSizeDescription DescribeObjectSize(const zelda3::RoomObject &object)
bool DrawObjectSizeControls(const zelda3::RoomObject &object, uint8_t *requested_size)
bool DrawActionButton(const char *label, const ImVec2 &size)
bool HasEditableRoomObjectSize(std::span< const zelda3::RoomObject > objects, std::span< const size_t > selected_indices)
bool DrawHeaderIconAction(const char *id, const char *icon, float button_size, const char *tooltip, bool active=false)
constexpr const char * kConnected
Editors are the view controllers for the application.
void DrawDungeonEntityInspector(DungeonCanvasViewer &viewer, const std::function< void(int, size_t)> &jump_to_reciprocal)
absl::StatusOr< DungeonProposalOverlay > ParseDungeonProposalOverlay(std::string_view json_text, int max_room_id)
DungeonWorkbenchPaneLayout ResolveDungeonWorkbenchPaneLayout(float total_width, float min_canvas_width, float min_sidebar_width, float splitter_width, float stored_left_width, float stored_right_width, bool want_left, bool want_right)
PitDamageMembershipState BuildPitDamageMembershipState(const zelda3::PitDamageTable *table, int room_id, uint16_t replacement_fallback, uint16_t victim_fallback)
absl::Status RemoveCurrentRoomFromPitDamage(zelda3::PitDamageTable *table, uint16_t current_room_id, uint16_t replacement_room_id)
RoomSelectionIntent
Intent for room selection in the dungeon editor.
DungeonWorkbenchResponsiveLayout ResolveDungeonWorkbenchResponsiveLayout(float total_width, float min_canvas_width, float min_sidebar_width, float splitter_width, bool want_left, bool want_right)
bool DrawDungeonWorkbenchVerticalSplitter(const char *id, float height, float *pane_width, float min_width, float max_width, bool resize_from_left_edge, float collapse_threshold)
DungeonWorkbenchToolRequestTarget ResolveDungeonWorkbenchToolRequestTarget(bool standalone_window_open)
absl::Status AddCurrentRoomToPitDamage(zelda3::PitDamageTable *table, uint16_t current_room_id, uint16_t victim_room_id)
std::string FormatCtrlShiftShortcut(ImGuiKey key)
Convenience function for Ctrl+Shift+key shortcuts.
void AutoRegisterLastItem(const std::string &widget_type, const std::string &explicit_label, const std::string &description)
Automatically register the last ImGui item.
InputHexResult InputHexWordEx(const char *label, uint16_t *data, float input_width, bool no_step)
Definition input.cc:561
int GetObjectSubtype(int object_id)
constexpr int kNumRegularDungeonEntrances
bool UsesRoomObjectStream(const RoomObject &object)
constexpr int kNumDungeonSpawnPoints
std::string GetObjectName(int object_id)
constexpr int kNumberOfRooms
absl::StatusOr< EntranceLayout > ResolveEntranceLayout(std::span< const uint8_t > data)
struct yaze::core::FeatureFlags::Flags::Dungeon dungeon
std::function< const std::deque< int > &()> get_recent_rooms
bool ShouldApply() const
Definition input.h:82
static constexpr float kStatusBarHeight
Definition ui_config.h:21
static constexpr float kSplitterWidth
Definition ui_config.h:76
static constexpr float kContentMinWidthSidebar
Definition ui_config.h:58
Modern project structure with comprehensive settings consolidation.
Definition project.h:287
std::vector< FileDialogFilter > filters
Definition file_util.h:17
Automatic widget registration helpers for ImGui Test Engine integration.