yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
welcome_screen.cc
Go to the documentation of this file.
1#define IMGUI_DEFINE_MATH_OPERATORS
2
4#include "util/i18n/tr.h"
5
6#include <algorithm>
7#include <cmath>
8#include <cstdint>
9#include <string>
10
11#include "absl/strings/str_format.h"
12#include "absl/time/clock.h"
13#include "absl/time/time.h"
15#include "app/gui/core/icons.h"
16#include "app/gui/core/input.h"
21#include "app/platform/timing.h"
22#include "imgui/imgui.h"
23#include "imgui/imgui_internal.h"
24#include "util/file_util.h"
25
26#ifndef M_PI
27#define M_PI 3.14159265358979323846
28#endif
29
30namespace yaze {
31namespace editor {
32
33namespace {
34
35// Zelda-inspired color palette (fallbacks)
36const ImVec4 kTriforceGoldFallback = ImVec4(1.0f, 0.843f, 0.0f, 1.0f);
37const ImVec4 kHyruleGreenFallback = ImVec4(0.133f, 0.545f, 0.133f, 1.0f);
38const ImVec4 kMasterSwordBlueFallback = ImVec4(0.196f, 0.6f, 0.8f, 1.0f);
39const ImVec4 kHeartRedFallback = ImVec4(0.863f, 0.078f, 0.235f, 1.0f);
40
41// Compact recent rows with a fixed comfortable height. The welcome card itself
42// stays a GIMP-style start dialog — it does not stretch to fill the dockspace.
43constexpr float kRecentRowBaseHeight = 52.0f;
44constexpr float kWelcomeSplitMinWidth = 800.0f;
45constexpr float kWelcomeCardMaxWidth = 960.0f;
46constexpr float kWelcomeCardMaxHeight = 580.0f;
47
48// Active colors (updated each frame from theme)
53
55 auto& theme_mgr = gui::ThemeManager::Get();
56 // Skip the palette recompute when the active theme hasn't changed. The
57 // The welcome screen previously recomputed its accent palette every frame;
58 // skip that work while the active theme is unchanged.
59 static std::string s_cached_theme_name;
60 static uint64_t s_cached_signature = 0;
61 static bool s_cached_once = false;
62 const std::string& current_name = theme_mgr.GetCurrentThemeName();
63 const auto& theme = theme_mgr.GetCurrentTheme();
64 // Key on the colors themselves, not just the name: applying a custom
65 // accent, saving over the current theme, or ending a preview all change
66 // the palette while the name stays put, which used to leave these brand
67 // colors stale until the next rename or restart.
68 const auto pack = [](const auto& color) -> uint64_t {
69 const ImVec4 v = gui::ConvertColorToImVec4(color);
70 return (static_cast<uint64_t>(v.x * 255.0f) << 16) |
71 (static_cast<uint64_t>(v.y * 255.0f) << 8) |
72 static_cast<uint64_t>(v.z * 255.0f);
73 };
74 const uint64_t signature = pack(theme.accent) ^ (pack(theme.warning) << 1) ^
75 (pack(theme.success) << 2) ^
76 (pack(theme.info) << 3) ^ (pack(theme.error) << 4);
77 if (s_cached_once && current_name == s_cached_theme_name &&
78 signature == s_cached_signature) {
79 return;
80 }
81 s_cached_theme_name = current_name;
82 s_cached_signature = signature;
83 s_cached_once = true;
84
85 const ImVec4 accent = gui::ConvertColorToImVec4(theme.accent);
86 const ImVec4 warning = gui::ConvertColorToImVec4(theme.warning);
87 const ImVec4 success = gui::ConvertColorToImVec4(theme.success);
88 const ImVec4 info = gui::ConvertColorToImVec4(theme.info);
89 const ImVec4 error = gui::ConvertColorToImVec4(theme.error);
90
91 // Welcome accent palette: themed, but with distinct flavor per role.
92 // Anchor the brand gold on `warning`, which every shipped theme sets to an
93 // amber. Averaging it halfway with `accent` used to hue-blend into olive
94 // whenever the accent was cool: Wind Waker's teal accent produced a sage
95 // green at 2.4:1 against its cream background, below the 3:1 floor even for
96 // large text. A light touch of accent keeps the theme's identity without
97 // letting it drag the hue off gold. This colour also drives every section
98 // heading and the accent rule, so the drift was never just the wordmark.
99 kTriforceGold = ImLerp(warning, accent, 0.15f);
100 kHyruleGreen = success;
101 kMasterSwordBlue = info;
102 kHeartRed = error;
103}
104
105// Section headings share one colour so Start, Recent, and the first-run hint
106// read as peers rather than three competing accents.
108 return kTriforceGold;
109}
110
111// A single low-contrast rule, themed from the same accent as the headings.
112void DrawAccentRule(ImDrawList* draw_list) {
113 const ImVec2 start = ImGui::GetCursorScreenPos();
114 const ImVec2 end(start.x + ImGui::GetContentRegionAvail().x, start.y + 1.0f);
115 ImVec4 rule = SectionHeadingColor();
116 rule.w = 0.22f;
117 draw_list->AddRectFilled(start, end, ImGui::GetColorU32(rule));
118}
119
120// Truncate `text` to fit within `max_width` pixels, appending "..." if clipped.
121// Uses binary search over byte positions; CalcTextSize is invoked at most
122// log2(N) times per call instead of the previous O(N) pop_back loop that
123// re-measured the string after every character removal.
124std::string EllipsizeText(const std::string& text, float max_width) {
125 if (text.empty())
126 return std::string();
127 if (ImGui::CalcTextSize(text.c_str()).x <= max_width)
128 return text;
129
130 static constexpr const char* kEllipsis = "...";
131 const float ellipsis_w = ImGui::CalcTextSize(kEllipsis).x;
132 if (ellipsis_w > max_width)
133 return std::string(kEllipsis);
134
135 const float budget = max_width - ellipsis_w;
136
137 // Binary search the longest byte-prefix whose width is <= budget.
138 // Note: this splits by bytes, not code points; for ASCII-only titles (the
139 // common case here) that is exact. For UTF-8 multi-byte sequences we nudge
140 // the split point back to a code-point boundary after the search.
141 size_t lo = 0;
142 size_t hi = text.size();
143 std::string buffer;
144 buffer.reserve(text.size());
145 while (lo < hi) {
146 size_t mid = lo + (hi - lo + 1) / 2;
147 buffer.assign(text, 0, mid);
148 if (ImGui::CalcTextSize(buffer.c_str()).x <= budget) {
149 lo = mid;
150 } else {
151 hi = mid - 1;
152 }
153 }
154
155 // Pull back off any UTF-8 continuation bytes so we don't split a codepoint.
156 while (lo > 0 && (static_cast<unsigned char>(text[lo]) & 0xC0) == 0x80) {
157 --lo;
158 }
159
160 if (lo == 0)
161 return std::string(kEllipsis);
162 buffer.assign(text, 0, lo);
163 buffer.append(kEllipsis);
164 return buffer;
165}
166
167// Draw a pixelated triforce in the background (ALTTP style)
168void DrawTriforceBackground(ImDrawList* draw_list, ImVec2 pos, float size,
169 float alpha, float glow) {
170 // Make it pixelated - round size to nearest 4 pixels
171 size = std::round(size / 4.0f) * 4.0f;
172
173 // Calculate triangle points with pixel-perfect positioning
174 auto triangle = [&](ImVec2 center, float s, ImU32 color) {
175 // Round to pixel boundaries for crisp edges
176 float half_s = s / 2.0f;
177 float tri_h = s * 0.866f; // Height of equilateral triangle
178
179 // Fixed: Proper equilateral triangle with apex at top
180 ImVec2 p1(std::round(center.x),
181 std::round(center.y - tri_h / 2.0f)); // Top apex
182 ImVec2 p2(std::round(center.x - half_s),
183 std::round(center.y + tri_h / 2.0f)); // Bottom left
184 ImVec2 p3(std::round(center.x + half_s),
185 std::round(center.y + tri_h / 2.0f)); // Bottom right
186
187 draw_list->AddTriangleFilled(p1, p2, p3, color);
188 };
189
190 ImVec4 gold_color = kTriforceGold;
191 gold_color.w = alpha;
192 ImU32 gold = ImGui::GetColorU32(gold_color);
193
194 // Proper triforce layout with three triangles
195 float small_size = size / 2.0f;
196 float small_height = small_size * 0.866f;
197
198 // Top triangle (centered above)
199 triangle(ImVec2(pos.x, pos.y), small_size, gold);
200
201 // Bottom left triangle
202 triangle(ImVec2(pos.x - small_size / 2.0f, pos.y + small_height), small_size,
203 gold);
204
205 // Bottom right triangle
206 triangle(ImVec2(pos.x + small_size / 2.0f, pos.y + small_height), small_size,
207 gold);
208}
209
210} // namespace
211
215
217 if (!settings)
218 return;
219 const auto& prefs = settings->prefs();
221 triforce_speed_multiplier_ = prefs.welcome_triforce_speed;
222 triforce_size_multiplier_ = prefs.welcome_triforce_size;
223 particles_enabled_ = prefs.welcome_particles_enabled;
224 triforce_mouse_repel_enabled_ = prefs.welcome_mouse_repel_enabled;
225}
226
228 float content_height,
229 float layout_scale) {
230 // Card and pane minimums scale with the active font. Scale the breakpoint
231 // from the same source so accessibility-sized text cannot force the wide
232 // layout into a space that only fits it at the default font size.
233 const float safe_scale = std::max(layout_scale, 0.01f);
234 // Width only. Split needs max(left, right) of vertical space; stacked needs
235 // their sum, so falling back to stacked when height is short chose the
236 // layout that needs more of the scarce axis. Width is the real constraint:
237 // two columns genuinely cannot fit in a narrow card.
238 (void)content_height;
239 return content_width < kWelcomeSplitMinWidth * safe_scale;
240}
241
242// Helper function to calculate staggered animation progress
243float GetStaggeredEntryProgress(float entry_time, int section_index,
244 float duration, float stagger_delay) {
245 float section_start = section_index * stagger_delay;
246 float section_time = entry_time - section_start;
247 if (section_time < 0.0f) {
248 return 0.0f;
249 }
250 float progress = std::min(section_time / duration, 1.0f);
251 // Use EaseOutCubic for smooth deceleration
252 float inv = 1.0f - progress;
253 return 1.0f - (inv * inv * inv);
254}
255
256bool WelcomeScreen::Show(bool* p_open) {
257 // Update theme colors each frame
258 UpdateWelcomeAccentPalette();
259
260 // Update entry animation time
262 entry_time_ = 0.0f;
264 }
265 entry_time_ += ImGui::GetIO().DeltaTime;
266
268
269 // Get mouse position for interactive triforce movement
270 ImVec2 mouse_pos = ImGui::GetMousePos();
271
272 bool action_taken = false;
273
274 // Center the window within the dockspace region (accounting for sidebars)
275 ImGuiViewport* viewport = ImGui::GetMainViewport();
276 ImVec2 viewport_size = viewport->WorkSize;
277
278 // Calculate the dockspace region (excluding sidebars)
279 float dockspace_x = viewport->WorkPos.x + left_offset_;
280 float dockspace_width = viewport_size.x - left_offset_ - right_offset_;
281 if (dockspace_width < 200.0f) {
282 dockspace_x = viewport->WorkPos.x;
283 dockspace_width = viewport_size.x;
284 }
285 float dockspace_center_x = dockspace_x + dockspace_width / 2.0f;
286 float dockspace_center_y = viewport->WorkPos.y + viewport_size.y / 2.0f;
287 ImVec2 center(dockspace_center_x, dockspace_center_y);
288
289 // Compact start card centered in the dockspace. Leave the surrounding canvas
290 // visible so this reads like a launcher, not a stretched full-bleed pane.
291 const float font_scale = ImGui::GetFontSize() / 16.0f;
292 float width = std::clamp(dockspace_width * 0.70f, 560.0f * font_scale,
293 kWelcomeCardMaxWidth * font_scale);
294 float height = std::clamp(viewport_size.y * 0.68f, 400.0f * font_scale,
295 kWelcomeCardMaxHeight * font_scale);
296 // Treat the viewport as a hard ceiling. The preferred card minimums above
297 // yield to a cramped browser/WASM surface instead of pushing the window
298 // off-screen.
299 width = std::min(width, std::max(1.0f, dockspace_width - 24.0f));
300 height = std::min(height, std::max(1.0f, viewport_size.y - 24.0f));
301
302 ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f));
303 ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always);
304
305 // Window flags: allow menu bar to be clickable by not bringing to front
306 ImGuiWindowFlags window_flags =
307 ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
308 ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus |
309 ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings;
310
311 // Roomier than the ambient padding, because this is a centred card rather
312 // than a docked panel — but derived from it, so Compact and Comfortable
313 // still move it. At Normal (8,8) this reproduces the previous literal
314 // (16,14) exactly.
315 const ImVec2 ambient_padding = ImGui::GetStyle().WindowPadding;
316 bool window_visible = false;
317 {
318 gui::StyleVarGuard window_padding_guard(
319 ImGuiStyleVar_WindowPadding,
320 ImVec2(ambient_padding.x + 8.0f, ambient_padding.y + 6.0f));
321 // Scoped to Begin alone. ImGui caches the value into window->WindowPadding
322 // there, so the card keeps it for the frame — while every tooltip and
323 // context menu opened below (ImGui does NOT zero padding for popups) goes
324 // back to the theme's own padding instead of inheriting the card's.
325 window_visible = ImGui::Begin("##WelcomeScreen", p_open, window_flags);
326 }
327
328 if (window_visible) {
329 // Esc dismisses the welcome screen when it (or one of its children) has
330 // focus. Avoids stealing Esc globally, which would conflict with other
331 // editors that use it for their own "cancel current interaction" flow.
332 if (p_open != nullptr &&
333 ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) &&
334 ImGui::IsKeyPressed(ImGuiKey_Escape, /*repeat=*/false)) {
335 *p_open = false;
336 }
337
338 ImDrawList* bg_draw_list = ImGui::GetWindowDrawList();
339 ImVec2 window_pos = ImGui::GetWindowPos();
340 ImVec2 window_size = ImGui::GetWindowSize();
341
342 // Interactive scattered triforces (react to mouse position)
343 struct TriforceConfig {
344 float x_pct, y_pct; // Base position (percentage of window)
345 float size;
346 float alpha;
347 float repel_distance; // How far they move away from mouse
348 };
349
350 TriforceConfig triforce_configs[] = {
351 {0.08f, 0.12f, 36.0f, 0.025f, 50.0f}, // Top left corner
352 {0.92f, 0.15f, 34.0f, 0.022f, 50.0f}, // Top right corner
353 {0.06f, 0.88f, 32.0f, 0.020f, 45.0f}, // Bottom left
354 {0.94f, 0.85f, 34.0f, 0.023f, 50.0f}, // Bottom right
355 {0.50f, 0.08f, 38.0f, 0.028f, 55.0f}, // Top center
356 {0.50f, 0.92f, 32.0f, 0.020f, 45.0f}, // Bottom center
357 };
358
359 // Initialize base positions on first frame
361 for (int i = 0; i < kNumTriforces; ++i) {
362 float x = window_pos.x + window_size.x * triforce_configs[i].x_pct;
363 float y = window_pos.y + window_size.y * triforce_configs[i].y_pct;
364 triforce_base_positions_[i] = ImVec2(x, y);
366 }
368 }
369
370 // Skip the triforce background entirely when the user has faded it out.
371 // The alpha_multiplier slider at 0 should mean "no work at all", not
372 // "compute positions and draw transparent triangles".
373 const bool triforces_visible = triforce_alpha_multiplier_ > 0.001f;
374
375 // Update triforce positions based on mouse interaction + floating animation
376 for (int i = 0; triforces_visible && i < kNumTriforces; ++i) {
377 // Update base position in case window moved/resized
378 float base_x = window_pos.x + window_size.x * triforce_configs[i].x_pct;
379 float base_y = window_pos.y + window_size.y * triforce_configs[i].y_pct;
380 triforce_base_positions_[i] = ImVec2(base_x, base_y);
381
382 // Slow, subtle floating animation
383 float time_offset = i * 1.2f; // Offset each triforce's animation
384 float float_speed_x =
385 (0.15f + (i % 2) * 0.1f) * triforce_speed_multiplier_; // Very slow
386 float float_speed_y =
387 (0.12f + ((i + 1) % 2) * 0.08f) * triforce_speed_multiplier_;
388 float float_amount_x = (20.0f + (i % 2) * 10.0f) *
389 triforce_size_multiplier_; // Smaller amplitude
390 float float_amount_y =
391 (25.0f + ((i + 1) % 2) * 15.0f) * triforce_size_multiplier_;
392
393 // Create gentle orbital motion
394 float float_x = std::sin(animation_time_ * float_speed_x + time_offset) *
395 float_amount_x;
396 float float_y =
397 std::cos(animation_time_ * float_speed_y + time_offset * 1.2f) *
398 float_amount_y;
399
400 // Calculate distance from mouse
401 float dx = triforce_base_positions_[i].x - mouse_pos.x;
402 float dy = triforce_base_positions_[i].y - mouse_pos.y;
403 float dist = std::sqrt(dx * dx + dy * dy);
404
405 // Calculate repulsion offset with stronger effect
406 ImVec2 target_pos = triforce_base_positions_[i];
407 float repel_radius =
408 200.0f; // Larger radius for more visible interaction
409
410 // Add floating motion to base position
411 target_pos.x += float_x;
412 target_pos.y += float_y;
413
414 // Apply mouse repulsion if enabled
415 if (triforce_mouse_repel_enabled_ && dist < repel_radius && dist > 0.1f) {
416 // Normalize direction away from mouse
417 float dir_x = dx / dist;
418 float dir_y = dy / dist;
419
420 // Much stronger repulsion when closer with exponential falloff
421 float normalized_dist = dist / repel_radius;
422 float repel_strength = (1.0f - normalized_dist * normalized_dist) *
423 triforce_configs[i].repel_distance;
424
425 target_pos.x += dir_x * repel_strength;
426 target_pos.y += dir_y * repel_strength;
427 }
428
429 // Smooth interpolation to target position (faster response)
430 // Use TimingManager for accurate delta time
431 float lerp_speed = 8.0f * yaze::TimingManager::Get().GetDeltaTime();
432 triforce_positions_[i].x +=
433 (target_pos.x - triforce_positions_[i].x) * lerp_speed;
434 triforce_positions_[i].y +=
435 (target_pos.y - triforce_positions_[i].y) * lerp_speed;
436
437 // Draw at current position with alpha multiplier. Skip issuing draw
438 // commands for alphas that would quantize to 0 in an 8-bit color.
439 float adjusted_alpha =
440 triforce_configs[i].alpha * triforce_alpha_multiplier_;
441 if (adjusted_alpha < (1.0f / 255.0f)) {
442 continue;
443 }
444 float adjusted_size =
445 triforce_configs[i].size * triforce_size_multiplier_;
446 DrawTriforceBackground(bg_draw_list, triforce_positions_[i],
447 adjusted_size, adjusted_alpha, 0.0f);
448 }
449
450 // Update and draw particle system. Also skipped when the triforce alpha
451 // multiplier is 0, because particles inherit that alpha — drawing them
452 // invisibly is pure overhead.
453 if (particles_enabled_ && triforces_visible) {
454 // Spawn new particles
456 ImGui::GetIO().DeltaTime * particle_spawn_rate_;
457 while (particle_spawn_accumulator_ >= 1.0f &&
459 // Find inactive particle slot
460 for (int i = 0; i < kMaxParticles; ++i) {
461 if (particles_[i].lifetime <= 0.0f) {
462 // Spawn from random triforce
463 int source_triforce = rand() % kNumTriforces;
464 particles_[i].position = triforce_positions_[source_triforce];
465
466 // Random direction and speed
467 float angle = (rand() % 360) * (M_PI / 180.0f);
468 float speed = 20.0f + (rand() % 40);
470 ImVec2(std::cos(angle) * speed, std::sin(angle) * speed);
471
472 particles_[i].size = 2.0f + (rand() % 4);
473 particles_[i].alpha = 0.4f + (rand() % 40) / 100.0f;
474 particles_[i].max_lifetime = 2.0f + (rand() % 30) / 10.0f;
477 break;
478 }
479 }
481 }
482
483 // Update and draw particles
484 float dt = ImGui::GetIO().DeltaTime;
485 for (int i = 0; i < kMaxParticles; ++i) {
486 if (particles_[i].lifetime > 0.0f) {
487 // Update lifetime
488 particles_[i].lifetime -= dt;
489 if (particles_[i].lifetime <= 0.0f) {
491 continue;
492 }
493
494 // Update position
495 particles_[i].position.x += particles_[i].velocity.x * dt;
496 particles_[i].position.y += particles_[i].velocity.y * dt;
497
498 // Fade out near end of life
499 float life_ratio =
501 float alpha =
503
504 // Draw particle as small golden circle
505 ImU32 particle_color = ImGui::GetColorU32(
506 ImVec4(kTriforceGold.x, kTriforceGold.y, kTriforceGold.z, alpha));
507 bg_draw_list->AddCircleFilled(particles_[i].position,
508 particles_[i].size, particle_color, 8);
509 }
510 }
511 }
512
513 DrawHeader();
514
515 ImGui::Spacing();
516
517 // One quiet accent rule under the brand. A multi-hue gradient competed
518 // with every theme's own palette and read as decoration, not structure.
519 ImDrawList* draw_list = ImGui::GetWindowDrawList();
520 DrawAccentRule(draw_list);
521
522 ImGui::Dummy(ImVec2(0, ImGui::GetStyle().ItemSpacing.y));
523
524 // Reserve the footer from the active font and theme, not a fixed pixel
525 // height. The content pane itself never scrolls — recents are capped to
526 // what fits so the whole welcome surface stays readable at a glance.
527 const float footer_gap = ImGui::GetStyle().ItemSpacing.y;
528 const float footer_height = ImGui::GetFrameHeight() + 3.0f * footer_gap;
529 ImGui::BeginChild(
530 "WelcomeContent", ImVec2(0, -footer_height), false,
531 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
532 const float content_width = ImGui::GetContentRegionAvail().x;
533 const float content_height = ImGui::GetContentRegionAvail().y;
534 const float layout_scale = ImGui::GetFontSize() / 16.0f;
535 const bool stacked_layout =
536 ShouldUseStackedLayout(content_width, content_height, layout_scale);
537
538 if (stacked_layout) {
541 ImGui::Spacing();
542 ImGui::Separator();
543 ImGui::Spacing();
545 } else {
546 // Fixed action rail — comfortable width, not a percentage of a huge pane.
547 float left_width =
548 std::clamp(280.0f * layout_scale, 260.0f * layout_scale,
549 std::min(320.0f * layout_scale, content_width * 0.42f));
550 ImGui::BeginChild(
551 "LeftPanel", ImVec2(left_width, 0), false,
552 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
555 ImGui::EndChild();
556
557 ImGui::SameLine(0.0f, 16.0f);
558
559 ImGui::BeginChild(
560 "RightPanel", ImVec2(0, 0), false,
561 ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
563 ImGui::EndChild();
564 }
565
566 ImGui::EndChild();
567
568 DrawAccentRule(draw_list);
569 ImGui::Dummy(ImVec2(0, footer_gap));
571 }
572 ImGui::End();
573
574 return action_taken;
575}
576
578 animation_time_ += ImGui::GetIO().DeltaTime;
579
580 // Note: Triforce positions and particles are updated in Show() based on mouse
581 // position
582}
583
587
589 ImDrawList* draw_list = ImGui::GetWindowDrawList();
590
591 // Entry animation for header (section 0)
592 float header_progress = GetStaggeredEntryProgress(
594 float header_alpha = header_progress;
595 float header_offset_y = (1.0f - header_progress) * 20.0f;
596
597 if (header_progress < 0.001f) {
598 // Reserve what the real header will occupy: scaled title line, subtitle
599 // line, and the gap between them. A flat 48 popped at large font sizes.
600 const float title_line = ImGui::GetTextLineHeight() * 2.0f;
601 ImGui::Dummy(ImVec2(0, title_line + ImGui::GetTextLineHeightWithSpacing()));
602 return;
603 }
604
605 // Scale the current face instead of indexing the atlas. Every font in the
606 // registry is loaded at the same size and the icon/Japanese merges do not
607 // add entries, so the old Fonts[2] was Cousine — a monospace code face at
608 // body size. The wordmark had no size treatment at all, only a typeface
609 // change nobody asked for.
610 constexpr float kTitleFontScale = 2.0f;
611 // FontSizeBase starts at 0 and is resolved on the first frame; PushFont
612 // reads 0 as "keep current size", which would silently drop the scale.
613 const float base_font_size = ImGui::GetStyle().FontSizeBase > 0.0f
614 ? ImGui::GetStyle().FontSizeBase
615 : ImGui::GetFontSize();
616 ImGui::PushFont(nullptr, base_font_size * kTitleFontScale);
617
618 // Simple centered title
619 const char* title = ICON_MD_CASTLE " yaze";
620 const float window_width = ImGui::GetWindowSize().x;
621 const float title_width = ImGui::CalcTextSize(title).x;
622 const float xPos = (window_width - title_width) * 0.5f;
623
624 // Apply entry offset
625 ImVec2 cursor_pos = ImGui::GetCursorPos();
626 ImGui::SetCursorPos(ImVec2(xPos, cursor_pos.y - header_offset_y));
627 ImVec2 text_pos = ImGui::GetCursorScreenPos();
628
629 // Halo behind the wordmark. Two things it has to get right:
630 //
631 // Size — it used a flat 30px radius at a flat +15px offset, which was tuned
632 // for a body-sized title. The title is now scaled, so both track the real
633 // text metrics instead.
634 //
635 // Direction — a low-alpha warm disc is a *lightening* effect. On the five
636 // light presets (Wind Waker's background is 250,245,232) it blends into an
637 // already-pale ground and simply is not there. A single "add light"
638 // strategy cannot work in both directions, so on a light ground deepen the
639 // gold toward amber and carry more alpha, giving a soft warm shadow rather
640 // than an invisible highlight.
641 const float title_height = ImGui::GetTextLineHeight();
642 const ImVec4 window_bg = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg);
643 const float bg_luma =
644 0.299f * window_bg.x + 0.587f * window_bg.y + 0.114f * window_bg.z;
645 const bool light_ground = bg_luma > 0.5f;
646
647 ImVec4 glow = kTriforceGold;
648 float glow_alpha = 0.15f;
649 if (light_ground) {
650 glow.x *= 0.65f;
651 glow.y *= 0.50f;
652 glow.z *= 0.30f;
653 glow_alpha = 0.22f;
654 }
655 glow.w = glow_alpha * header_alpha;
656 const float glow_radius = title_height * 0.9f;
657 draw_list->AddCircleFilled(
658 ImVec2(text_pos.x + title_width * 0.5f, text_pos.y + title_height * 0.5f),
659 glow_radius, ImGui::GetColorU32(glow), 32);
660
661 // Simple gold color for title with entry alpha
662 ImVec4 title_color = kTriforceGold;
663 title_color.w *= header_alpha;
664 ImGui::TextColored(title_color, "%s", title);
665 ImGui::PopFont();
666
667 // Static subtitle (entry animation section 1)
668 float subtitle_progress = GetStaggeredEntryProgress(
670 float subtitle_alpha = subtitle_progress;
671 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
672
673 const char* subtitle = "Yet Another Zelda3 Editor";
674 const float subtitle_width = ImGui::CalcTextSize(subtitle).x;
675 ImGui::SetCursorPosX((window_width - subtitle_width) * 0.5f);
676
677 ImGui::TextColored(
678 ImVec4(text_secondary.x, text_secondary.y, text_secondary.z,
679 text_secondary.w * subtitle_alpha),
680 "%s", subtitle);
681}
682
684 // Keep the empty-state guidance compact so the primary action stays visible.
685 if (!recent_projects_model_.entries().empty() || has_rom_)
686 return;
687
688 const float layout_scale = ImGui::GetFontSize() / 16.0f;
689 if (ImGui::GetContentRegionAvail().y < 300.0f * layout_scale) {
690 return;
691 }
692
693 // Entry animation piggybacks on the quick actions section.
696 if (progress < 0.001f)
697 return;
698 gui::StyleVarGuard alpha_guard(ImGuiStyleVar_Alpha, progress);
699
700 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
701 ImGui::TextColored(SectionHeadingColor(), ICON_MD_AUTO_AWESOME " New here?");
702 ImGui::SameLine();
703 {
704 gui::StyleColorGuard text_guard(ImGuiCol_Text, text_secondary);
705 ImGui::TextWrapped(
706 tr("Open a clean .sfc or .smc ROM. Changes are not written until you "
707 "choose Save."));
708 }
709}
710
712 // Entry animation for quick actions (section 2)
713 float actions_progress = GetStaggeredEntryProgress(
715 float actions_alpha = actions_progress;
716 float actions_offset_x =
717 (1.0f - actions_progress) * -30.0f; // Slide from left
718
719 if (actions_progress < 0.001f) {
720 return; // Don't draw yet
721 }
722
723 gui::StyleVarGuard alpha_guard(ImGuiStyleVar_Alpha, actions_alpha);
724
725 // Apply horizontal offset for slide effect
726 float indent = std::max(0.0f, -actions_offset_x);
727 if (indent > 0.0f) {
728 ImGui::Indent(indent);
729 }
730
731 ImGui::TextColored(SectionHeadingColor(), ICON_MD_BOLT " Start");
732 ImGui::Spacing();
733
734 const float scale = ImGui::GetFontSize() / 16.0f;
735 // Derive from GetFrameHeight (font size + 2*FramePadding.y) so these track
736 // the Display Density preset, which scales FramePadding/ItemSpacing but
737 // leaves GetFontSize alone. The minimum-hit-target floors have to scale with
738 // density too: at the shipped 16px font GetFrameHeight is 20.5/22/23.5 for
739 // Compact/Normal/Comfortable, so a flat 34px floor clamped Compact and
740 // Normal to the identical height and the density setting did nothing — the
741 // very bug this is meant to fix. The 1.2:1 primary:secondary ratio is the
742 // deliberate hierarchy and is preserved.
743 const float density =
744 std::max(0.1f, gui::ThemeManager::Get().GetCurrentTheme().compact_factor);
745 const float frame_height = ImGui::GetFrameHeight();
746 const float button_height = std::max(34.0f * density, frame_height * 1.5f);
747 const float secondary_height =
748 std::max(28.0f * density, frame_height * 1.25f);
749 const float action_width = ImGui::GetContentRegionAvail().x;
750 float button_width = action_width;
751
752 // Budget the optional rows. This pane has NoScrollbar|NoScrollWithMouse, so
753 // anything that does not fit is clipped with no scrollbar and no hint — and
754 // because Recent is drawn after this, overflowing here silently swallows
755 // the entire recents list. Shed the lowest-priority rows first so Open and
756 // New Project, the two reasons this screen exists, always survive.
757 const float gap = ImGui::GetStyle().ItemSpacing.y;
758 const float heading_h = ImGui::GetTextLineHeightWithSpacing();
759 const float required_primary =
760 heading_h + gap + button_height + gap + button_height;
761 float budget = ImGui::GetContentRegionAvail().y - required_primary;
762 const float resume_cost = gap + secondary_height;
763 const float no_rom_label_cost = gap + heading_h;
764 const float no_rom_button_cost = gap + secondary_height;
765
766 bool show_resume = budget >= resume_cost;
767 if (show_resume) {
768 budget -= resume_cost;
769 }
770 const int no_rom_count = (open_prototype_research_callback_ ? 1 : 0) +
772 bool show_no_rom =
773 no_rom_count > 0 &&
774 budget >= no_rom_label_cost + no_rom_count * no_rom_button_cost;
775
776 // The browser upload path accepts ROMs only; desktop uses the combined picker.
777#ifdef __EMSCRIPTEN__
778 constexpr const char* open_label = ICON_MD_FOLDER_OPEN " Open ROM";
779 constexpr const char* open_tooltip = ICON_MD_INFO " Open .sfc/.smc ROMs";
780#else
781 constexpr const char* open_label = ICON_MD_FOLDER_OPEN " Open ROM / Project";
782 constexpr const char* open_tooltip =
783 ICON_MD_INFO " Open .sfc/.smc ROMs and .yaze/.yazeproj project files";
784#endif
785 if (gui::PrimaryButton(open_label, ImVec2(button_width, button_height),
786 "welcome_screen", "open_rom_or_project") &&
789 }
790 if (ImGui::IsItemHovered()) {
791 ImGui::SetTooltip("%s", open_tooltip);
792 }
793
794 ImGui::Spacing();
795
796 if (gui::ThemedButton(ICON_MD_ADD_CIRCLE " New Project",
797 ImVec2(button_width, button_height), "welcome_screen",
798 "new_project") &&
801 }
802 if (ImGui::IsItemHovered()) {
803 ImGui::SetTooltip(
805 " Create a new project for metadata, labels, and workflow settings");
806 }
807
808 // Secondary starts live in the open — no nested "More ways" menu.
809 const RecentProject* last_recent =
811 : nullptr;
812 if (last_recent && open_project_callback_) {
813 ImGui::Spacing();
814 const std::string resume_label = absl::StrFormat(
815 "%s Resume %s", ICON_MD_PLAY_ARROW, last_recent->name.c_str());
816 const std::string resume_path = last_recent->filepath;
817 if (gui::ThemedButton(resume_label.c_str(),
818 ImVec2(button_width, secondary_height),
819 "welcome_screen", "resume_recent")) {
820 open_project_callback_(resume_path);
821 }
822 if (ImGui::IsItemHovered()) {
823 ImGui::SetTooltip("%s", resume_path.c_str());
824 }
825 }
826
827 if (show_no_rom) {
828 ImGui::Spacing();
829 ImGui::TextColored(gui::GetTextSecondaryVec4(), "%s", tr("Without a ROM"));
830 }
831
832 const bool has_both_secondary = show_no_rom &&
835 const bool inline_secondary =
836 has_both_secondary && action_width >= 420.0f * scale;
837 const float secondary_width =
838 inline_secondary ? (button_width - ImGui::GetStyle().ItemSpacing.x) * 0.5f
839 : button_width;
840
841 if (show_no_rom && open_prototype_research_callback_) {
842 ImGui::Spacing();
843 if (gui::ThemedButton(ICON_MD_CONSTRUCTION " Prototype Research",
844 ImVec2(secondary_width, secondary_height),
845 "welcome_screen", "prototype_research")) {
847 }
848 if (ImGui::IsItemHovered()) {
849 ImGui::SetTooltip(
851 " Open the Graphics editor for CGX, SCR, COL, BIN, and clipboard "
852 "work without loading a ROM");
853 }
854 if (inline_secondary) {
855 ImGui::SameLine();
856 }
857 }
858
859 if (show_no_rom && open_assembly_editor_no_rom_callback_) {
860 if (!inline_secondary) {
861 ImGui::Spacing();
862 }
863 if (gui::ThemedButton(ICON_MD_CODE " Assembly Editor",
864 ImVec2(secondary_width, secondary_height),
865 "welcome_screen", "assembly_editor")) {
867 }
868 if (ImGui::IsItemHovered()) {
869 ImGui::SetTooltip(
871 " Open files or a folder for assembly work without loading a ROM");
872 }
873 }
874
875 // Clean up entry animation styles
876 if (indent > 0.0f) {
877 ImGui::Unindent(indent);
878 }
879}
880
882 const std::vector<RecentProject>& entries) {
883 const RecentProject* most_recent = nullptr;
884 for (const auto& recent : entries) {
885 if (recent.unavailable || recent.is_missing) {
886 continue;
887 }
888 if (most_recent == nullptr ||
889 recent.recent_index < most_recent->recent_index) {
890 most_recent = &recent;
891 }
892 }
893 return most_recent;
894}
895
897 // Entry animation for recent projects (section 4)
898 float recent_progress = GetStaggeredEntryProgress(
900
901 if (recent_progress < 0.001f) {
902 return; // Don't draw yet
903 }
904
905 gui::StyleVarGuard alpha_guard(ImGuiStyleVar_Alpha, recent_progress);
906
907 ImGui::TextColored(SectionHeadingColor(), ICON_MD_HISTORY " Recent");
908
909 const float header_spacing = ImGui::GetStyle().ItemSpacing.x;
910 const float manage_width = ImGui::CalcTextSize(ICON_MD_FOLDER_SPECIAL).x +
911 ImGui::GetStyle().FramePadding.x * 2.0f;
912 const float clear_width = ImGui::CalcTextSize(ICON_MD_DELETE_SWEEP).x +
913 ImGui::GetStyle().FramePadding.x * 2.0f;
914 const float total_width = manage_width + clear_width + header_spacing;
915
916 ImGui::SameLine();
917 const float start_x = ImGui::GetCursorPosX();
918 const float right_edge = start_x + ImGui::GetContentRegionAvail().x;
919 const float button_start = std::max(start_x, right_edge - total_width);
920 ImGui::SetCursorPosX(button_start);
921
922 bool can_manage = open_project_management_callback_ != nullptr;
923 if (!can_manage) {
924 ImGui::BeginDisabled();
925 }
926 if (ImGui::SmallButton(ICON_MD_FOLDER_SPECIAL "##manage_recents")) {
929 }
930 }
931 if (ImGui::IsItemHovered()) {
932 ImGui::SetTooltip("%s", tr("Manage projects"));
933 }
934 if (!can_manage) {
935 ImGui::EndDisabled();
936 }
937 ImGui::SameLine(0.0f, header_spacing);
938 if (ImGui::SmallButton(ICON_MD_DELETE_SWEEP "##clear_recents")) {
941 }
942 if (ImGui::IsItemHovered()) {
943 ImGui::SetTooltip("%s", tr("Clear recent list"));
944 }
945
947
948 ImGui::Spacing();
949
950 if (recent_projects_model_.entries().empty()) {
951 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
952 gui::StyleColorGuard text_guard(ImGuiCol_Text, text_secondary);
953 ImGui::TextWrapped(tr("No recent files yet. Open a ROM to begin."));
954 return;
955 }
956
957 const float scale = ImGui::GetFontSize() / 16.0f;
958 const float row_height = std::max(44.0f, kRecentRowBaseHeight * scale);
959 const float row_gap = ImGui::GetStyle().ItemSpacing.y;
960 const float avail_h = ImGui::GetContentRegionAvail().y;
961 const float more_line = ImGui::GetTextLineHeightWithSpacing();
962 const auto& entries = recent_projects_model_.entries();
963 const size_t visible = static_cast<size_t>(
964 CalculateVisibleRecentCount(static_cast<int>(entries.size()), avail_h,
965 row_height, row_gap, more_line));
966 for (size_t i = 0; i < visible; ++i) {
967 DrawProjectPanel(entries[i], static_cast<int>(i),
968 ImVec2(ImGui::GetContentRegionAvail().x, row_height));
969 }
970 if (entries.size() > visible) {
971 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
972 gui::StyleColorGuard text_guard(ImGuiCol_Text, text_secondary);
973 ImGui::Text(tr("+%zu more in Manage"), entries.size() - visible);
974 }
975
977}
978
980 float available_height,
981 float row_height, float row_gap,
982 float more_line_height) {
983 if (entry_count <= 0 || available_height <= 0.0f || row_height <= 0.0f) {
984 return 0;
985 }
986
987 row_gap = std::max(0.0f, row_gap);
988 more_line_height = std::max(0.0f, more_line_height);
989 const float row_stride = row_height + row_gap;
990 auto rows_that_fit = [&](float height) {
991 return std::max(
992 0, static_cast<int>((std::max(0.0f, height) + row_gap) / row_stride));
993 };
994
995 // First determine whether every entry fits without a hint. If rows must be
996 // truncated, reserve the hint before calculating the final row count. This
997 // may intentionally leave zero rows in extremely short layouts so the
998 // management path remains visible without introducing a hidden scrollbar.
999 int max_visible = rows_that_fit(available_height);
1000 if (entry_count > max_visible) {
1001 max_visible = rows_that_fit(available_height - more_line_height);
1002 }
1003
1004 return std::min(entry_count, max_visible);
1005}
1006
1009 return;
1010
1011 // Open once per transition, then keep the modal visible until the user
1012 // hits Save or Cancel. IsPopupOpen gates the OpenPopup call so we don't
1013 // re-open every frame.
1014 const char* kPopupId = "##RecentAnnotationPopup";
1015 if (!ImGui::IsPopupOpen(kPopupId)) {
1016 ImGui::OpenPopup(kPopupId);
1017 }
1018
1019 ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_Appearing);
1020 if (ImGui::BeginPopupModal(kPopupId, nullptr,
1021 ImGuiWindowFlags_AlwaysAutoResize |
1022 ImGuiWindowFlags_NoSavedSettings)) {
1023 const bool renaming =
1025 ImGui::TextUnformatted(renaming ? ICON_MD_EDIT " Rename"
1026 : ICON_MD_NOTE " Edit Notes");
1027 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
1028 {
1029 gui::StyleColorGuard text_guard(ImGuiCol_Text, text_secondary);
1030 ImGui::TextWrapped("%s", pending_annotation_path_.c_str());
1031 }
1032 ImGui::Spacing();
1033
1034 bool committed = false;
1035 if (renaming) {
1036 ImGui::SetNextItemWidth(-1);
1037 if (ImGui::InputText("##rename_input", rename_buffer_,
1038 sizeof(rename_buffer_),
1039 ImGuiInputTextFlags_EnterReturnsTrue)) {
1040 committed = true;
1041 }
1042 {
1043 gui::StyleColorGuard text_guard(ImGuiCol_Text, text_secondary);
1044 ImGui::TextWrapped(tr(
1045 "Leave blank to restore the filename. Affects only how this entry "
1046 "is displayed on the welcome screen."));
1047 }
1048 } else {
1049 ImGui::SetNextItemWidth(-1);
1050 ImGui::InputTextMultiline("##notes_input", notes_buffer_,
1051 sizeof(notes_buffer_), ImVec2(-1, 120));
1052 {
1053 gui::StyleColorGuard text_guard(ImGuiCol_Text, text_secondary);
1054 ImGui::TextWrapped(
1055 tr("Short free-form note shown on hover. Useful for tagging "
1056 "works-in-progress (\"WIP: palette swap\")."));
1057 }
1058 }
1059
1060 ImGui::Spacing();
1061 if (ImGui::Button(ICON_MD_CHECK " Save") || committed) {
1062 if (renaming) {
1064 std::string(rename_buffer_));
1065 } else {
1067 std::string(notes_buffer_));
1068 }
1071 ImGui::CloseCurrentPopup();
1072 }
1073 ImGui::SameLine();
1074 if (ImGui::Button(ICON_MD_CLOSE " Cancel") ||
1075 ImGui::IsKeyPressed(ImGuiKey_Escape, /*repeat=*/false)) {
1078 ImGui::CloseCurrentPopup();
1079 }
1080 ImGui::EndPopup();
1081 }
1082}
1083
1086 return;
1087
1088 const auto pending = recent_projects_model_.PeekLastRemoval();
1089 if (pending.path.empty())
1090 return;
1091
1092 // A single-row inline banner reads as ephemeral feedback, not a dialog.
1093 // We colour it with the theme's warning surface so it visually pairs with
1094 // destructive-action affordances elsewhere.
1095 const ImVec4 warning_bg = gui::ConvertColorToImVec4(
1096 gui::ThemeManager::Get().GetCurrentTheme().warning);
1097 ImVec4 bg = warning_bg;
1098 bg.w = 0.18f;
1099
1100 const ImVec2 avail = ImGui::GetContentRegionAvail();
1101 const float row_height = ImGui::GetFrameHeight() + 6.0f;
1102 const ImVec2 cursor = ImGui::GetCursorScreenPos();
1103 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1104 draw_list->AddRectFilled(cursor,
1105 ImVec2(cursor.x + avail.x, cursor.y + row_height),
1106 ImGui::GetColorU32(bg), 4.0f);
1107
1108 // Centre the text row in the banner. The previous Dummy(0,3)+SameLine(8)
1109 // pair discarded its own inset: SameLine snaps CursorPos.y back to where
1110 // the Dummy started, so the 16px text sat flush against the top of the
1111 // 28px plate with all the slack below it.
1112 ImGui::SetCursorScreenPos(
1113 ImVec2(cursor.x + 8.0f,
1114 cursor.y + (row_height - ImGui::GetTextLineHeight()) * 0.5f));
1115 ImGui::TextColored(warning_bg, ICON_MD_INFO);
1116 ImGui::SameLine();
1117 ImGui::Text(tr("Removed \"%s\""), pending.display_name.c_str());
1118 ImGui::SameLine();
1119
1120 // Right-align the action buttons inside the banner.
1121 const float undo_width = ImGui::CalcTextSize(ICON_MD_UNDO " Undo").x +
1122 ImGui::GetStyle().FramePadding.x * 2.0f;
1123 const float dismiss_width = ImGui::CalcTextSize(ICON_MD_CLOSE).x +
1124 ImGui::GetStyle().FramePadding.x * 2.0f;
1125 const float spacing = ImGui::GetStyle().ItemSpacing.x;
1126 const float button_row = undo_width + dismiss_width + spacing;
1127 const float right_edge =
1128 ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x;
1129 ImGui::SetCursorPosX(
1130 std::max(ImGui::GetCursorPosX(), right_edge - button_row - 4.0f));
1131
1132 if (ImGui::SmallButton(ICON_MD_UNDO " Undo")) {
1134 RefreshRecentProjects(/*force=*/true);
1135 }
1136 ImGui::SameLine(0.0f, spacing);
1137 if (ImGui::SmallButton(ICON_MD_CLOSE "##dismiss_undo")) {
1139 }
1140 ImGui::Dummy(ImVec2(0, 2.0f));
1141}
1142
1143void WelcomeScreen::DrawProjectPanel(const RecentProject& project, int index,
1144 const ImVec2& card_size) {
1145 // Disambiguate ImGui IDs without allocating a new std::string every frame
1146 // (the old code called absl::StrFormat("ProjectPanel_%d", ...) per card).
1147 ImGui::PushID(index);
1148
1149 const ImVec4 text_primary = gui::GetOnSurfaceVec4();
1150 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
1151
1152 const ImVec2 resolved_card_size = card_size;
1153 const bool can_open = !project.is_missing && !project.unavailable;
1154
1155 ImVec4 accent = kTriforceGold;
1156 if (project.unavailable) {
1157 accent = kHeartRed;
1158 } else if (project.item_type == "ROM") {
1159 accent = kHyruleGreen;
1160 } else if (project.item_type == "Project") {
1161 accent = kMasterSwordBlue;
1162 }
1163
1164 // Selectable supplies hit-testing and keyboard nav, but not the painting:
1165 // it renders its fill with rounding hardcoded to 0, and the card's own
1166 // outline below uses 6.0f over the identical rect, so a hovered row grew
1167 // four square nubs of HeaderHovered outside the rounded border. Its idle
1168 // ImGuiCol_Header never rendered either — Selectable only paints Header
1169 // when `selected` is true, and this one is always false. Push all three
1170 // states transparent and draw every state below at the border's rounding.
1171 {
1172 // Read the theme's hover/active colours BEFORE the guards below push them
1173 // to transparent, otherwise the fill reads back its own suppression.
1174 const ImVec4 theme_hovered =
1175 ImGui::GetStyleColorVec4(ImGuiCol_HeaderHovered);
1176 const ImVec4 theme_active = ImGui::GetStyleColorVec4(ImGuiCol_HeaderActive);
1177 const ImVec4 kNoFill(0.0f, 0.0f, 0.0f, 0.0f);
1178 gui::StyleColorGuard header_guard({{ImGuiCol_Header, kNoFill},
1179 {ImGuiCol_HeaderHovered, kNoFill},
1180 {ImGuiCol_HeaderActive, kNoFill}});
1181 const bool is_activated = ImGui::Selectable(
1182 "##ProjectPanel", false, ImGuiSelectableFlags_AllowDoubleClick,
1183 resolved_card_size);
1184 const bool is_hovered = ImGui::IsItemHovered();
1185 const bool is_held = ImGui::IsItemActive();
1186 const ImVec2 cursor_pos = ImGui::GetItemRectMin();
1187 const ImVec2 item_max = ImGui::GetItemRectMax();
1188
1189 if (ImGui::BeginPopupContextItem("ProjectPanelMenu")) {
1190 if (project.is_missing) {
1191 // Missing file: offer relink + forget instead of open. Destructive "Open"
1192 // is hidden because it would just fail.
1193 if (ImGui::MenuItem(ICON_MD_SEARCH " Locate...")) {
1194 const std::string new_path =
1196 if (!new_path.empty() && new_path != project.filepath) {
1197 recent_projects_model_.RelinkRecent(project.filepath, new_path);
1198 }
1199 }
1200 if (ImGui::IsItemHovered()) {
1201 ImGui::SetTooltip(tr(
1202 "Point at the new location for this file. Pin/rename/notes are "
1203 "preserved."));
1204 }
1205 } else if (can_open) {
1206 if (ImGui::MenuItem(ICON_MD_OPEN_IN_NEW " Open")) {
1209 }
1210 }
1211 } else {
1212 ImGui::BeginDisabled();
1213 ImGui::MenuItem(ICON_MD_WARNING " Re-open required");
1214 ImGui::EndDisabled();
1215 }
1216 ImGui::Separator();
1217 if (ImGui::MenuItem(project.pinned ? ICON_MD_PUSH_PIN " Unpin"
1218 : ICON_MD_PUSH_PIN " Pin")) {
1220 }
1221 if (ImGui::MenuItem(ICON_MD_EDIT " Rename...")) {
1224 // Seed the buffer with the current display name (empty override falls
1225 // back to the filename so the user can start from what they see).
1226 std::snprintf(rename_buffer_, sizeof(rename_buffer_), "%s",
1227 project.display_name_override.empty()
1228 ? project.name.c_str()
1229 : project.display_name_override.c_str());
1230 }
1231 if (ImGui::MenuItem(ICON_MD_NOTE " Edit Notes...")) {
1234 std::snprintf(notes_buffer_, sizeof(notes_buffer_), "%s",
1235 project.notes.c_str());
1236 }
1237 ImGui::Separator();
1238 if (ImGui::MenuItem(ICON_MD_CONTENT_COPY " Copy Path")) {
1239 ImGui::SetClipboardText(project.filepath.c_str());
1240 }
1241 if (ImGui::MenuItem(project.is_missing ? ICON_MD_DELETE_SWEEP " Forget"
1243 " Remove from Recents")) {
1245 }
1246 ImGui::EndPopup();
1247 }
1248
1249 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1250 // Card surface, at the same rounding as the border that follows it.
1251 constexpr float kCardRounding = 6.0f;
1252 ImVec4 surface = gui::GetSurfaceVariantVec4();
1253 surface.w = 0.35f;
1254 if (is_held) {
1255 surface = theme_active;
1256 } else if (is_hovered) {
1257 surface = theme_hovered;
1258 }
1259 draw_list->AddRectFilled(cursor_pos, item_max, ImGui::GetColorU32(surface),
1260 kCardRounding);
1261
1262 ImVec4 border = project.unavailable
1263 ? ImVec4(kHeartRed.x, kHeartRed.y, kHeartRed.z, 0.7f)
1264 : ImGui::GetStyleColorVec4(ImGuiCol_Border);
1265 draw_list->AddRect(cursor_pos, item_max, ImGui::GetColorU32(border),
1266 kCardRounding, 0, 1.0f);
1267 // Accent rail on the left edge for type identity without covering hover.
1268 draw_list->AddRectFilled(cursor_pos,
1269 ImVec2(cursor_pos.x + 3.0f, item_max.y),
1270 ImGui::GetColorU32(accent), 2.0f);
1271
1272 // Compact two-line row: name + secondary meta. Details stay on hover.
1273 // The row height scales with the font, so its interior must too: fixed
1274 // pixels left the icon and badge marooned in dead space at accessibility
1275 // sizes, and cramped against the border at the 44px floor.
1276 const float row_scale = ImGui::GetFontSize() / 16.0f;
1277 const float padding_x = 10.0f * row_scale;
1278 const float padding_y = 6.0f * row_scale;
1279 // Size the disc from the glyph it has to contain rather than a literal.
1280 const float icon_radius =
1281 std::max(8.0f * row_scale, ImGui::GetTextLineHeight() * 0.7f);
1282 const float row_h = item_max.y - cursor_pos.y;
1283 const ImVec2 icon_center(cursor_pos.x + padding_x + icon_radius,
1284 cursor_pos.y + row_h * 0.5f);
1285 draw_list->AddCircleFilled(icon_center, icon_radius,
1286 ImGui::GetColorU32(accent), 20);
1287
1288 const char* item_icon = project.item_icon.empty()
1290 : project.item_icon.c_str();
1291 const ImVec2 icon_size = ImGui::CalcTextSize(item_icon);
1292 draw_list->AddText(ImVec2(icon_center.x - icon_size.x * 0.5f,
1293 icon_center.y - icon_size.y * 0.5f),
1294 ImGui::GetColorU32(text_primary), item_icon);
1295
1296 const std::string badge_text =
1297 project.item_type.empty() ? "File" : project.item_type;
1298 const ImVec2 badge_text_size = ImGui::CalcTextSize(badge_text.c_str());
1299 const float badge_pad_x = 5.0f;
1300 const float badge_pad_y = 1.0f;
1301 const ImVec2 badge_min(
1302 item_max.x - padding_x - badge_text_size.x - (badge_pad_x * 2.0f),
1303 cursor_pos.y + (row_h - badge_text_size.y - badge_pad_y * 2.0f) * 0.5f);
1304 const ImVec2 badge_max(
1305 badge_min.x + badge_text_size.x + (badge_pad_x * 2.0f),
1306 badge_min.y + badge_text_size.y + (badge_pad_y * 2.0f));
1307 draw_list->AddRectFilled(
1308 badge_min, badge_max,
1309 ImGui::GetColorU32(ImVec4(accent.x, accent.y, accent.z, 0.22f)), 3.0f);
1310
1311 const float content_x = icon_center.x + icon_radius + 10.0f;
1312 const float content_right = badge_min.x - 8.0f;
1313 const float text_max_w = std::max(60.0f, content_right - content_x);
1314 const float line_h = ImGui::GetTextLineHeight();
1315 const float text_block_h = line_h * 2.0f + 2.0f;
1316 float text_y =
1317 cursor_pos.y + std::max(padding_y, (row_h - text_block_h) * 0.5f);
1318
1319 const std::string raw_name =
1320 project.pinned
1321 ? absl::StrFormat("%s %s", ICON_MD_PUSH_PIN, project.name.c_str())
1322 : project.name;
1323 const std::string visible_name = EllipsizeText(raw_name, text_max_w);
1324 draw_list->AddText(ImVec2(content_x, text_y),
1325 ImGui::GetColorU32(text_primary), visible_name.c_str());
1326
1327 text_y += line_h + 2.0f;
1328 const std::string secondary =
1329 !project.last_modified.empty()
1330 ? project.last_modified
1331 : (!project.rom_title.empty() ? project.rom_title
1332 : project.filepath);
1333 draw_list->AddText(ImVec2(content_x, text_y),
1334 ImGui::GetColorU32(text_secondary),
1335 EllipsizeText(secondary, text_max_w).c_str());
1336 draw_list->AddText(
1337 ImVec2(badge_min.x + badge_pad_x, badge_min.y + badge_pad_y),
1338 ImGui::GetColorU32(text_primary), badge_text.c_str());
1339
1340 if (is_hovered) {
1341 // Only what the card itself cannot show. Type, name, details, metadata
1342 // and last-opened were all already on the row or its badge; repeating
1343 // them in a seven-line panel made hovering a recent feel like opening a
1344 // properties dialog. The path is genuinely hidden (the row ellipsizes
1345 // it), and an unavailable entry needs to say what to do about it.
1346 ImGui::BeginTooltip();
1347 ImGui::TextUnformatted(project.filepath.c_str());
1348 if (project.is_missing) {
1349 ImGui::TextColored(kTriforceGold,
1350 ICON_MD_SEARCH " Right-click to locate");
1351 } else if (project.unavailable) {
1352 ImGui::TextColored(kHeartRed,
1353 ICON_MD_WARNING " Re-open from the start actions");
1354 }
1355 ImGui::EndTooltip();
1356 }
1357
1358 if (is_activated && can_open && open_project_callback_) {
1360 }
1361 }
1362
1363 ImGui::PopID();
1364}
1365
1367 // One footer row carries the build, the release-notes link, and a single
1368 // tip. These used to be two separate blocks: a "What's new" card with
1369 // hand-written highlights that went stale, and a tip strip.
1372 if (progress < 0.001f) {
1373 return;
1374 }
1375 gui::StyleVarGuard alpha_guard(ImGuiStyleVar_Alpha, progress);
1376
1377 const ImGuiStyle& style = ImGui::GetStyle();
1378 const ImVec4 text_secondary = gui::GetTextSecondaryVec4();
1379
1380 const std::string version = absl::StrFormat("v%s", YAZE_VERSION_STRING);
1381 ImGui::TextColored(text_secondary, "%s", version.c_str());
1382 ImGui::SameLine(0.0f, style.ItemSpacing.x);
1383
1384 if (ImGui::SmallButton(ICON_MD_OPEN_IN_NEW " Release notes")) {
1385 constexpr char kReleaseNotesUrl[] =
1386 "https://github.com/scawful/yaze/blob/master/docs/public/"
1387 "release-notes.md";
1388 release_notes_open_failed_ = !gui::OpenUrl(kReleaseNotesUrl);
1389 }
1390 if (ImGui::IsItemHovered()) {
1391 ImGui::SetTooltip("%s", tr("Open the release notes in your browser"));
1392 }
1393
1394 const std::string close_label =
1395 absl::StrFormat("%s Don't show again", ICON_MD_CLOSE);
1396 const float close_width =
1397 ImGui::CalcTextSize(close_label.c_str()).x + 2.0f * style.FramePadding.x;
1398
1399 // This slot now carries only the release-notes failure. The rotating
1400 // "Tip:" line that used to live here was ambient advice nobody came to the
1401 // launcher to read, and it competed with the two real actions above it.
1402 const std::string notice =
1404 ? absl::StrFormat("%s %s", ICON_MD_INFO,
1405 tr("Could not open the browser; see "
1406 "docs/public/release-notes.md"))
1407 : std::string();
1408
1409 ImGui::SameLine(0.0f, style.ItemSpacing.x);
1410 const float notice_start = ImGui::GetCursorPosX();
1411 const float right_edge = notice_start + ImGui::GetContentRegionAvail().x;
1412 const float notice_width =
1413 right_edge - close_width - style.ItemSpacing.x - notice_start;
1414 if (!notice.empty() && notice_width > 0.0f) {
1415 const std::string visible = EllipsizeText(notice, notice_width);
1416 ImGui::TextColored(gui::ConvertColorToImVec4(
1417 gui::ThemeManager::Get().GetCurrentTheme().warning),
1418 "%s", visible.c_str());
1419 if (visible != notice && ImGui::IsItemHovered()) {
1420 ImGui::SetTooltip("%s", notice.c_str());
1421 }
1422 }
1423 if (notice_width > 0.0f) {
1424 ImGui::SameLine(right_edge - close_width);
1425 }
1426 if (ImGui::SmallButton(close_label.c_str())) {
1427 manually_closed_ = true;
1428 }
1429}
1430
1431} // namespace editor
1432} // namespace yaze
static TimingManager & Get()
Definition timing.h:20
float GetDeltaTime() const
Get the last frame's delta time in seconds.
Definition timing.h:60
void SetPinned(const std::string &path, bool pinned)
void SetDisplayName(const std::string &path, std::string display_name)
void SetNotes(const std::string &path, std::string notes)
void RelinkRecent(const std::string &old_path, const std::string &new_path)
void RemoveRecent(const std::string &path)
const std::vector< RecentProject > & entries() const
Manages user preferences and settings persistence.
std::function< void()> open_rom_callback_
std::function< void()> new_project_callback_
static int CalculateVisibleRecentCount(int entry_count, float available_height, float row_height, float row_gap, float more_line_height)
static constexpr float kEntryStaggerDelay
RecentProjectsModel recent_projects_model_
static constexpr int kNumTriforces
RecentAnnotationKind pending_annotation_kind_
static constexpr float kEntryAnimDuration
void RefreshRecentProjects(bool force=false)
Refresh recent projects list from the project manager.
void DrawProjectPanel(const RecentProject &project, int index, const ImVec2 &card_size)
ImVec2 triforce_base_positions_[kNumTriforces]
Particle particles_[kMaxParticles]
static bool ShouldUseStackedLayout(float content_width, float content_height, float layout_scale)
void UpdateAnimations()
Update animation time for dynamic effects.
static constexpr int kMaxParticles
bool Show(bool *p_open)
Show the welcome screen.
static const RecentProject * FindResumeProject(const std::vector< RecentProject > &entries)
ImVec2 triforce_positions_[kNumTriforces]
std::function< void(const std::string &) open_project_callback_)
std::function< void()> open_prototype_research_callback_
void SetUserSettings(UserSettings *settings)
Load persisted Welcome-screen animation preferences.
std::function< void()> open_assembly_editor_no_rom_callback_
std::function< void()> open_project_management_callback_
RAII guard for ImGui style colors.
Definition style_guard.h:27
RAII guard for ImGui style vars.
Definition style_guard.h:68
static ThemeManager & Get()
static std::string ShowOpenFileDialog()
ShowOpenFileDialog opens a file dialog and returns the selected filepath. Uses global feature flag to...
#define YAZE_VERSION_STRING
#define ICON_MD_INSERT_DRIVE_FILE
Definition icons.h:999
#define ICON_MD_FOLDER_OPEN
Definition icons.h:813
#define ICON_MD_INFO
Definition icons.h:993
#define ICON_MD_WARNING
Definition icons.h:2123
#define ICON_MD_FOLDER_SPECIAL
Definition icons.h:815
#define ICON_MD_SEARCH
Definition icons.h:1673
#define ICON_MD_PLAY_ARROW
Definition icons.h:1479
#define ICON_MD_CHECK
Definition icons.h:397
#define ICON_MD_CONSTRUCTION
Definition icons.h:458
#define ICON_MD_AUTO_AWESOME
Definition icons.h:214
#define ICON_MD_CODE
Definition icons.h:434
#define ICON_MD_EDIT
Definition icons.h:645
#define ICON_MD_CASTLE
Definition icons.h:380
#define ICON_MD_BOLT
Definition icons.h:282
#define ICON_MD_NOTE
Definition icons.h:1329
#define ICON_MD_OPEN_IN_NEW
Definition icons.h:1354
#define ICON_MD_CONTENT_COPY
Definition icons.h:465
#define ICON_MD_PUSH_PIN
Definition icons.h:1529
#define ICON_MD_CLOSE
Definition icons.h:418
#define ICON_MD_UNDO
Definition icons.h:2039
#define ICON_MD_ADD_CIRCLE
Definition icons.h:95
#define ICON_MD_HISTORY
Definition icons.h:946
#define ICON_MD_DELETE_SWEEP
Definition icons.h:533
std::string EllipsizeText(const std::string &text, float max_width)
void DrawTriforceBackground(ImDrawList *draw_list, ImVec2 pos, float size, float alpha, float glow)
float GetStaggeredEntryProgress(float entry_time, int section_index, float duration, float stagger_delay)
ImVec4 ConvertColorToImVec4(const Color &color)
Definition color.h:134
bool PrimaryButton(const char *label, const ImVec2 &size, const char *panel_id, const char *anim_id)
Draw a primary action button (accented color).
bool ThemedButton(const char *label, const ImVec2 &size, const char *panel_id, const char *anim_id)
Draw a standard text button with theme colors.
ImVec4 GetSurfaceVariantVec4()
bool OpenUrl(const std::string &url)
Definition input.cc:798
ImVec4 GetTextSecondaryVec4()
ImVec4 GetOnSurfaceVec4()
#define M_PI