yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
pixel_editor_panel.cc
Go to the documentation of this file.
1#define IMGUI_DEFINE_MATH_OPERATORS
3#include "util/i18n/tr.h"
4
5#include <algorithm>
6#include <cmath>
7#include <memory>
8#include <queue>
9
10#include "absl/strings/str_format.h"
16#include "app/gui/core/color.h"
17#include "app/gui/core/icons.h"
18#include "app/gui/core/style.h"
24#include "imgui/imgui.h"
25#include "imgui/imgui_internal.h"
27
28namespace yaze {
29namespace editor {
30namespace {
31
32// Material icons are merged with a text-baseline offset. Center their visible
33// glyph bounds, not the text line box, while retaining ordinary button input,
34// navigation, IDs, and disabled styling. This is for one-glyph controls only.
35bool PixelIconButton(const char* label, float button_size) {
36 const bool pressed = [&] {
37 gui::StyleColorGuard transparent_text(ImGuiCol_Text, ImVec4(0, 0, 0, 0));
38 return ImGui::Button(label, ImVec2(button_size, button_size));
39 }();
40 if (!ImGui::IsItemVisible())
41 return pressed;
42
43 unsigned int codepoint = 0;
44 ImTextCharFromUtf8(&codepoint, label, nullptr);
45 auto* baked = ImGui::GetFontBaked();
46 const auto* glyph = baked->FindGlyph(static_cast<ImWchar>(codepoint));
47 if (!glyph || !glyph->Visible)
48 return pressed;
49
50 const float font_scale = ImGui::GetFontSize() / baked->Size;
51 const float width = (glyph->X1 - glyph->X0) * font_scale;
52 const float height = (glyph->Y1 - glyph->Y0) * font_scale;
53 if (width <= 0 || height <= 0)
54 return pressed;
55 const ImVec2 min = ImGui::GetItemRectMin();
56 const ImVec2 max = ImGui::GetItemRectMax();
57 // Keep the existing compact frame size; fit oversized glyphs inside it.
58 const float inset = ImGui::GetFontSize() / 8.0f;
59 const float available = std::max(1.0f, button_size - 2 * inset);
60 const float fit = std::min({1.0f, available / width, available / height});
61 const ImVec2 glyph_size(width * fit, height * fit);
62 const ImVec2 glyph_min((min.x + max.x - glyph_size.x) * 0.5f,
63 (min.y + max.y - glyph_size.y) * 0.5f);
64 ImGui::GetWindowDrawList()->AddImage(
65 ImGui::GetFont()->OwnerAtlas->TexRef, glyph_min,
66 ImVec2(glyph_min.x + glyph_size.x, glyph_min.y + glyph_size.y),
67 ImVec2(glyph->U0, glyph->V0), ImVec2(glyph->U1, glyph->V1),
68 ImGui::GetColorU32(ImGuiCol_Text));
69 return pressed;
70}
71
72} // namespace
73
75 // Canvas is initialized via member initializer list
76}
77
78void PixelEditorPanel::Draw(bool* p_open) {
79 // WindowContent interface - delegate to Update()
80 Update().IgnoreError();
81}
82
86
87 // Match every control to the current font/frame size. Mixing toolbar icon
88 // presets and normal buttons made Undo taller than the drawing tools.
89 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,
90 ImVec2(4, ImGui::GetStyle().ItemSpacing.y));
92 const bool visible = sheet_browser_visible_();
93 std::optional<gui::StyleColorGuard> selected;
94 if (visible)
95 selected.emplace(ImGuiCol_Button, gui::GetPrimaryVec4());
96 if (ImGui::Button(tr("Sheets")))
98 gui::AutoRegisterLastItem("button", "Toggle Sheet Browser");
99 HOVER_HINT(visible ? "Hide Sheet Browser to expand the canvas"
100 : "Show Sheet Browser in its previous position");
101 ImGui::SameLine();
102 }
103 DrawToolbar();
104 ImGui::SameLine();
105 const float button_size = ImGui::GetFrameHeight();
106 if (PixelIconButton(ICON_MD_TUNE "##ViewOptions", button_size)) {
107 ImGui::OpenPopup("View options");
108 }
109 gui::AutoRegisterLastItem("button", "View options");
110 HOVER_HINT("Display overlays and sheet overview");
111 if (ImGui::BeginPopup("View options")) {
113 ImGui::EndPopup();
114 }
115 ImGui::SameLine();
116 if (PixelIconButton(ICON_MD_MORE_HORIZ "##PixelEditOptions", button_size)) {
117 ImGui::OpenPopup("Edit options");
118 }
119 gui::AutoRegisterLastItem("button", "Edit options");
120 HOVER_HINT("Copy and paste a PNG");
121 if (ImGui::BeginPopup("Edit options")) {
123 ImGui::EndPopup();
124 }
125 ImGui::PopStyleVar();
128 }
129
131
132 const float status_height =
133 ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y + 1;
134 ImGui::BeginChild("##PixelEditorContent", ImVec2(0, -status_height), false);
135 const ImVec2 available = ImGui::GetContentRegionAvail();
136 const float font = ImGui::GetFontSize();
137 const bool preview = show_source_preview_ && draw_source_preview_ &&
140 state_->source_context->render_preview;
141 const bool beside = preview && available.x >= 48 * font;
142 const float preview_width =
143 std::clamp(available.x * 0.30f, 14 * font, 22 * font);
144 const float preview_height = std::min(available.y * 0.38f, 18 * font);
145 const float gap = ImGui::GetStyle().ItemSpacing.x;
146 const ImVec2 canvas_size(
147 beside ? std::max(1.0f, available.x - preview_width - gap) : 0,
148 preview && !beside ? std::max(1.0f, available.y - preview_height -
149 ImGui::GetStyle().ItemSpacing.y)
150 : 0);
152 ImGui::SetNextWindowScroll(ImVec2(0, 0));
153 }
154 ImGui::BeginChild("##CanvasArea", canvas_size, false,
155 ImGuiWindowFlags_HorizontalScrollbar);
156 DrawCanvas();
157 ImGui::EndChild();
158 if (preview) {
159 if (beside)
160 ImGui::SameLine();
161 ImGui::BeginChild(
162 "##SourceUsagePreview",
163 beside ? ImVec2(preview_width, 0) : ImVec2(0, preview_height), false);
165 ImGui::EndChild();
166 }
167 ImGui::EndChild();
168
170 // Status bar
172
173 return absl::OkStatus();
174}
175
177 if (const auto* target = state_->SourceTarget()) {
178 return target->palette;
179 }
180 const auto& sheet = gfx::Arena::Get().gfx_sheets()[state_->current_sheet_id];
181 const auto* palette = platform::GetSurfacePalette(sheet.surface());
182 if (!palette) {
183 return {};
184 }
185
186 // Bitmap::palette() retains the full source bank. Manual row selection
187 // applies a slice to the surface, inserting transparent index zero. Pixel
188 // tools and PNG transfer must use those displayed three-bit indices.
189 std::vector<gfx::SnesColor> colors;
190 const int count = std::min(8, palette->ncolors);
191 colors.reserve(count);
192 for (int i = 0; i < count; ++i) {
193 const auto& color = palette->colors[i];
194 colors.emplace_back(color.r, color.g, color.b);
195 colors.back().set_transparent(i == 0);
196 }
197 return gfx::SnesPalette(colors);
198}
199
201 auto& sheet =
203 if (state_->SourceTarget() == nullptr || !sheet.is_active()) {
204 return sheet;
205 }
206 bool changed = !context_sheet_.is_active();
207 if (!context_sheet_.is_active()) {
208 context_sheet_.Create(sheet.width(), sheet.height(), 8, sheet.vector());
209 } else if (context_sheet_.vector() != sheet.vector()) {
210 context_sheet_.set_data(sheet.vector());
211 changed = true;
212 }
213 const auto& palette = DisplayPalette();
214 const auto& old = context_sheet_.palette();
215 bool palette_changed = old.size() != palette.size();
216 for (size_t i = 0; !palette_changed && i < palette.size(); ++i) {
217 palette_changed = old[i].snes() != palette[i].snes();
218 }
219 if (palette_changed) {
221 }
222 if (changed || palette_changed || !context_sheet_.texture()) {
227 }
228 return context_sheet_;
229}
230
232 if (!state_->source_context ||
234 return;
235 }
236 auto& context = *state_->source_context;
237 ImGui::BeginDisabled(!context.return_to_source);
238 const bool return_to_source = ImGui::SmallButton(tr("Return to source"));
239 gui::AutoRegisterLastItem("button", "Return to source");
240 ImGui::EndDisabled();
241 if (return_to_source) {
242 // Copy before invoking: navigation may replace editor state.
243 auto navigate = context.return_to_source;
245 navigate();
246 return;
247 }
248 if (draw_source_preview_ && context.render_preview) {
249 ImGui::SameLine();
250 ImGui::Checkbox(tr("Preview"), &show_source_preview_);
251 gui::AutoRegisterLastItem("checkbox", "Show source preview");
252 }
253 ImGui::SameLine();
254 if (ImGui::SmallButton(ICON_MD_INFO_OUTLINE "##SourceDetails")) {
255 ImGui::OpenPopup("Source details");
256 }
257 gui::AutoRegisterLastItem("button", "Source details");
258 HOVER_HINT("Source, drawing pattern and palette details");
259 if (ImGui::BeginPopup("Source details")) {
260 ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 28 * ImGui::GetFontSize());
261 ImGui::TextWrapped("%s", context.label.c_str());
262 if (!context.drawing_description.empty()) {
263 ImGui::TextWrapped(tr("Drawing: %s"),
264 context.drawing_description.c_str());
265 }
266 if (const auto* target = state_->SourceTarget();
267 target && target->source_orientation_mask != 0) {
268 ImGui::TextWrapped(tr("Pattern orientations: %s"),
269 target->SourceOrientationLabel().c_str());
270 ImGui::TextWrapped("%s",
271 tr("Paint the unflipped source; all uses of this "
272 "sheet share its pixels."));
273 }
274 if (!context.limitation.empty()) {
275 ImGui::TextWrapped("%s", context.limitation.c_str());
276 }
277 ImGui::PopTextWrapPos();
278 ImGui::Separator();
279 ImGui::BeginDisabled(state_->selection.is_floating);
280 const bool manual = ImGui::SmallButton(tr("Use manual palette"));
281 gui::AutoRegisterLastItem("button", "Use manual palette");
282 ImGui::EndDisabled();
283 if (manual)
284 ImGui::CloseCurrentPopup();
285 ImGui::EndPopup();
286 if (manual) {
288 return;
289 }
290 }
291 ImGui::SameLine();
292 const ImVec2 label_pos = ImGui::GetCursorScreenPos();
293 const ImVec2 label_size(std::max(1.0f, ImGui::GetContentRegionAvail().x),
294 ImGui::GetFrameHeight());
295 const ImVec2 text_pos(label_pos.x,
296 label_pos.y + ImGui::GetStyle().FramePadding.y);
297 const bool clipped =
298 ImGui::CalcTextSize(context.label.c_str()).x > label_size.x;
299 const float ellipsis_width = clipped ? ImGui::CalcTextSize("...").x : 0;
300 const float text_right =
301 label_pos.x + std::max(0.0f, label_size.x - ellipsis_width);
302 auto* draw = ImGui::GetWindowDrawList();
303 draw->PushClipRect(label_pos, ImVec2(text_right, label_pos.y + label_size.y),
304 true);
305 draw->AddText(text_pos, ImGui::GetColorU32(ImGuiCol_Text),
306 context.label.c_str());
307 draw->PopClipRect();
308 if (clipped && label_size.x >= ellipsis_width) {
309 draw->AddText(ImVec2(text_right, text_pos.y),
310 ImGui::GetColorU32(ImGuiCol_Text), "...");
311 }
312 ImGui::Dummy(label_size);
313 if (ImGui::IsItemHovered())
314 ImGui::SetTooltip("%s", context.label.c_str());
315
316 if (!context.palette_error.empty()) {
317 ImGui::TextWrapped(tr("Source colors could not refresh: %s"),
318 context.palette_error.c_str());
319 }
320 if (context.targets.empty())
321 return;
322 const auto& target = context.targets[context.selected_target];
323 ImGui::BeginDisabled(state_->selection.is_floating);
324 ImGui::SetNextItemWidth(-1);
325 const bool open = ImGui::BeginCombo("##SourceTile", target.label.c_str());
326 gui::AutoRegisterLastItem("combo", "Source tile");
327 if (open) {
328 for (size_t i = 0; i < context.targets.size(); ++i) {
329 ImGui::PushID(static_cast<int>(i));
330 if (ImGui::Selectable(context.targets[i].label.c_str(),
331 i == context.selected_target)) {
333 }
334 gui::AutoRegisterLastItem("selectable",
335 absl::StrFormat("Source tile %zu", i));
336 ImGui::PopID();
337 }
338 ImGui::EndCombo();
339 }
340 ImGui::EndDisabled();
341}
342
344 struct Tool {
345 PixelTool tool;
346 const char* icon;
347 const char* label;
348 };
349 const Tool tools[] = {
350 {PixelTool::kSelect, ICON_MD_SELECT_ALL, "Select (M)"},
351 {PixelTool::kHand, ICON_MD_PAN_TOOL, "Hand (H)"},
352 {PixelTool::kPencil, ICON_MD_DRAW, "Pencil (B)"},
353 {PixelTool::kBrush, ICON_MD_BRUSH, "Brush (P)"},
357 {PixelTool::kRectangle, ICON_MD_CROP_SQUARE, "Rectangle (R)"},
358 {PixelTool::kEyedropper, ICON_MD_COLORIZE, "Eyedropper (I)"},
359 };
360 const float button_size = ImGui::GetFrameHeight();
361 const bool uses_brush = state_->current_tool == PixelTool::kPencil ||
364 // Nine tools, Undo/Redo, View and Edit. Only collapse on genuinely narrow
365 // detached panels; normal laptop workspaces keep the drawing tools visible.
366 const float toolbar_width =
367 13 * button_size + 12 * ImGui::GetStyle().ItemSpacing.x +
368 (uses_brush ? 60 + ImGui::GetStyle().ItemSpacing.x : 0);
369 if (ImGui::GetContentRegionAvail().x < toolbar_width) {
370 ImGui::SetNextItemWidth(125);
371 if (ImGui::BeginCombo("##PixelTool", state_->GetToolName())) {
372 for (const auto& tool : tools) {
373 if (ImGui::Selectable(tool.label, state_->current_tool == tool.tool)) {
374 state_->SetTool(tool.tool);
375 }
376 }
377 ImGui::EndCombo();
378 }
379 } else {
380 for (const auto& tool : tools) {
381 std::optional<gui::StyleColorGuard> selected;
382 if (state_->current_tool == tool.tool) {
383 selected.emplace(ImGuiCol_Button, gui::GetPrimaryVec4());
384 }
385 if (PixelIconButton(tool.icon, button_size)) {
386 state_->SetTool(tool.tool);
387 }
388 gui::AutoRegisterLastItem("button", tool.label);
389 if (ImGui::IsItemHovered())
390 ImGui::SetTooltip("%s", tool.label);
391 if (&tool != &tools[8])
392 ImGui::SameLine();
393 }
394 }
395 ImGui::SameLine();
396
397 // Brush size for pencil/brush/eraser
398 if (uses_brush) {
399 ImGui::SetNextItemWidth(60);
400 int brush = state_->brush_size;
401 if (ImGui::SliderInt("##BrushSize", &brush, 1, 8, "%d px")) {
402 state_->brush_size = static_cast<uint8_t>(brush);
403 }
404 HOVER_HINT("Brush size");
405 ImGui::SameLine();
406 }
407
408 // Undo/Redo buttons
409 ImGui::BeginDisabled(HasActiveStroke() || !undo_manager_ ||
411 if (PixelIconButton(ICON_MD_UNDO, button_size) && undo_manager_) {
412 const auto status = undo_ ? undo_() : undo_manager_->Undo();
413 history_error_ = status.ok() ? "" : std::string(status.message());
414 }
415 gui::AutoRegisterLastItem("button", "Undo pixels");
416 HOVER_HINT("Undo (Cmd/Ctrl+Z)");
417 ImGui::EndDisabled();
418
419 ImGui::SameLine();
420
421 ImGui::BeginDisabled(HasActiveStroke() || !undo_manager_ ||
423 if (PixelIconButton(ICON_MD_REDO, button_size) && undo_manager_) {
424 const auto status = redo_ ? redo_() : undo_manager_->Redo();
425 history_error_ = status.ok() ? "" : std::string(status.message());
426 }
427 gui::AutoRegisterLastItem("button", "Redo pixels");
428 HOVER_HINT("Redo (Cmd/Ctrl+Shift+Z)");
429 ImGui::EndDisabled();
430}
431
433 ImGui::Checkbox(tr("Pixel grid (6x zoom)"), &state_->show_pixel_grid);
434 ImGui::Checkbox(tr("8x8 tile grid"), &state_->show_grid);
435 ImGui::Checkbox(tr("16x16 block grid"), &state_->show_tile_boundaries);
436 ImGui::Checkbox(tr("Cursor crosshair"), &state_->show_cursor_crosshair);
437 ImGui::Checkbox(tr("Brush preview"), &state_->show_brush_preview);
438 ImGui::Checkbox(tr("Transparency grid"), &state_->show_transparency_grid);
439 if (ImGui::CollapsingHeader(tr("Sheet overview"))) {
440 DrawMiniMap();
441 }
442}
443
445 // Show the sheet the browser has selected rather than an empty editor.
446 if (state_->open_sheets.empty() &&
449 }
450 if (state_->open_sheets.empty()) {
451 ImGui::TextDisabled(
452 tr("No sheet selected. Select a sheet from the browser."));
453 return;
454 }
455
456 // Tab bar for open sheets
457 if (gui::BeginThemedTabBar("##SheetTabs",
458 ImGuiTabBarFlags_AutoSelectNewTabs |
459 ImGuiTabBarFlags_Reorderable |
460 ImGuiTabBarFlags_TabListPopupButton)) {
461 std::vector<uint16_t> sheets_to_close;
462
463 for (uint16_t sheet_id : state_->open_sheets) {
464 bool open = true;
465 std::string tab_label = absl::StrFormat("%02X", sheet_id);
466 if (state_->modified_sheets.count(sheet_id) > 0) {
467 tab_label += "*";
468 }
469
470 const bool requested = state_->requested_sheet_tab == sheet_id;
471 if (ImGui::BeginTabItem(tab_label.c_str(), &open,
472 requested ? ImGuiTabItemFlags_SetSelected : 0)) {
473 if (state_->current_sheet_id != sheet_id)
475 state_->current_sheet_id = sheet_id;
476 if (requested) {
478 }
479
480 // Get the current sheet bitmap
481 auto& sheet = DisplaySheet();
482
483 if (!sheet.is_active()) {
484 ImGui::TextDisabled(tr("Sheet %02X is not active"), sheet_id);
485 ImGui::EndTabItem();
486 continue;
487 }
488
490 // Fit once, after the sheet tabs establish the actual canvas space.
491 // Resizing the window must not silently change an artist's zoom.
492 const ImVec2 available = ImGui::GetContentRegionAvail();
493 state_->SetZoom(std::floor(std::min(available.x / sheet.width(),
494 available.y / sheet.height())));
495 fit_canvas_requested_ = false;
496 }
497
498 // Calculate canvas size based on zoom
499 float canvas_width = sheet.width() * state_->zoom_level;
500 float canvas_height = sheet.height() * state_->zoom_level;
501
502 // Draw canvas background
503 canvas_.DrawBackground(ImVec2(canvas_width, canvas_height));
504
505 // Draw transparency checkerboard background if enabled
507 DrawTransparencyGrid(canvas_width, canvas_height);
508 }
509
510 // Draw the sheet texture
511 if (sheet.texture()) {
512 canvas_.draw_list()->AddImage(
513 (ImTextureID)(intptr_t)sheet.texture(), canvas_.zero_point(),
514 ImVec2(canvas_.zero_point().x + canvas_width,
515 canvas_.zero_point().y + canvas_height),
516 ImVec2(0, 0), ImVec2(1, 1),
517 ImGui::GetColorU32(ImVec4(1, 1, 1, 1)));
518 }
519
520 // Draw grid if enabled
521 if (state_->show_grid) {
523 }
524 DrawPixelGrids(canvas_width, canvas_height);
525
526 // Draw transient tile highlight (e.g., from "Edit Graphics" jump)
527 DrawTileHighlight(sheet);
528
529 // Draw selection rectangle if active
531 ImVec2 sel_min =
533 ImVec2 sel_max =
536 canvas_.draw_list()->AddRect(
537 sel_min, sel_max, ImGui::GetColorU32(IM_COL32(255, 255, 0, 255)),
538 0.0f, 0, 2.0f);
539
540 // Marching ants effect (simplified)
541 canvas_.draw_list()->AddRect(
542 sel_min, sel_max, ImGui::GetColorU32(IM_COL32(0, 0, 0, 128)),
543 0.0f, 0, 1.0f);
544 }
545
547
548 // Draw tool preview (line/rectangle)
550 ImVec2 start = PixelToScreen(static_cast<int>(tool_start_pixel_.x),
551 static_cast<int>(tool_start_pixel_.y));
552 ImVec2 end = PixelToScreen(static_cast<int>(preview_end_.x),
553 static_cast<int>(preview_end_.y));
554
556 canvas_.draw_list()->AddLine(
557 start, end, ImGui::GetColorU32(IM_COL32(255, 255, 0, 200)),
558 2.0f);
560 canvas_.draw_list()->AddRect(
561 start, end, ImGui::GetColorU32(IM_COL32(255, 255, 0, 200)),
562 0.0f, 0, 2.0f);
563 }
564 }
565
566 // Draw cursor crosshair overlay if enabled and cursor in canvas
569 }
570
571 // Draw brush preview if using brush/eraser tool
576 }
577
579
580 // Handle mouse input
582
583 // Show pixel info tooltip if enabled
586 }
587
588 ImGui::EndTabItem();
589 }
590
591 if (!open) {
592 sheets_to_close.push_back(sheet_id);
593 }
594 }
595
596 // Close tabs that were requested
597 for (uint16_t sheet_id : sheets_to_close) {
598 state_->CloseSheet(sheet_id);
599 }
600
602 }
603}
604
606 float canvas_height) {
607 const float cell_size = 8.0f; // Checkerboard cell size
608 const ImU32 color1 = ImGui::GetColorU32(IM_COL32(180, 180, 180, 255));
609 const ImU32 color2 = ImGui::GetColorU32(IM_COL32(220, 220, 220, 255));
610
611 ImVec2 origin = canvas_.zero_point();
612 int cols = static_cast<int>(canvas_width / cell_size) + 1;
613 int rows = static_cast<int>(canvas_height / cell_size) + 1;
614
615 for (int row = 0; row < rows; row++) {
616 for (int col = 0; col < cols; col++) {
617 bool is_light = (row + col) % 2 == 0;
618 ImVec2 p_min(origin.x + col * cell_size, origin.y + row * cell_size);
619 ImVec2 p_max(std::min(p_min.x + cell_size, origin.x + canvas_width),
620 std::min(p_min.y + cell_size, origin.y + canvas_height));
621 canvas_.draw_list()->AddRectFilled(p_min, p_max,
622 is_light ? color1 : color2);
623 }
624 }
625}
626
628 ImVec2 cursor_screen = PixelToScreen(cursor_x_, cursor_y_);
629 float pixel_size = state_->zoom_level;
630
631 // Vertical line through cursor pixel
632 ImVec2 v_start(cursor_screen.x + pixel_size / 2, canvas_.zero_point().y);
633 ImVec2 v_end(cursor_screen.x + pixel_size / 2,
635 canvas_.draw_list()->AddLine(
636 v_start, v_end, ImGui::GetColorU32(IM_COL32(255, 100, 100, 100)), 1.0f);
637
638 // Horizontal line through cursor pixel
639 ImVec2 h_start(canvas_.zero_point().x, cursor_screen.y + pixel_size / 2);
640 ImVec2 h_end(canvas_.zero_point().x + canvas_.canvas_size().x,
641 cursor_screen.y + pixel_size / 2);
642 canvas_.draw_list()->AddLine(
643 h_start, h_end, ImGui::GetColorU32(IM_COL32(255, 100, 100, 100)), 1.0f);
644
645 // Highlight current pixel with a bright outline
646 ImVec2 pixel_min = cursor_screen;
647 ImVec2 pixel_max(cursor_screen.x + pixel_size, cursor_screen.y + pixel_size);
648 canvas_.draw_list()->AddRect(pixel_min, pixel_max,
649 ImGui::GetColorU32(IM_COL32(255, 255, 255, 200)),
650 0.0f, 0, 2.0f);
651}
652
654 ImVec2 cursor_screen = PixelToScreen(cursor_x_, cursor_y_);
655 float pixel_size = state_->zoom_level;
656 int brush = state_->brush_size;
657 int half = brush / 2;
658
659 // Draw preview of brush area
660 ImVec2 brush_min(cursor_screen.x - half * pixel_size,
661 cursor_screen.y - half * pixel_size);
662 ImVec2 brush_max(cursor_screen.x + (brush - half) * pixel_size,
663 cursor_screen.y + (brush - half) * pixel_size);
664
665 // Fill with semi-transparent color preview
666 ImU32 preview_color = (state_->current_tool == PixelTool::kEraser)
667 ? ImGui::GetColorU32(IM_COL32(255, 0, 0, 50))
668 : ImGui::GetColorU32(IM_COL32(0, 255, 0, 50));
669 canvas_.draw_list()->AddRectFilled(brush_min, brush_max, preview_color);
670
671 // Outline
672 ImU32 outline_color = (state_->current_tool == PixelTool::kEraser)
673 ? ImGui::GetColorU32(IM_COL32(255, 100, 100, 200))
674 : ImGui::GetColorU32(IM_COL32(100, 255, 100, 200));
675 canvas_.draw_list()->AddRect(brush_min, brush_max, outline_color, 0.0f, 0,
676 1.0f);
677}
678
680 if (cursor_x_ < 0 || cursor_x_ >= sheet.width() || cursor_y_ < 0 ||
681 cursor_y_ >= sheet.height()) {
682 return;
683 }
684
685 uint8_t color_index = sheet.GetPixel(cursor_x_, cursor_y_);
686 const auto& palette = DisplayPalette();
687
688 ImGui::BeginTooltip();
689 ImGui::Text(tr("Pos: %d, %d"), cursor_x_, cursor_y_);
690 ImGui::Text(tr("Tile: %d, %d"), cursor_x_ / 8, cursor_y_ / 8);
691 ImGui::Text(tr("Index: %d"), color_index);
692
693 if (color_index < palette.size()) {
694 ImGui::Text(tr("SNES: $%04X"), palette[color_index].snes());
695 ImVec4 color(palette[color_index].rgb().x / 255.0f,
696 palette[color_index].rgb().y / 255.0f,
697 palette[color_index].rgb().z / 255.0f, 1.0f);
698 ImGui::ColorButton("##ColorPreview", color, ImGuiColorEditFlags_NoTooltip,
699 ImVec2(24, 24));
700 if (color_index == 0) {
701 ImGui::SameLine();
702 ImGui::TextDisabled(tr("(Transparent)"));
703 }
704 }
705 ImGui::EndTooltip();
706}
707
710 return;
711 }
713 return;
714 }
715
716 const double now = ImGui::GetTime();
717 const double elapsed = now - state_->tile_highlight.start_time;
718 if (elapsed > state_->tile_highlight.duration) {
720 return;
721 }
722
723 const int tiles_per_row = sheet.width() / 8;
724 if (tiles_per_row <= 0) {
725 return;
726 }
727 const uint16_t tile_index = state_->tile_highlight.tile_index;
728 const int tile_x = static_cast<int>(tile_index % tiles_per_row);
729 const int tile_y = static_cast<int>(tile_index / tiles_per_row);
730
731 const float pulse =
732 0.5f + 0.5f * std::sin(static_cast<float>(elapsed) * 6.0f);
733 const float alpha = 0.25f + (pulse * 0.35f);
734
735 ImVec2 min = PixelToScreen(tile_x * 8, tile_y * 8);
736 ImVec2 max = PixelToScreen(tile_x * 8 + 8, tile_y * 8 + 8);
737
738 ImVec4 sel = gui::GetSelectedColor();
739 sel.w = alpha;
740 const ImU32 fill_color = ImGui::GetColorU32(sel);
741 ImVec4 sel_outline = gui::GetSelectedColor();
742 sel_outline.w = 0.9f;
743 const ImU32 outline_color = ImGui::GetColorU32(sel_outline);
744 canvas_.draw_list()->AddRectFilled(min, max, fill_color);
745 canvas_.draw_list()->AddRect(min, max, outline_color, 0.0f, 0, 2.0f);
746
747 if (ImGui::IsMouseHoveringRect(min, max)) {
748 ImGui::BeginTooltip();
749 ImGui::Text(tr("Focus tile: %d (sheet %02X)"), tile_index,
751 if (!state_->tile_highlight.label.empty()) {
752 ImGui::Text("%s", state_->tile_highlight.label.c_str());
753 }
754 ImGui::EndTooltip();
755 }
756}
757
759 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,
760 ImVec2(4, ImGui::GetStyle().ItemSpacing.y));
761 const float right =
762 ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x;
763 ImGui::BeginGroup();
765 ImGui::EndGroup();
766 const float swatch_size = ImGui::GetFrameHeight();
767 const float swatch_width =
768 9 * swatch_size + 9 * ImGui::GetStyle().ItemSpacing.x;
769 if (ImGui::GetItemRectMax().x + swatch_width +
770 ImGui::GetStyle().ItemSpacing.x <=
771 right) {
772 ImGui::SameLine();
773 }
774 ImGui::BeginGroup();
775 if (state_->open_sheets.empty()) {
776 ImGui::TextDisabled(tr("No sheet"));
777 ImGui::EndGroup();
778 ImGui::PopStyleVar();
779 return;
780 }
781
782 const auto& palette = DisplayPalette();
783
784 // Selection is an index, not a cached RGB value. Resolve it again when the
785 // sheet, palette row, or source context changes. Index zero stays transparent.
786 const auto display_color = [&palette](int index) {
787 ImVec4 color = gui::ConvertSnesColorToImVec4(palette[index]);
788 color.w = index == 0 ? 0.0f : 1.0f;
789 return color;
790 };
791 constexpr auto kSwatchFlags = ImGuiColorEditFlags_NoTooltip |
792 ImGuiColorEditFlags_NoBorder |
793 ImGuiColorEditFlags_AlphaPreview;
794
795 // Sheet editing is indexed 3bpp; rendered 4bpp expansion is not authored.
796 for (int i = 0; i < static_cast<int>(palette.size()) && i < 8; i++) {
797 if (i > 0) {
798 ImGui::SameLine();
799 }
800
801 std::string id = absl::StrFormat("##Color%d", i);
802 if (ImGui::ColorButton(id.c_str(), display_color(i), kSwatchFlags,
803 ImVec2(swatch_size, swatch_size))) {
804 state_->current_color_index = static_cast<uint8_t>(i);
805 }
806 gui::AutoRegisterLastItem("color", absl::StrFormat("Palette index %d", i));
807
808 if (state_->current_color_index == i) {
809 const auto min = ImGui::GetItemRectMin();
810 const auto max = ImGui::GetItemRectMax();
811 ImGui::GetWindowDrawList()->AddRect(
812 ImVec2(min.x - 1, min.y - 1), ImVec2(max.x + 1, max.y + 1),
813 ImGui::GetColorU32(gui::GetSelectedColor()),
814 ImGui::GetStyle().FrameRounding, 0, 1.5f);
815 }
816
817 if (ImGui::IsItemHovered()) {
818 ImGui::BeginTooltip();
819 ImGui::Text(tr("Index: %d"), i);
820 ImGui::Text(tr("SNES: $%04X"), palette[i].snes());
821 ImGui::Text(tr("RGB: %d, %d, %d"), static_cast<int>(palette[i].rgb().x),
822 static_cast<int>(palette[i].rgb().y),
823 static_cast<int>(palette[i].rgb().z));
824 if (i == 0) {
825 ImGui::Text(tr("(Transparent)"));
826 }
827 ImGui::EndTooltip();
828 }
829 }
830
831 // Keep the foreground swatch adjacent to the indexed colors.
832 ImGui::SameLine(0, ImGui::GetStyle().ItemSpacing.x * 2);
833 const int index = state_->current_color_index;
834 if (index >= 8 || index >= palette.size()) {
835 ImGui::TextDisabled("?");
836 HOVER_HINT("Selected palette index is unavailable");
837 ImGui::EndGroup();
838 ImGui::PopStyleVar();
839 return;
840 }
841 ImGui::ColorButton("##CurrentColor", display_color(index), kSwatchFlags,
842 ImVec2(swatch_size, swatch_size));
843 gui::AutoRegisterLastItem("color", "Current color");
844 if (ImGui::IsItemHovered()) {
845 ImGui::SetTooltip(tr("Current color: %d%s"), index,
846 index == 0 ? tr(" (transparent)") : "");
847 }
848 ImGui::EndGroup();
849 ImGui::PopStyleVar();
851}
852
854 if (state_->open_sheets.empty()) {
855 ImGui::TextDisabled(tr("No sheet"));
856 return;
857 }
858
859 auto& sheet = DisplaySheet();
860 if (!sheet.texture())
861 return;
862
863 // Draw mini version of the sheet
864 float mini_scale = 0.5f;
865 float mini_width = sheet.width() * mini_scale;
866 float mini_height = sheet.height() * mini_scale;
867
868 ImVec2 pos = ImGui::GetCursorScreenPos();
869
870 ImGui::GetWindowDrawList()->AddImage(
871 (ImTextureID)(intptr_t)sheet.texture(), pos,
872 ImVec2(pos.x + mini_width, pos.y + mini_height), ImVec2(0, 0),
873 ImVec2(1, 1), ImGui::GetColorU32(ImVec4(1, 1, 1, 1)));
874
875 // Draw viewport rectangle
876 // TODO: Calculate actual viewport bounds based on scroll position
877
878 ImGui::Dummy(ImVec2(mini_width, mini_height));
879}
880
882 ImGui::Separator();
883 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,
884 ImVec2(4, ImGui::GetStyle().ItemSpacing.y));
885 const float button_size = ImGui::GetFrameHeight();
886 if (PixelIconButton(ICON_MD_ZOOM_OUT, button_size)) {
887 state_->ZoomOut();
888 }
889 gui::AutoRegisterLastItem("button", "Zoom out");
890 HOVER_HINT("Zoom out (-)");
891 ImGui::SameLine();
892 ImGui::SetNextItemWidth(ImGui::CalcTextSize("16x").x + button_size + 8);
893 if (ImGui::BeginCombo("##PixelZoom",
894 absl::StrFormat("%.0fx", state_->zoom_level).c_str())) {
895 for (int zoom = 1; zoom <= 16; ++zoom) {
896 if (ImGui::Selectable(absl::StrFormat("%dx", zoom).c_str(),
897 state_->zoom_level == zoom)) {
898 state_->SetZoom(zoom);
899 }
900 }
901 ImGui::EndCombo();
902 }
903 ImGui::SameLine();
904 if (PixelIconButton(ICON_MD_ZOOM_IN, button_size)) {
905 state_->ZoomIn();
906 }
907 gui::AutoRegisterLastItem("button", "Zoom in");
908 HOVER_HINT("Zoom in (+)");
909 ImGui::SameLine();
910 if (ImGui::Button(tr("Fit"))) {
912 }
913 gui::AutoRegisterLastItem("button", "Fit sheet");
914 HOVER_HINT("Fit this sheet at an integer zoom and reset panning");
915 ImGui::SameLine();
916
917 std::string status = absl::StrFormat(
918 "%s | %02X | %s %d", state_->GetToolName(), state_->current_sheet_id,
919 tr("Color"), state_->current_color_index);
920 if (cursor_in_canvas_) {
921 absl::StrAppendFormat(&status, " | %d,%d | %s %d", cursor_x_, cursor_y_,
922 tr("Tile"), cursor_y_ / 8 * 16 + cursor_x_ / 8);
923 }
926 absl::StrAppendFormat(&status, " | %s %d", tr("Focus"),
928 }
930 status += tr(" | Modified");
931 }
932 if (!history_error_.empty())
933 status += " | " + history_error_;
934 // Long cursor/source metadata must not push view controls off a small panel.
935 ImGui::BeginChild(
936 "##PixelStatus", ImVec2(0, button_size), false,
937 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
938 ImGui::AlignTextToFramePadding();
939 ImGui::TextUnformatted(status.c_str());
940 if (ImGui::IsWindowHovered()) {
941 ImGui::SetTooltip("%s", status.c_str());
942 }
943 ImGui::EndChild();
944 ImGui::PopStyleVar();
945}
946
948 if (!ImGui::IsItemHovered()) {
949 cursor_in_canvas_ = false;
950 return;
951 }
952
953 cursor_in_canvas_ = true;
954 ImVec2 mouse_pos = ImGui::GetMousePos();
955 ImVec2 pixel_pos = ScreenToPixel(mouse_pos);
956
957 cursor_x_ = static_cast<int>(pixel_pos.x);
958 cursor_y_ = static_cast<int>(pixel_pos.y);
959
960 auto& sheet =
962
963 // Clamp to sheet bounds
964 cursor_x_ = std::clamp(cursor_x_, 0, sheet.width() - 1);
965 cursor_y_ = std::clamp(cursor_y_, 0, sheet.height() - 1);
966
968 return;
969 }
970
971 // Mouse button handling
972 if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
973 is_drawing_ = true;
976 ImVec2(static_cast<float>(cursor_x_), static_cast<float>(cursor_y_));
978
979 // Pixel writes until mouse-up form one stroke (one undo step).
980 BeginStroke();
981
982 // Handle tools that need start position
983 switch (state_->current_tool) {
986 break;
989 break;
992 break;
993 case PixelTool::kFill:
995 break;
998 break;
1001 break;
1002 case PixelTool::kLine:
1004 show_tool_preview_ = true;
1005 break;
1006 default:
1007 break;
1008 }
1009 }
1010
1011 if (ImGui::IsMouseDragging(ImGuiMouseButton_Left) && is_drawing_) {
1012 preview_end_ =
1013 ImVec2(static_cast<float>(cursor_x_), static_cast<float>(cursor_y_));
1014
1015 switch (state_->current_tool) {
1016 case PixelTool::kPencil:
1018 break;
1019 case PixelTool::kBrush:
1021 break;
1022 case PixelTool::kEraser:
1024 break;
1025 case PixelTool::kSelect:
1027 break;
1028 default:
1029 break;
1030 }
1031
1033 ImVec2(static_cast<float>(cursor_x_), static_cast<float>(cursor_y_));
1034 }
1035
1036 if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && is_drawing_)
1037 FinishGesture();
1038}
1039
1041 if (is_drawing_ && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
1042 // The canvas release path already handles the current endpoint. This is
1043 // the outside/hidden-panel fallback, so retain the last valid drag point.
1044 cursor_x_ = static_cast<int>(last_mouse_pixel_.x);
1045 cursor_y_ = static_cast<int>(last_mouse_pixel_.y);
1046 FinishGesture();
1047 }
1048}
1049
1051 if ((is_drawing_ || stroke_) && ActiveSheetStore(*state_) == nullptr)
1052 return;
1053 if (is_drawing_) {
1054 // Use the last point inside the canvas, never the control released over.
1055 // Explicit sheet navigation calls this before changing the selected sheet.
1056 switch (gesture_tool_) {
1057 case PixelTool::kLine:
1058 DrawLine(static_cast<int>(tool_start_pixel_.x),
1059 static_cast<int>(tool_start_pixel_.y), cursor_x_, cursor_y_);
1060 break;
1062 DrawRectangle(static_cast<int>(tool_start_pixel_.x),
1063 static_cast<int>(tool_start_pixel_.y), cursor_x_,
1064 cursor_y_, false);
1065 break;
1066 case PixelTool::kSelect:
1067 EndSelection();
1068 break;
1069 default:
1070 break;
1071 }
1072 }
1073 is_drawing_ = false;
1074 show_tool_preview_ = false;
1075 EndStroke();
1076}
1077
1079 auto* edit = StrokeEdit();
1080 if (edit == nullptr) {
1081 return;
1082 }
1083 ShowStrokeChanges(edit->Set(x, y, state_->current_color_index));
1084}
1085
1087 auto* edit = StrokeEdit();
1088 if (edit == nullptr) {
1089 return;
1090 }
1091 int size = state_->brush_size;
1092 int half = size / 2;
1093 bool changed = false;
1094 for (int dy = -half; dy < size - half; dy++) {
1095 for (int dx = -half; dx < size - half; dx++) {
1096 changed |= edit->Set(x + dx, y + dy, state_->current_color_index);
1097 }
1098 }
1099 ShowStrokeChanges(changed);
1100}
1101
1103 auto* edit = StrokeEdit();
1104 if (edit == nullptr) {
1105 return;
1106 }
1107 int size = state_->brush_size;
1108 int half = size / 2;
1109 bool changed = false;
1110 for (int dy = -half; dy < size - half; dy++) {
1111 for (int dx = -half; dx < size - half; dx++) {
1112 changed |= edit->Set(x + dx, y + dy, 0); // Index 0 = transparent
1113 }
1114 }
1115 ShowStrokeChanges(changed);
1116}
1117
1119 auto* edit = StrokeEdit();
1120 if (edit == nullptr) {
1121 return;
1122 }
1123 const int width = edit->width();
1124 const int height = edit->height();
1125 if (x < 0 || x >= width || y < 0 || y >= height)
1126 return;
1127
1128 uint8_t target_color = edit->Get(x, y);
1129 uint8_t fill_color = state_->current_color_index;
1130
1131 if (target_color == fill_color)
1132 return; // Nothing to fill
1133
1134 // BFS flood fill
1135 std::queue<std::pair<int, int>> queue;
1136 std::vector<bool> visited(width * height, false);
1137
1138 queue.push({x, y});
1139 visited[y * width + x] = true;
1140
1141 while (!queue.empty()) {
1142 auto [cx, cy] = queue.front();
1143 queue.pop();
1144
1145 edit->Set(cx, cy, fill_color);
1146
1147 // Check 4-connected neighbors
1148 const int dx[] = {0, 0, -1, 1};
1149 const int dy[] = {-1, 1, 0, 0};
1150
1151 for (int i = 0; i < 4; i++) {
1152 int nx = cx + dx[i];
1153 int ny = cy + dy[i];
1154
1155 if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
1156 int idx = ny * width + nx;
1157 if (!visited[idx] && edit->Get(nx, ny) == target_color) {
1158 visited[idx] = true;
1159 queue.push({nx, ny});
1160 }
1161 }
1162 }
1163 }
1164
1165 ShowStrokeChanges(true);
1166}
1167
1170
1171 if (x >= 0 && x < sheet.width() && y >= 0 && y < sheet.height()) {
1172 state_->current_color_index = sheet.GetPixel(x, y);
1173 }
1174}
1175
1176void PixelEditorPanel::DrawLine(int x1, int y1, int x2, int y2) {
1177 auto* edit = StrokeEdit();
1178 if (edit == nullptr) {
1179 return;
1180 }
1181
1182 // Bresenham's line algorithm
1183 int dx = std::abs(x2 - x1);
1184 int dy = std::abs(y2 - y1);
1185 int sx = x1 < x2 ? 1 : -1;
1186 int sy = y1 < y2 ? 1 : -1;
1187 int err = dx - dy;
1188 bool changed = false;
1189
1190 while (true) {
1191 changed |= edit->Set(x1, y1, state_->current_color_index);
1192
1193 if (x1 == x2 && y1 == y2)
1194 break;
1195
1196 int e2 = 2 * err;
1197 if (e2 > -dy) {
1198 err -= dy;
1199 x1 += sx;
1200 }
1201 if (e2 < dx) {
1202 err += dx;
1203 y1 += sy;
1204 }
1205 }
1206
1207 ShowStrokeChanges(changed);
1208}
1209
1210void PixelEditorPanel::DrawRectangle(int x1, int y1, int x2, int y2,
1211 bool filled) {
1212 auto* edit = StrokeEdit();
1213 if (edit == nullptr) {
1214 return;
1215 }
1216
1217 int min_x = std::min(x1, x2);
1218 int max_x = std::max(x1, x2);
1219 int min_y = std::min(y1, y2);
1220 int max_y = std::max(y1, y2);
1221 const uint8_t color = state_->current_color_index;
1222 bool changed = false;
1223
1224 if (filled) {
1225 for (int y = min_y; y <= max_y; y++) {
1226 for (int x = min_x; x <= max_x; x++) {
1227 changed |= edit->Set(x, y, color);
1228 }
1229 }
1230 } else {
1231 // Top and bottom edges
1232 for (int x = min_x; x <= max_x; x++) {
1233 changed |= edit->Set(x, min_y, color);
1234 changed |= edit->Set(x, max_y, color);
1235 }
1236 // Left and right edges
1237 for (int y = min_y; y <= max_y; y++) {
1238 changed |= edit->Set(min_x, y, color);
1239 changed |= edit->Set(max_x, y, color);
1240 }
1241 }
1242
1243 ShowStrokeChanges(changed);
1244}
1245
1247 state_->selection.x = x;
1248 state_->selection.y = y;
1249 state_->selection.width = 1;
1250 state_->selection.height = 1;
1251 state_->selection.is_active = true;
1252 state_->is_selecting = true;
1253}
1254
1256 int start_x = static_cast<int>(tool_start_pixel_.x);
1257 int start_y = static_cast<int>(tool_start_pixel_.y);
1258
1259 state_->selection.x = std::min(start_x, x);
1260 state_->selection.y = std::min(start_y, y);
1261 state_->selection.width = std::abs(x - start_x) + 1;
1262 state_->selection.height = std::abs(y - start_y) + 1;
1263}
1264
1266 state_->is_selecting = false;
1267
1268 // Copy pixel data for the selection
1269 if (state_->selection.width > 0 && state_->selection.height > 0) {
1273
1274 for (int y = 0; y < state_->selection.height; y++) {
1275 for (int x = 0; x < state_->selection.width; x++) {
1276 int src_x = state_->selection.x + x;
1277 int src_y = state_->selection.y + y;
1278 if (src_x >= 0 && src_x < sheet.width() && src_y >= 0 &&
1279 src_y < sheet.height()) {
1281 sheet.GetPixel(src_x, src_y);
1282 }
1283 }
1284 }
1285
1287 }
1288}
1289
1291 // Selection data is already in state_->selection
1292}
1293
1295 if (state_->selection.pixel_data.empty())
1296 return;
1297
1298 BeginStroke();
1299 auto* edit = StrokeEdit();
1300 if (edit == nullptr) {
1301 return;
1302 }
1303 bool changed = false;
1304 for (int dy = 0; dy < state_->selection.height; dy++) {
1305 for (int dx = 0; dx < state_->selection.width; dx++) {
1306 uint8_t pixel =
1308 changed |= edit->Set(x + dx, y + dy, pixel);
1309 }
1310 }
1311 ShowStrokeChanges(changed);
1312 EndStroke();
1313}
1314
1316 if (state_->selection.pixel_data.empty())
1317 return;
1318
1319 std::vector<uint8_t> flipped(state_->selection.pixel_data.size());
1320 for (int y = 0; y < state_->selection.height; y++) {
1321 for (int x = 0; x < state_->selection.width; x++) {
1322 int src_idx = y * state_->selection.width + x;
1323 int dst_idx =
1324 y * state_->selection.width + (state_->selection.width - 1 - x);
1325 flipped[dst_idx] = state_->selection.pixel_data[src_idx];
1326 }
1327 }
1328 state_->selection.pixel_data = std::move(flipped);
1329}
1330
1332 if (state_->selection.pixel_data.empty())
1333 return;
1334
1335 std::vector<uint8_t> flipped(state_->selection.pixel_data.size());
1336 for (int y = 0; y < state_->selection.height; y++) {
1337 for (int x = 0; x < state_->selection.width; x++) {
1338 int src_idx = y * state_->selection.width + x;
1339 int dst_idx =
1340 (state_->selection.height - 1 - y) * state_->selection.width + x;
1341 flipped[dst_idx] = state_->selection.pixel_data[src_idx];
1342 }
1343 }
1344 state_->selection.pixel_data = std::move(flipped);
1345}
1346
1348 EndStroke(); // a stroke left open ends here
1349 auto* store = ActiveSheetStore(*state_);
1350 if (store == nullptr) {
1351 return;
1352 }
1353 stroke_.emplace(*store, state_->current_sheet_id);
1354 if (!stroke_->ok()) {
1355 stroke_.reset();
1356 }
1357}
1358
1360 if (!stroke_ || stroke_->sheet() != state_->current_sheet_id) {
1361 BeginStroke();
1362 }
1363 return stroke_ ? &*stroke_ : nullptr;
1364}
1365
1367 if (!stroke_ || !changed) {
1368 return;
1369 }
1370 RefreshArenaSheet(*state_, stroke_->sheet());
1371 state_->MarkSheetModified(stroke_->sheet());
1372}
1373
1375 if (!stroke_) {
1376 return;
1377 }
1378 const uint16_t sheet = stroke_->sheet();
1379 SheetPixelDiff diff =
1380 MakeSheetPixelDiff(sheet, stroke_->before(), stroke_->pixels());
1381 stroke_.reset(); // commits: one revision for the whole stroke
1382 RefreshArenaSheet(*state_, sheet);
1383 if (diff.empty()) {
1384 return;
1385 }
1386 state_->MarkSheetModified(sheet);
1387 if (undo_manager_ != nullptr) {
1388 undo_manager_->Push(std::make_unique<GraphicsPixelEditAction>(
1389 state_, std::move(diff),
1390 absl::StrFormat("Edit pixels on sheet %02X", sheet)));
1391 }
1392}
1393
1395 const bool hand = state_->current_tool == PixelTool::kHand;
1396 const bool middle = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
1397 if (!middle && !(hand && ImGui::IsMouseDown(ImGuiMouseButton_Left))) {
1398 return hand; // the Hand tool never paints
1399 }
1400 // DrawCanvas runs inside the scrolling "##CanvasArea" child.
1401 const ImVec2 delta = ImGui::GetIO().MouseDelta;
1402 ImGui::SetScrollX(ImGui::GetScrollX() - delta.x);
1403 ImGui::SetScrollY(ImGui::GetScrollY() - delta.y);
1404 return true;
1405}
1406
1407void PixelEditorPanel::DrawPixelGrids(float canvas_width, float canvas_height) {
1408 auto* draw_list = canvas_.draw_list();
1409 const ImVec2 origin = canvas_.zero_point();
1410 const float zoom = state_->zoom_level;
1411 auto lines = [&](float step, ImU32 color, float thickness) {
1412 for (float x = step; x < canvas_width; x += step) {
1413 draw_list->AddLine(ImVec2(origin.x + x, origin.y),
1414 ImVec2(origin.x + x, origin.y + canvas_height), color,
1415 thickness);
1416 }
1417 for (float y = step; y < canvas_height; y += step) {
1418 draw_list->AddLine(ImVec2(origin.x, origin.y + y),
1419 ImVec2(origin.x + canvas_width, origin.y + y), color,
1420 thickness);
1421 }
1422 };
1423 if (state_->show_pixel_grid && zoom >= 6.0f) {
1424 lines(zoom, ImGui::GetColorU32(IM_COL32(255, 255, 255, 28)), 1.0f);
1425 }
1427 lines(16.0f * zoom, ImGui::GetColorU32(gui::GetInfoColor()), 1.5f);
1428 }
1429}
1430
1432 if (state_->SourceTarget()) {
1433 ImGui::AlignTextToFramePadding();
1434 ImGui::TextDisabled("%s", tr("Source colors"));
1435 HOVER_HINT("Colors resolved by the source editor");
1436 return;
1437 }
1440}
1441
1443 SheetColors colors{};
1444 const auto& palette = DisplayPalette();
1445 for (size_t i = 0; i < colors.size(); ++i) {
1446 if (i < palette.size()) {
1447 const ImVec4 rgb = palette[i].rgb(); // 0-255 components
1448 colors[i] = {static_cast<uint8_t>(rgb.x), static_cast<uint8_t>(rgb.y),
1449 static_cast<uint8_t>(rgb.z)};
1450 } else {
1451 const auto gray = static_cast<uint8_t>(i * 255 / 7);
1452 colors[i] = {gray, gray, gray};
1453 }
1454 }
1455 return colors;
1456}
1457
1458// Cmd+C/X/V reach Copy/Cut/Paste through the editor shortcuts
1459// (GraphicsEditor::Copy and friends); only the floating paste's own keys are
1460// handled here.
1463 !ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) ||
1464 ImGui::GetIO().WantTextInput) {
1465 return;
1466 }
1467 if (ImGui::IsKeyPressed(ImGuiKey_Enter) ||
1468 ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) {
1470 return;
1471 }
1472 if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
1474 return;
1475 }
1476 const int step = ImGui::GetIO().KeyShift ? 8 : 1;
1477 auto& selection = state_->selection;
1478 if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow))
1479 selection.x -= step;
1480 if (ImGui::IsKeyPressed(ImGuiKey_RightArrow))
1481 selection.x += step;
1482 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow))
1483 selection.y -= step;
1484 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow))
1485 selection.y += step;
1487}
1488
1490 const bool image_clipboard = util::ImageClipboardSupported();
1491 ImGui::BeginDisabled(!image_clipboard);
1493 "Copy selection, or the whole sheet, as a PNG "
1494 "(Cmd+C)")) {
1496 }
1497 ImGui::SameLine();
1499 "Paste a PNG from the clipboard as a floating "
1500 "selection (Cmd+V)")) {
1502 }
1503 ImGui::EndDisabled();
1504 if (!image_clipboard) {
1505 ImGui::SameLine();
1506 ImGui::TextDisabled("%s", tr("(image clipboard: macOS only)"));
1507 }
1509 ImGui::SameLine();
1510 if (ImGui::SmallButton(ICON_MD_CHECK " Commit")) {
1512 }
1513 HOVER_HINT("Write the floating paste into the sheet (Enter)");
1514 ImGui::SameLine();
1515 if (ImGui::SmallButton(ICON_MD_CLOSE " Cancel")) {
1517 }
1518 HOVER_HINT("Drop the floating paste (Esc)");
1519 ImGui::SameLine();
1520 ImGui::Checkbox(tr("Skip color 0"), &paste_skip_color0_);
1521 HOVER_HINT("Leave pixels under color 0 of the paste unchanged");
1522 }
1523 if (!clipboard_status_.empty()) {
1524 ImGui::TextColored(
1526 "%s", clipboard_status_.c_str());
1527 }
1528}
1529
1531 const auto& sheet = gfx::Arena::Get().gfx_sheets()[state_->current_sheet_id];
1532 if (!sheet.is_active()) {
1533 clipboard_status_ = "No sheet to copy";
1535 return;
1536 }
1537 const auto& selection = state_->selection;
1538 int x0 = 0;
1539 int y0 = 0;
1540 int width = sheet.width();
1541 int height = sheet.height();
1542 if (selection.is_active && !selection.is_floating && selection.width > 0 &&
1543 selection.height > 0) {
1544 x0 = selection.x;
1545 y0 = selection.y;
1546 width = selection.width;
1547 height = selection.height;
1548 }
1549 // Read the current pixels; the selection's copy may predate later edits.
1550 std::vector<uint8_t> pixels(static_cast<size_t>(width) * height, 0);
1551 for (int y = 0; y < height; ++y) {
1552 for (int x = 0; x < width; ++x) {
1553 const int sx = x0 + x;
1554 const int sy = y0 + y;
1555 if (sx >= 0 && sx < sheet.width() && sy >= 0 && sy < sheet.height()) {
1556 pixels[y * width + x] = sheet.GetPixel(sx, sy);
1557 }
1558 }
1559 }
1560 auto png = EncodeClipboardPng(pixels, width, height, CurrentSheetColors());
1561 const absl::Status status =
1562 png.ok() ? util::SetClipboardPng(*png) : png.status();
1564 status.ok() ? absl::StrFormat("Copied %dx%d from sheet %02X", width,
1565 height, state_->current_sheet_id)
1566 : std::string(status.message());
1567 clipboard_status_is_error_ = !status.ok();
1568}
1569
1571 const auto& selection = state_->selection;
1572 if (!selection.is_active || selection.is_floating || selection.width <= 0 ||
1573 selection.height <= 0) {
1574 clipboard_status_ = "Select pixels to cut";
1576 return;
1577 }
1580 return;
1581 }
1582 BeginStroke();
1583 auto* edit = StrokeEdit();
1584 if (edit == nullptr) {
1585 clipboard_status_ = "Copied, but this sheet cannot be edited here";
1587 return;
1588 }
1589 bool changed = false;
1590 for (int y = selection.y; y < selection.y + selection.height; ++y) {
1591 for (int x = selection.x; x < selection.x + selection.width; ++x) {
1592 changed |= edit->Set(x, y, 0);
1593 }
1594 }
1595 ShowStrokeChanges(changed);
1596 EndStroke();
1598 absl::StrFormat("Cut %dx%d from sheet %02X", selection.width,
1599 selection.height, state_->current_sheet_id);
1600}
1601
1603 const auto& sheet = gfx::Arena::Get().gfx_sheets()[state_->current_sheet_id];
1604 if (!sheet.is_active()) {
1605 clipboard_status_ = "Open a sheet before pasting";
1607 return;
1608 }
1609 auto png = util::GetClipboardPng();
1610 auto paste = png.ok() ? MapClipboardPng(*png, CurrentSheetColors(),
1611 sheet.width(), sheet.height())
1612 : absl::StatusOr<ClipboardPaste>(png.status());
1613 if (!paste.ok()) {
1614 clipboard_status_ = std::string(paste.status().message());
1616 return;
1617 }
1618 auto& selection = state_->selection;
1619 // Float over the current selection's corner, or the sheet's top left.
1620 const int x = selection.is_active ? selection.x : 0;
1621 const int y = selection.is_active ? selection.y : 0;
1622 selection.pixel_data = paste->indices;
1623 selection.width = paste->width;
1624 selection.height = paste->height;
1625 selection.x = x;
1626 selection.y = y;
1627 selection.is_active = true;
1628 selection.is_floating = true;
1631 "Paste " + DescribeClipboardPaste(*paste) +
1632 (paste->above_depth > 0 ? " (colors above 7 were mapped by color)" : "") +
1633 ". Drag or use arrows, Enter to commit, Esc to cancel.";
1635}
1636
1638 const auto& sheet = gfx::Arena::Get().gfx_sheets()[state_->current_sheet_id];
1639 auto& selection = state_->selection;
1640 selection.x =
1641 std::clamp(selection.x, 0, std::max(0, sheet.width() - selection.width));
1642 selection.y = std::clamp(selection.y, 0,
1643 std::max(0, sheet.height() - selection.height));
1644}
1645
1647 auto& selection = state_->selection;
1648 if (!selection.is_floating) {
1649 return;
1650 }
1651 BeginStroke();
1652 auto* edit = StrokeEdit();
1653 if (edit == nullptr) {
1654 clipboard_status_ = "This sheet cannot be edited here";
1656 return;
1657 }
1658 bool changed = false;
1659 for (int dy = 0; dy < selection.height; ++dy) {
1660 for (int dx = 0; dx < selection.width; ++dx) {
1661 const uint8_t pixel = selection.pixel_data[dy * selection.width + dx];
1662 if (paste_skip_color0_ && pixel == 0) {
1663 continue;
1664 }
1665 changed |= edit->Set(selection.x + dx, selection.y + dy, pixel);
1666 }
1667 }
1668 ShowStrokeChanges(changed);
1669 EndStroke();
1670 selection.is_floating = false;
1671 dragging_paste_ = false;
1673 absl::StrFormat("Pasted %dx%d at %d,%d", selection.width,
1674 selection.height, selection.x, selection.y);
1676}
1677
1680 dragging_paste_ = false;
1681 clipboard_status_ = "Paste cancelled";
1683}
1684
1686 auto& selection = state_->selection;
1687 if (!selection.is_floating) {
1688 return false;
1689 }
1690 const bool inside =
1691 cursor_x_ >= selection.x && cursor_x_ < selection.x + selection.width &&
1692 cursor_y_ >= selection.y && cursor_y_ < selection.y + selection.height;
1693 if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
1694 if (inside) {
1695 dragging_paste_ = true;
1696 paste_drag_dx_ = cursor_x_ - selection.x;
1697 paste_drag_dy_ = cursor_y_ - selection.y;
1698 } else {
1700 }
1701 return true;
1702 }
1703 if (dragging_paste_ && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
1704 selection.x = cursor_x_ - paste_drag_dx_;
1705 selection.y = cursor_y_ - paste_drag_dy_;
1707 }
1708 if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
1709 dragging_paste_ = false;
1710 }
1711 return true;
1712}
1713
1715 const auto& selection = state_->selection;
1716 if (!selection.is_floating) {
1717 return;
1718 }
1719 const SheetColors colors = CurrentSheetColors();
1720 auto* draw_list = canvas_.draw_list();
1721 for (int dy = 0; dy < selection.height; ++dy) {
1722 for (int dx = 0; dx < selection.width; ++dx) {
1723 const uint8_t pixel = selection.pixel_data[dy * selection.width + dx];
1724 if (paste_skip_color0_ && pixel == 0) {
1725 continue;
1726 }
1727 const auto& rgb = colors[pixel & 7];
1728 const ImVec2 p0 = PixelToScreen(selection.x + dx, selection.y + dy);
1729 const ImVec2 p1 =
1730 PixelToScreen(selection.x + dx + 1, selection.y + dy + 1);
1731 draw_list->AddRectFilled(
1732 p0, p1, ImGui::GetColorU32(IM_COL32(rgb[0], rgb[1], rgb[2], 230)));
1733 }
1734 }
1735 draw_list->AddRect(PixelToScreen(selection.x, selection.y),
1736 PixelToScreen(selection.x + selection.width,
1737 selection.y + selection.height),
1738 ImGui::GetColorU32(gui::GetInfoColor()), 0.0f, 0, 2.0f);
1739}
1740
1741ImVec2 PixelEditorPanel::ScreenToPixel(ImVec2 screen_pos) {
1742 float px = (screen_pos.x - canvas_.zero_point().x) / state_->zoom_level;
1743 float py = (screen_pos.y - canvas_.zero_point().y) / state_->zoom_level;
1744 return ImVec2(px, py);
1745}
1746
1748 return ImVec2(canvas_.zero_point().x + x * state_->zoom_level,
1749 canvas_.zero_point().y + y * state_->zoom_level);
1750}
1751
1752} // namespace editor
1753} // namespace yaze
void MarkSheetModified(uint16_t sheet_id)
Mark a sheet as modified for save tracking.
std::optional< GraphicsEditContext > source_context
std::optional< uint16_t > requested_sheet_tab
void SetZoom(float zoom)
Set zoom level with clamping.
const char * GetToolName() const
Get tool name for status display.
void CloseSheet(uint16_t sheet_id)
Close a sheet tab.
const GraphicsEditTarget * SourceTarget() const
void SetTool(PixelTool tool)
Set the current editing tool.
void Draw(GraphicsEditorState &state, const Apply &apply, bool busy)
void EndSelection()
Finalize the selection.
PaletteReorderControl palette_reorder_
void DrawLine(int x1, int y1, int x2, int y2)
Draw line from start to end.
void DrawColorPicker()
Draw the color palette picker.
void ApplyEraser(int x, int y)
Apply eraser tool at position.
std::optional< zelda3::GraphicsSheetStore::Edit > stroke_
void DrawPixelInfoTooltip(const gfx::Bitmap &sheet)
Draw tooltip with pixel information.
void DrawCursorCrosshair()
Draw crosshair at cursor position.
void DrawMiniMap()
Draw the mini navigation map.
void Draw(bool *p_open) override
Draw the pixel editor UI (WindowContent interface)
void HandleCanvasInput()
Handle canvas mouse input for current tool.
ImVec2 ScreenToPixel(ImVec2 screen_pos)
Convert screen coordinates to pixel coordinates.
std::function< void(bool)> set_sheet_browser_visible_
void DrawBrushPreview()
Draw brush size preview circle.
void ApplyEyedropper(int x, int y)
Apply eyedropper tool at position.
void CopySelection()
Copy selection to clipboard.
void UpdateSelection(int x, int y)
Update selection during drag.
void FlipSelectionVertical()
Flip selection vertically.
void ApplyBrush(int x, int y)
Apply brush tool at position.
void Initialize()
Initialize the panel.
void FlipSelectionHorizontal()
Flip selection horizontally.
void DrawViewControls()
Draw zoom and view controls.
void ApplyPencil(int x, int y)
Apply pencil tool at position.
void DrawToolbar()
Draw the toolbar with tool selection.
absl::Status Update()
Legacy Update method for backward compatibility.
ImVec2 PixelToScreen(int x, int y)
Convert pixel coordinates to screen coordinates.
void BeginSelection(int x, int y)
Start a new selection.
std::function< absl::Status()> redo_
std::function< absl::Status()> undo_
void DrawStatusBar()
Draw the status bar with cursor position.
void DrawTransparencyGrid(float canvas_width, float canvas_height)
Draw checkerboard pattern for transparent pixels.
void EndStroke()
Ends the stroke: the sheet's store revision changes once and one GraphicsPixelEditAction (the changed...
void BeginStroke()
Starts a stroke on the current sheet. Pixel writes go to the session's GraphicsSheetStore until EndSt...
void DrawTileHighlight(const gfx::Bitmap &sheet)
Draw a transient highlight for a target tile.
void DrawPixelGrids(float canvas_width, float canvas_height)
void ShowStrokeChanges(bool changed)
After pixel writes: refresh the Arena copy and mark the sheet modified.
std::function< void()> draw_source_preview_
void ApplyFill(int x, int y)
Apply flood fill starting at position.
void PasteSelection(int x, int y)
Paste clipboard at position.
gfx::SnesPalette DisplayPalette() const
std::function< bool()> sheet_browser_visible_
void DrawRectangle(int x1, int y1, int x2, int y2, bool filled)
Draw rectangle from start to end.
void DrawCanvas()
Draw the main editing canvas.
PaletteReorderControl::Apply apply_palette_reorder_
zelda3::GraphicsSheetStore::Edit * StrokeEdit()
void Push(std::unique_ptr< UndoAction > action)
absl::Status Redo()
Redo the top action. Returns error if stack is empty.
absl::Status Undo()
Undo the top action. Returns error if stack is empty.
void QueueTextureCommand(TextureCommandType type, Bitmap *bitmap)
Definition arena.cc:39
auto mutable_gfx_sheets()
Get mutable reference to all graphics sheets.
Definition arena.h:206
GraphicsSheets & gfx_sheets()
Definition arena.h:178
static Arena & Get()
Definition arena.cc:24
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:70
const SnesPalette & palette() const
Definition bitmap.h:393
void Create(int width, int height, int depth, std::span< uint8_t > data)
Create a bitmap with the given dimensions and data.
Definition bitmap.cc:198
TextureHandle texture() const
Definition bitmap.h:410
const std::vector< uint8_t > & vector() const
Definition bitmap.h:411
bool is_active() const
Definition bitmap.h:414
int height() const
Definition bitmap.h:399
void set_data(const std::vector< uint8_t > &data)
Definition bitmap.cc:901
int width() const
Definition bitmap.h:398
uint8_t GetPixel(int x, int y) const
Get the palette index at the given x,y coordinates.
Definition bitmap.h:277
void SetPaletteWithTransparent(const SnesPalette &palette, size_t index, int length=7)
Set the palette with a transparent color.
Definition bitmap.cc:524
Represents a palette of colors for the Super Nintendo Entertainment System (SNES).
auto draw_list() const
Definition canvas.h:363
auto canvas_size() const
Definition canvas.h:373
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
void DrawGrid(float grid_step=64.0f, int tile_id_offset=8)
Definition canvas.cc:1620
RAII guard for ImGui style colors.
Definition style_guard.h:27
The Graphics editor's path for sheet pixel writes.
#define ICON_MD_COLORIZE
Definition icons.h:441
#define ICON_MD_INFO_OUTLINE
Definition icons.h:994
#define ICON_MD_CHECK
Definition icons.h:397
#define ICON_MD_DRAW
Definition icons.h:625
#define ICON_MD_BRUSH
Definition icons.h:325
#define ICON_MD_TUNE
Definition icons.h:2022
#define ICON_MD_ZOOM_OUT
Definition icons.h:2196
#define ICON_MD_REDO
Definition icons.h:1570
#define ICON_MD_FORMAT_COLOR_FILL
Definition icons.h:830
#define ICON_MD_MORE_HORIZ
Definition icons.h:1241
#define ICON_MD_AUTO_FIX_HIGH
Definition icons.h:218
#define ICON_MD_CONTENT_PASTE
Definition icons.h:467
#define ICON_MD_PAN_TOOL
Definition icons.h:1371
#define ICON_MD_CROP_SQUARE
Definition icons.h:500
#define ICON_MD_HORIZONTAL_RULE
Definition icons.h:960
#define ICON_MD_ZOOM_IN
Definition icons.h:2194
#define ICON_MD_SELECT_ALL
Definition icons.h:1680
#define ICON_MD_CONTENT_COPY
Definition icons.h:465
#define ICON_MD_CLOSE
Definition icons.h:418
#define ICON_MD_UNDO
Definition icons.h:2039
PNG images on the system clipboard. SDL's clipboard is text-only.
#define HOVER_HINT(string)
Definition macro.h:24
bool PixelIconButton(const char *label, float button_size)
zelda3::GraphicsSheetStore * ActiveSheetStore(const GraphicsEditorState &state)
absl::StatusOr< ClipboardPaste > MapClipboardPng(const std::vector< uint8_t > &png_bytes, const SheetColors &colors, int max_width, int max_height)
std::array< std::array< uint8_t, 3 >, 8 > SheetColors
The 8 colors a 3bpp sheet shows (index 0 is transparent in game).
void DrawGraphicsPreviewPaletteSelector(GraphicsEditorState &state, GraphicsPaletteSelectorLayout layout)
void RefreshArenaSheet(GraphicsEditorState &state, uint16_t sheet)
absl::StatusOr< std::vector< uint8_t > > EncodeClipboardPng(const std::vector< uint8_t > &indices, int width, int height, const SheetColors &colors)
std::string DescribeClipboardPaste(const ClipboardPaste &paste)
"16x16: 200 kept" / "16x8: 90 exact, 38 remapped, 4 above color 7".
PixelTool
Pixel editing tool types for the graphics editor.
SheetPixelDiff MakeSheetPixelDiff(uint16_t sheet, absl::Span< const uint8_t > before, absl::Span< const uint8_t > after)
The pixels that differ between two 4096-byte sheets.
void AutoRegisterLastItem(const std::string &widget_type, const std::string &explicit_label, const std::string &description)
Automatically register the last ImGui item.
bool BeginThemedTabBar(const char *id, ImGuiTabBarFlags flags)
A stylized tab bar with "Mission Control" branding.
ImVec4 GetSelectedColor()
Definition ui_helpers.cc:99
void EndThemedTabBar()
ImVec4 GetPrimaryVec4()
ImVec4 ConvertSnesColorToImVec4(const gfx::SnesColor &color)
Convert SnesColor to standard ImVec4 for display.
Definition color.cc:24
ImVec4 GetErrorColor()
Definition ui_helpers.cc:59
ImVec4 GetInfoColor()
Definition ui_helpers.cc:64
bool ToolbarIconButton(const char *icon, const char *tooltip, bool is_active)
Convenience wrapper for toolbar-sized icon buttons.
SDL_Palette * GetSurfacePalette(SDL_Surface *surface)
Get the palette attached to a surface.
Definition sdl_compat.h:398
absl::StatusOr< std::vector< uint8_t > > GetClipboardPng(const std::string &)
bool ImageClipboardSupported()
absl::Status SetClipboardPng(const std::vector< uint8_t > &, const std::string &)
Replaces the clipboard contents with one PNG image.
SDL2/SDL3 compatibility layer.
std::vector< uint8_t > pixel_data
The pixels one undo step changes in one sheet.
Automatic widget registration helpers for ImGui Test Engine integration.