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