package gui import "core:c" import clay "clay:." import "core:fmt" import "core:math" import "core:os" import filepath "core:path/filepath" import "core:strings" import rl "vendor:raylib" import "../core" import "../shared" import "../ui" // GUI_App_State holds all mutable GUI-local state for the main loop. FIELD_BUF_SIZE :: 1024 ANIM_DURATION_SIDEBAR :: 0.25 ANIM_DURATION_OVERLAY :: 0.2 ANIM_DURATION_SLIDE :: 0.3 GUI_App_State :: struct { controller: ui.App_Controller, selected_field: int, export_path: string, project_path: string, local_script_pages: string, autosave_interval_text: string, export_format: core.Export_Format, use_fal_panels: bool, sidebar_collapsed: bool, status_msg: string, is_dirty: bool, autosave_enabled: bool, autosave_interval_s: f64, last_autosave_at: f64, last_save_at: f64, last_export_at: f64, action_log: Action_Log, log_show_lines: i32, log_oldest_first: bool, summary_opts: Summary_View_Options, show_help_overlay: bool, show_confirm_overlay: bool, pending_confirm: Pending_Confirm_Action, panel_textures: map[string]rl.Texture2D, wireframe_cell_rects: [16]rl.Rectangle, wireframe_cell_count: int, layout_selected_panel: int, bubble_edit_text: string, editing_char_id: string, char_edit_text: string, editing_panel_id: string, panel_edit_text: string, field_buf: [FIELD_BUF_SIZE]u8, // ─── Animation state ────────────────────────────────────────── sidebar_anim: f32, // current animated sidebar width (px) overlay_alpha: f32, // 0→1 fade progress for active overlay prev_screen: ui.App_Screen, slide_offset: f32, // horizontal offset for screen transitions slide_progress: f32, // 0→1 progress of slide animation } clicked :: proc(id: clay.ElementId) -> bool { return clay.PointerOver(id) && rl.IsMouseButtonPressed(.LEFT) } // ─── Persistent String Pool (survives temp-allocator resets) ──── @(private) persistent_pool: [256 * 1024]u8 @(private) persistent_offset: int @(private) pool_clone :: proc(s: string) -> string { if len(s) == 0 { return "" } total := len(s) + 1 if persistent_offset + total > len(persistent_pool) { // Pool full — fall back to strings.clone (heap) instead of corrupting old data return strings.clone(s) } start := persistent_offset for c, i in s { persistent_pool[start + i] = u8(c) } persistent_pool[start + len(s)] = 0 persistent_offset += total return string(persistent_pool[start:start+len(s)]) } // ─── Panel Image Loading ───────────────────────────────────────── @(private) download_path_cache: map[string]string @(private) download_failed_cache: map[string]bool @(private) resolve_image_path :: proc(url: string) -> string { if download_path_cache == nil { download_path_cache = make(map[string]string, context.allocator) } if download_failed_cache == nil { download_failed_cache = make(map[string]bool, context.allocator) } if strings.has_prefix(url, "file://") { return url[7:] } if !strings.has_prefix(url, "http://") && !strings.has_prefix(url, "https://") { // Reject non-URL garbage (could be corrupted temp-allocator data) if len(url) == 0 || url[0] < 32 || url[0] > 126 { return "" } return url } if cached_path, ok := download_path_cache[url]; ok { return pool_clone(cached_path) } if _, failed := download_failed_cache[url]; failed { return "" } base := url if q := strings.index(base, "?"); q >= 0 { base = base[:q] } filename := filepath.base(base) if len(filename) == 0 { filename = "cached_image.png" } cache_dir := "./assets" os.mkdir_all(cache_dir) local_path := fmt.aprintf("%s/%s", cache_dir, filename) if os.exists(local_path) { download_path_cache[url] = pool_clone(local_path) return pool_clone(local_path) } cmd := [6]string{"curl", "-L", "-sS", "-o", local_path, url} desc := os.Process_Desc{command = cmd[:]} state, _, _, exec_err := os.process_exec(desc, context.temp_allocator) if exec_err != nil || !state.exited || state.exit_code != 0 { download_failed_cache[url] = true return "" } download_path_cache[url] = pool_clone(local_path) return pool_clone(local_path) } @(private) load_panel_texture :: proc(cache: ^map[string]rl.Texture2D, panel_id: string, url: string) -> (rl.Texture2D, bool) { if tex, ok := cache[panel_id]; ok { return tex, true } filepath := resolve_image_path(url) if len(filepath) == 0 { return {}, false } fpath_c := strings.clone_to_cstring(filepath) defer delete(fpath_c) tex := rl.LoadTexture(fpath_c) if tex.id == 0 { // Fallback: try LoadImage + LoadTextureFromImage for broader format support img := rl.LoadImage(fpath_c) if img.data != nil { defer rl.UnloadImage(img) tex = rl.LoadTextureFromImage(img) } } if tex.id == 0 { return {}, false } rl.SetTextureFilter(tex, .BILINEAR) cache[panel_id] = tex return tex, true } @(private) unload_panel_textures :: proc(cache: ^map[string]rl.Texture2D) { for _, tex in cache { rl.UnloadTexture(tex) } delete(cache^) } run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { controller := ui.new_controller(state^) defer ui.dispose_job_manager(&controller.jobs) rl.SetConfigFlags({.WINDOW_RESIZABLE, .WINDOW_UNDECORATED}) rl.InitWindow(1240, 820, "comic-odin gui") defer rl.CloseWindow() monitor := rl.GetCurrentMonitor() monitor_pos := rl.GetMonitorPosition(monitor) rl.SetWindowPosition(i32(monitor_pos.x), i32(monitor_pos.y)) rl.SetWindowSize(rl.GetMonitorWidth(monitor), rl.GetMonitorHeight(monitor)) rl.SetWindowState({.BORDERLESS_WINDOWED_MODE}) rl.SetTargetFPS(60) clay_init(1240, 820) defer clay_shutdown() app: GUI_App_State app.controller = controller app.panel_textures = make(map[string]rl.Texture2D, 64) reserve(&app.panel_textures, 256) app.layout_selected_panel = -1 app.selected_field = 0 app.export_path = "./gui_export.pdf" app.project_path = "./gui_project.comic.json" app.local_script_pages = "2" app.autosave_interval_text = "20" app.export_format = .PDF app.use_fal_panels = false app.status_msg = fmt.aprintf("GUI ready") app.autosave_enabled = true app.autosave_interval_s = 20 app.last_autosave_at = rl.GetTime() app.last_save_at = -1 app.last_export_at = -1 app.log_oldest_first = false app.summary_opts = Summary_View_Options{script_show_all = true, script_desc = true} app.sidebar_anim = f32(shared.LAYOUT.sidebar_width) app.prev_screen = app.controller.active_screen push_status(&app.status_msg, &app.action_log, app.status_msg) defer action_log_dispose(&app.action_log) defer delete(app.status_msg) defer unload_panel_textures(&app.panel_textures) for !rl.WindowShouldClose() { screen_w := rl.GetScreenWidth() screen_h := rl.GetScreenHeight() bp := shared.breakpoint(screen_w, screen_h) compact_mode := shared.is_compact(screen_h) dt := rl.GetFrameTime() update_sidebar_anim(&app, bp, dt) update_overlay_anim(&app, dt) update_slide_anim(&app, dt) sidebar_w := sidebar_width(bp, app.sidebar_collapsed, app.sidebar_anim) main_w := shared.compute_main_width(screen_w, sidebar_w) cfg := shared.load_config() has_deepseek_key := len(cfg.deepseek_api_key) > 0 has_fal_key := len(cfg.fal_api_key) > 0 clay_update_dimensions(screen_w, screen_h) clay_update_input() // ─── Keyboard Input ────────────────────────────────────── interaction_locked := app.show_help_overlay || app.show_confirm_overlay if rl.IsKeyPressed(.SLASH) { toggle_help_overlay(&app.show_help_overlay) } if rl.IsKeyPressed(.ESCAPE) { close_help_overlay_if_open(&app.show_help_overlay) } // Compact mode forces the sidebar collapsed if bp == .Compact { app.sidebar_collapsed = true } if !interaction_locked { if rl.IsKeyPressed(.B) && (rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)) { app.sidebar_collapsed = !app.sidebar_collapsed status := "Sidebar expanded" if app.sidebar_collapsed { status = "Sidebar collapsed" } push_status(&app.status_msg, &app.action_log, status) } if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&app.controller, .Story) } if rl.IsKeyPressed(.TWO) { _ = ui.navigate_to_screen(&app.controller, .Script) } if rl.IsKeyPressed(.THREE) { _ = ui.navigate_to_screen(&app.controller, .Characters) } if rl.IsKeyPressed(.FOUR) { _ = ui.navigate_to_screen(&app.controller, .Panels) } if rl.IsKeyPressed(.FIVE) { _ = ui.navigate_to_screen(&app.controller, .Layout) } if rl.IsKeyPressed(.SIX) { _ = ui.navigate_to_screen(&app.controller, .Bubbles) } if rl.IsKeyPressed(.SEVEN) { _ = ui.navigate_to_screen(&app.controller, .Export) } if rl.IsKeyPressed(.EIGHT) { _ = ui.navigate_to_screen(&app.controller, .Community) } if rl.IsKeyPressed(.TAB) { app.selected_field = (app.selected_field + 1) % 7 } if rl.IsKeyPressed(.F1) { app.selected_field = 0 } if rl.IsKeyPressed(.F2) { app.selected_field = 1 } if rl.IsKeyPressed(.F3) { app.selected_field = 2 } if rl.IsKeyPressed(.F4) { app.selected_field = 3 } if rl.IsKeyPressed(.F11) { app.selected_field = 4 } if rl.IsKeyPressed(.F12) { app.selected_field = 5 } } pages_count := parse_pages_or_default(app.local_script_pages, 2) autosave_secs := parse_autosave_interval(app.autosave_interval_text, 20) app.autosave_interval_s = f64(autosave_secs) if app.selected_field != 6 && len(strings.trim_space(app.autosave_interval_text)) == 0 { app.autosave_interval_text = fmt.aprintf("%d", autosave_secs) } shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT) can_generate_panels := len(app.controller.state.script.pages) > 0 can_layout := len(app.controller.state.panel_images) > 0 can_export := len(app.controller.state.page_layouts) > 0 && len(app.controller.state.panel_images) > 0 project_path_ok := project_path_is_normalized(app.project_path) export_path_ok := export_path_matches_format(app.export_path, app.export_format) // ─── Confirm Overlay Logic ──────────────────────────────── if app.show_confirm_overlay { confirm_yes := rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y) confirm_no := rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N) if clicked(clay.ID("confirm_yes")) { confirm_yes = true } if clicked(clay.ID("confirm_no")) { confirm_no = true } if confirm_no { app.show_confirm_overlay = false app.pending_confirm = .None push_status(&app.status_msg, &app.action_log, "Cancelled destructive action") } else if confirm_yes { action := app.pending_confirm app.show_confirm_overlay = false app.pending_confirm = .None push_status(&app.status_msg, &app.action_log, resolve_confirm_action_with_message(action, &app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) } } // ─── Text Input (raygui GuiTextBox overlay) ──────────────── // raygui GuiTextBox is called after Clay render below // ─── Keyboard Shortcuts ───────────────────────────────────── ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL) if !interaction_locked { if ctrl_down && rl.IsKeyPressed(.S) { push_status(&app.status_msg, &app.action_log, save_project_session_with_message(&app.project_path, app.controller.state, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, "Saved project")) } if ctrl_down && rl.IsKeyPressed(.N) { if app.is_dirty && !shift_down { push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Reset_Project, "Confirm reset?")) } else { push_status(&app.status_msg, &app.action_log, reset_project_session(&app.controller, &app.is_dirty, &app.last_autosave_at, true)) } } if ctrl_down && rl.IsKeyPressed(.O) { if app.is_dirty && !shift_down { push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Open_Project, "Confirm open?")) } else { push_status(&app.status_msg, &app.action_log, open_project_session(&app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) } } if ctrl_down && rl.IsKeyPressed(.E) { push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } if ctrl_down && rl.IsKeyPressed(.H) { push_status_if_nonempty(&app.status_msg, &app.action_log, toggle_summary_show_if_supported(app.controller.active_screen, &app.summary_opts)) } if ctrl_down && rl.IsKeyPressed(.J) { push_status_if_nonempty(&app.status_msg, &app.action_log, toggle_summary_sort_if_supported(app.controller.active_screen, &app.summary_opts)) } if ctrl_down && rl.IsKeyPressed(.MINUS) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, autosave_secs-5)) } if ctrl_down && rl.IsKeyPressed(.EQUAL) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, autosave_secs+5)) } if ctrl_down && rl.IsKeyPressed(.SEVEN) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 15)) } if ctrl_down && rl.IsKeyPressed(.EIGHT) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 30)) } if ctrl_down && rl.IsKeyPressed(.NINE) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 60)) } if ctrl_down && rl.IsKeyPressed(.ZERO) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, reset_helper_fields_with_message(&app.export_path, &app.local_script_pages, &app.autosave_interval_text, app.export_format)) } if ctrl_down && !shift_down && rl.IsKeyPressed(.L) { set_status(&app.status_msg, clear_action_log_with_message(&app.action_log)) } if ctrl_down && rl.IsKeyPressed(.V) { push_status_if_nonempty(&app.status_msg, &app.action_log, paste_clipboard_into_selected_field_with_message(app.selected_field, &app.controller.state, &app.export_path, &app.local_script_pages, &app.project_path, &app.autosave_interval_text, &app.is_dirty)) } if ctrl_down && rl.IsKeyPressed(.BACKSPACE) { push_status(&app.status_msg, &app.action_log, clear_selected_field_with_message(app.selected_field, &app.controller.state, &app.export_path, &app.local_script_pages, &app.project_path, &app.autosave_interval_text, &app.is_dirty)) } if ctrl_down && shift_down && rl.IsKeyPressed(.C) { push_status(&app.status_msg, &app.action_log, copy_text_with_status(app.status_msg, "Copied status to clipboard")) } if ctrl_down && shift_down && rl.IsKeyPressed(.A) { push_status(&app.status_msg, &app.action_log, toggle_autosave_with_message(&app.autosave_enabled)) } if ctrl_down && shift_down && rl.IsKeyPressed(.X) { push_status(&app.status_msg, &app.action_log, copy_text_with_status(app.export_path, "Copied export path to clipboard")) } if ctrl_down && shift_down && rl.IsKeyPressed(.P) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_export_preset_with_message(&app.export_path, app.export_format)) } if ctrl_down && shift_down && rl.IsKeyPressed(.D) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_export_path_from_project_with_message(&app.export_path, app.project_path, app.export_format)) } if ctrl_down && shift_down && rl.IsKeyPressed(.G) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_project_path_from_export_with_message(&app.project_path, app.export_path)) } if ctrl_down && shift_down && rl.IsKeyPressed(.J) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_project_path_with_message(&app.project_path)) } if ctrl_down && shift_down && rl.IsKeyPressed(.K) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_project_path_with_message(&app.project_path)) } if ctrl_down && shift_down && rl.IsKeyPressed(.M) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_export_path_with_message(&app.export_path, app.export_format)) } if ctrl_down && shift_down && rl.IsKeyPressed(.U) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, fix_all_paths_with_message(&app.project_path, &app.export_path, app.export_format)) } if ctrl_down && shift_down && rl.IsKeyPressed(.F) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_export_path_with_message(&app.export_path, app.export_format)) } if rl.IsKeyPressed(.F5) { push_status(&app.status_msg, &app.action_log, action_generate_deepseek_script(&app.controller, pages_count)) } if rl.IsKeyPressed(.F6) { push_status(&app.status_msg, &app.action_log, "Use FAL panel button to generate panels") } if rl.IsKeyPressed(.F7) { push_status(&app.status_msg, &app.action_log, run_layout_action(&app.controller, can_layout, &app.is_dirty)) } if rl.IsKeyPressed(.F8) { push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } if rl.IsKeyPressed(.F9) { push_status(&app.status_msg, &app.action_log, run_next_action(&app.controller, &app.export_path, app.export_format, pages_count, &app.is_dirty, &app.last_export_at)) } if rl.IsKeyPressed(.F10) { push_status(&app.status_msg, &app.action_log, run_auto_all_action(&app.controller, &app.export_path, app.export_format, pages_count, &app.is_dirty, &app.last_export_at)) } } // ─── Autosave ────────────────────────────────────────────── push_status_if_nonempty(&app.status_msg, &app.action_log, autosave_tick_with_message(&app.project_path, app.controller.state, app.autosave_enabled, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, app.autosave_interval_s)) // ─── Clay Layout Declaration ────────────────────────────── fmt.eprintf("LAYOUT: screen=%dx%d sidebar_w=%.0f sidebar_anim=%.0f collapsed=%v bp=%v\n", screen_w, screen_h, f32(sidebar_width(bp, app.sidebar_collapsed, 0)), app.sidebar_anim, app.sidebar_collapsed, bp) clay.BeginLayout() // Root: horizontal layout (sidebar + main) if clay.UI(clay.ID("Root"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .LeftToRight}, backgroundColor = CLAY_BG_BASE, }) { // ─── Sidebar (responsive width) ─────────────────────── if clay.UI(clay.ID("Sidebar"))({ layout = {sizing = {width = clay.SizingFixed(f32(sidebar_w)), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 10, right = 10, bottom = 10, left = 10}, childGap = 4}, backgroundColor = CLAY_BG_SIDEBAR, }) { declare_sidebar(&app, bp) } // ─── Main Content ──────────────────────────────── if clay.UI(clay.ID("MainArea"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom}, }) { declare_pipeline_bar(&app, bp) declare_workspace(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, pages_count, bp, main_w) next_hint := gui_next_hint(app.controller) declare_bottom_bar(&app, can_generate_panels, can_layout, can_export, next_hint, has_fal_key, bp) } } // ─── Floating Overlays (Clay, animated alpha) ───────────── if app.show_help_overlay || app.overlay_alpha > 0.01 { declare_help_overlay(bp, app.overlay_alpha) } if app.show_confirm_overlay || app.overlay_alpha > 0.01 { declare_confirm_overlay(app.pending_confirm, bp, app.overlay_alpha) } declare_toast(&app.action_log) render_commands := clay.EndLayout(rl.GetFrameTime()) // ─── Click Detection (after layout, before render) ──────── process_clicks(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, has_fal_key, pages_count, shift_down, autosave_secs, compact_mode, bp) // ─── Render ──────────────────────────────────────────────────── rl.BeginDrawing() rl.EndScissorMode() rl.ClearBackground(RL_BG_BASE) clay_raylib_render(&render_commands) // ─── Layout wireframe overlay (raylib, after Clay) ────────────── draw_layout_wireframe(&app) // ─── Raygui field overlay (draw after Clay, before end) ───────── if !interaction_locked { render_raygui_fields(&app) rl.EndScissorMode() } rl.EndDrawing() } core.dispose_state(state) state^ = app.controller.state app.controller.state = core.Comic_State{} return shared.ok() } // ─── Click Handlers (called by process_clicks) ─────────────────── handle_nav_clicks :: proc(app: ^GUI_App_State) { nav_screen := []ui.App_Screen{.Story, .Script, .Characters, .Panels, .Layout, .Bubbles, .Export, .Community} for i in 0 ..< len(nav_screen) { if clicked(clay.ID("Nav", u32(i))) { push_status(&app.status_msg, &app.action_log, navigate_screen_with_status(&app.controller, nav_screen[i])) } } pipeline_screens := []ui.App_Screen{.Script, .Panels, .Layout, .Export} for i in 0 ..< len(pipeline_screens) { if clicked(clay.ID("PStep", u32(i))) { push_status(&app.status_msg, &app.action_log, navigate_screen_with_status(&app.controller, pipeline_screens[i])) } } } handle_field_clicks :: proc(app: ^GUI_App_State) { input_ids := []string{"field_idea", "field_genre", "field_audience", "field_export", "field_pages", "field_project"} for i in 0 ..< len(input_ids) { if clicked(clay.ID(input_ids[i])) { app.selected_field = i } } } handle_format_clicks :: proc(app: ^GUI_App_State, has_deepseek: bool) { if clicked(clay.ID("btn_pdf")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .PDF, &app.is_dirty)) } if clicked(clay.ID("btn_png")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .PNG, &app.is_dirty)) } if clicked(clay.ID("btn_cbz")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .CBZ, &app.is_dirty)) } } handle_action_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export: bool, pages_count: int, shift_down: bool, proj_ok, export_ok: bool, autosave_secs: int, has_fal_key: bool) { if clicked(clay.ID("btn_new")) { if app.is_dirty && !shift_down { push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Reset_Project, "Confirm reset?")) } else { push_status(&app.status_msg, &app.action_log, reset_project_session(&app.controller, &app.is_dirty, &app.last_autosave_at, false)) } } if clicked(clay.ID("btn_script")) { push_status(&app.status_msg, &app.action_log, action_generate_deepseek_script(&app.controller, pages_count)) } if clicked(clay.ID("btn_panels")) { push_status(&app.status_msg, &app.action_log, "Click [FAL] then [Panels] to generate via FAL") } if clicked(clay.ID("btn_layout")) { push_status(&app.status_msg, &app.action_log, run_layout_action(&app.controller, can_layout, &app.is_dirty)) } if clicked(clay.ID("btn_export")) { push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } if clicked(clay.ID("btn_export_now")) { push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } if clicked(clay.ID("btn_export_png")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .PNG, &app.is_dirty)) push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } if clicked(clay.ID("btn_export_cbz")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .CBZ, &app.is_dirty)) push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } if clicked(clay.ID("btn_next")) { push_status(&app.status_msg, &app.action_log, run_next_action(&app.controller, &app.export_path, app.export_format, pages_count, &app.is_dirty, &app.last_export_at)) } if clicked(clay.ID("btn_auto")) { push_status(&app.status_msg, &app.action_log, run_auto_all_action(&app.controller, &app.export_path, app.export_format, pages_count, &app.is_dirty, &app.last_export_at)) } if clicked(clay.ID("btn_save")) { push_status(&app.status_msg, &app.action_log, save_project_session_with_message(&app.project_path, app.controller.state, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, "Saved project")) } if clicked(clay.ID("btn_open")) { if app.is_dirty && !shift_down { push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Open_Project, "Confirm open?")) } else { push_status(&app.status_msg, &app.action_log, open_project_session(&app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) } } if clicked(clay.ID("btn_autosave_toggle")) { push_status(&app.status_msg, &app.action_log, toggle_autosave_with_message(&app.autosave_enabled)) } if clicked(clay.ID("btn_mode_casual")) { app.controller.state.user_mode = .Casual; push_status(&app.status_msg, &app.action_log, "Mode: Casual") } if clicked(clay.ID("btn_mode_pro")) { app.controller.state.user_mode = .Professional; push_status(&app.status_msg, &app.action_log, "Mode: Professional") } // Genre chips genres := []string{"action", "comedy", "drama", "scifi", "fantasy", "horror", "romance", "mystery", "slice-of-life"} for g in genres { if clicked(clay.ID(fmt.tprintf("btn_genre_%s", g))) { delete(app.controller.state.story_genre) app.controller.state.story_genre = strings.clone(g) push_status(&app.status_msg, &app.action_log, fmt.tprintf("Genre: %s", g)) app.is_dirty = true } } // Art style chips styles := []string{"manga", "western-comic", "pixel-art", "watercolor", "noir", "chibi", "sketch", "cyberpunk"} for s in styles { if clicked(clay.ID(fmt.tprintf("btn_style_%s", s))) { delete(app.controller.state.art_style) app.controller.state.art_style = strings.clone(s) push_status(&app.status_msg, &app.action_log, fmt.tprintf("Style: %s", s)) app.is_dirty = true } } // Generate All Refs button (Characters screen) if clicked(clay.ID("btn_char_gen_all")) { push_status(&app.status_msg, &app.action_log, action_generate_all_character_refs(&app.controller)) app.is_dirty = true } // Generate All Panels button if clicked(clay.ID("btn_panels_gen_all")) { cfg := shared.load_config() if len(cfg.fal_api_key) == 0 { push_status(&app.status_msg, &app.action_log, "FAL key missing") } else { push_status(&app.status_msg, &app.action_log, run_panels_action(&app.controller, nil, &app.is_dirty)) } } // Page size chips (Layout screen) page_sizes := []core.Page_Size_Name{.A4, .Letter, .Manga, .Webtoon, .Square} for sz, pi in page_sizes { if clicked(clay.ID(fmt.tprintf("btn_pgsize_%d", pi))) { app.controller.state.page_size = sz push_status(&app.status_msg, &app.action_log, fmt.tprintf("Page size: %v", sz)) app.is_dirty = true } } // Layout pattern cards patterns := []string{"grid-2x2", "manga-3-tier", "action-dynamic", "splash-page", "webtoon-scroll", "western-3x3", "dialogue-heavy", "cinematic-widescreen"} for pat in patterns { if clicked(clay.ID(fmt.tprintf("btn_pattern_%s", pat))) { if len(app.controller.state.panel_images) > 0 { panels := collect_script_panels(app.controller.state.script) core.dispose_page_layouts(&app.controller.state.page_layouts) app.controller.state.page_layouts = core.auto_layout_pages(panels, app.controller.state.page_size, app.controller.state.story_genre, pat) push_status(&app.status_msg, &app.action_log, fmt.tprintf("Layout: %s", pat)) app.is_dirty = true } else { push_status(&app.status_msg, &app.action_log, "Generate panels first") } } } // Audience chips auds := []string{"general", "children", "teen", "mature"} for a in auds { if clicked(clay.ID(fmt.tprintf("btn_aud_%s", a))) { delete(app.controller.state.target_audience) app.controller.state.target_audience = strings.clone(a) push_status(&app.status_msg, &app.action_log, fmt.tprintf("Audience: %s", a)) app.is_dirty = true } } if clicked(clay.ID("btn_help")) { toggle_help_overlay(&app.show_help_overlay) } if clicked(clay.ID("btn_toggle_sidebar")) { app.sidebar_collapsed = !app.sidebar_collapsed push_status(&app.status_msg, &app.action_log, fmt.tprintf("Sidebar: %s", "collapsed" if app.sidebar_collapsed else "expanded")) } if clicked(clay.ID("btn_fal_panels")) { if !has_fal_key { push_status(&app.status_msg, &app.action_log, "FAL key missing (set FAL_API_KEY)") } else { app.use_fal_panels = !app.use_fal_panels; push_status(&app.status_msg, &app.action_log, fmt.tprintf("FAL panels: %s", "ON" if app.use_fal_panels else "OFF")) } } diag_ctx := make_diagnostics_action_context(&app.controller, &app.action_log, app.is_dirty, app.autosave_enabled, proj_ok, export_ok, autosave_secs, app.project_path, app.export_path, app.log_show_lines, app.log_oldest_first) if clicked(clay.ID("btn_log_reset")) { push_status(&app.status_msg, &app.action_log, reset_log_view_with_message(&app.log_show_lines, &app.log_oldest_first)) } if clicked(clay.ID("btn_log_clear")) { set_status(&app.status_msg, clear_action_log_with_message(&app.action_log)) } if clicked(clay.ID("btn_log_report")) { push_status(&app.status_msg, &app.action_log, write_session_report_with_message(diag_ctx)) } if clicked(clay.ID("btn_log_copy")) { push_status(&app.status_msg, &app.action_log, copy_action_log_snapshot_with_message(diag_ctx)) } if clicked(clay.ID("btn_log_diag")) { push_status(&app.status_msg, &app.action_log, write_diagnostics_with_message(diag_ctx)) } if clicked(clay.ID("btn_log_status_copy")) { push_status(&app.status_msg, &app.action_log, copy_text_with_status(app.status_msg, "Copied status to clipboard")) } if clicked(clay.ID("btn_log_diag_copy")) { push_status(&app.status_msg, &app.action_log, copy_diagnostics_with_message(diag_ctx)) } } handle_workspace_nav :: proc(app: ^GUI_App_State) { screen := app.controller.active_screen if screen == .Script { page_count := len(app.controller.state.script.pages) // Collapsible toggles if clicked(clay.ID("btn_toggle_chars")) { app.summary_opts.script_show_all = !app.summary_opts.script_show_all push_status(&app.status_msg, &app.action_log, fmt.tprintf("Characters: %s", "shown" if app.summary_opts.script_show_all else "hidden")) } if clicked(clay.ID("btn_toggle_panels")) { app.summary_opts.script_desc = !app.summary_opts.script_desc push_status(&app.status_msg, &app.action_log, fmt.tprintf("Panels: %s", "shown" if app.summary_opts.script_desc else "hidden")) } if clicked(clay.ID("btn_script_prev")) && page_count > 0 { app.summary_opts.script_page_cursor -= 1 if app.summary_opts.script_page_cursor < 0 { app.summary_opts.script_page_cursor = page_count - 1 } } if clicked(clay.ID("btn_script_next")) && page_count > 0 { app.summary_opts.script_page_cursor += 1 if app.summary_opts.script_page_cursor >= page_count { app.summary_opts.script_page_cursor = 0 } } } if screen == .Panels { panel_count := count_script_panels(app.controller.state.script) if clicked(clay.ID("btn_panels_prev")) && panel_count > 0 { app.summary_opts.panel_cursor -= 1 if app.summary_opts.panel_cursor < 0 { app.summary_opts.panel_cursor = panel_count - 1 } } if clicked(clay.ID("btn_panels_next")) && panel_count > 0 { app.summary_opts.panel_cursor += 1 if app.summary_opts.panel_cursor >= panel_count { app.summary_opts.panel_cursor = 0 } } } if screen == .Layout { layout_count := len(app.controller.state.page_layouts) if clicked(clay.ID("btn_layout_prev")) && layout_count > 0 { app.summary_opts.layout_page_cursor -= 1 if app.summary_opts.layout_page_cursor < 0 { app.summary_opts.layout_page_cursor = layout_count - 1 } app.layout_selected_panel = -1 } if clicked(clay.ID("btn_layout_next")) && layout_count > 0 { app.summary_opts.layout_page_cursor += 1 if app.summary_opts.layout_page_cursor >= layout_count { app.summary_opts.layout_page_cursor = 0 } app.layout_selected_panel = -1 } } if screen == .Bubbles { layout_count := len(app.controller.state.page_layouts) if clicked(clay.ID("btn_bubbles_prev_page")) && layout_count > 0 { app.summary_opts.bubble_page_cursor -= 1 if app.summary_opts.bubble_page_cursor < 0 { app.summary_opts.bubble_page_cursor = layout_count - 1 } app.summary_opts.bubble_panel_cursor = 0 app.summary_opts.bubble_edit_cursor = 0 } if clicked(clay.ID("btn_bubbles_next_page")) && layout_count > 0 { app.summary_opts.bubble_page_cursor += 1 if app.summary_opts.bubble_page_cursor >= layout_count { app.summary_opts.bubble_page_cursor = 0 } app.summary_opts.bubble_panel_cursor = 0 app.summary_opts.bubble_edit_cursor = 0 } if layout_count > 0 { page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) layout_val := app.controller.state.page_layouts[page_idx] panel_count := len(layout_val.panels) if clicked(clay.ID("btn_bubbles_prev_panel")) && panel_count > 0 { app.summary_opts.bubble_panel_cursor -= 1 if app.summary_opts.bubble_panel_cursor < 0 { app.summary_opts.bubble_panel_cursor = panel_count - 1 } app.summary_opts.bubble_edit_cursor = 0 } if clicked(clay.ID("btn_bubbles_next_panel")) && panel_count > 0 { app.summary_opts.bubble_panel_cursor += 1 if app.summary_opts.bubble_panel_cursor >= panel_count { app.summary_opts.bubble_panel_cursor = 0 } app.summary_opts.bubble_edit_cursor = 0 } } } } handle_detail_clicks :: proc(app: ^GUI_App_State) { screen := app.controller.active_screen if screen == .Characters { char_count := len(app.controller.state.characters) // Parse Descriptions button if clicked(clay.ID("btn_char_parse_all")) { push_status(&app.status_msg, &app.action_log, action_parse_character_descriptions(&app.controller)) app.is_dirty = true } for i in 0.. 0 { idx := clamp_panel_cursor(panel_count, app.summary_opts.panel_cursor) panel, _, ok := panel_by_flat_index(app.controller.state.script, idx) if ok { is_editing := app.editing_panel_id == panel.panel_id if is_editing { if clicked(clay.ID("btn_panel_save")) { push_status(&app.status_msg, &app.action_log, action_update_panel_prompt(&app.controller, panel.panel_id, strings.clone(app.panel_edit_text))) app.editing_panel_id = "" app.selected_field = 0 app.is_dirty = true } } else { if clicked(clay.ID("btn_panel_edit")) { app.editing_panel_id = panel.panel_id app.panel_edit_text = panel.description app.selected_field = 9 } } if clicked(clay.ID("btn_panel_regenerate")) { push_status(&app.status_msg, &app.action_log, action_regenerate_panel(&app.controller, panel.panel_id)) } if clicked(clay.ID("field_panel_desc")) || clicked(clay.ID("field_panel_desc_missing")) || clicked(clay.ID("field_panel_desc_no_img")) { if is_editing { app.selected_field = 9 } } } for i in 0.. 0 && rl.IsMouseButtonPressed(.LEFT) { mouse := rl.GetMousePosition() for i in 0..= r.x && mouse.x <= r.x + r.width && mouse.y >= r.y && mouse.y <= r.y + r.height { if app.layout_selected_panel == i { app.layout_selected_panel = -1 } else { app.layout_selected_panel = i } break } } } } if screen == .Bubbles { layout_count := len(app.controller.state.page_layouts) if layout_count > 0 { if clicked(clay.ID("btn_bubble_auto_place")) { page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) layout_val := app.controller.state.page_layouts[page_idx] if len(layout_val.panels) > 0 { panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) panel_id := layout_val.panels[panel_idx].panel_id push_status(&app.status_msg, &app.action_log, action_auto_place_bubbles_for_panel(&app.controller, panel_id, layout_val)) app.is_dirty = true } } if clicked(clay.ID("btn_bubble_add")) { page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) layout_val := app.controller.state.page_layouts[page_idx] if len(layout_val.panels) > 0 { panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) panel_id := layout_val.panels[panel_idx].panel_id push_status(&app.status_msg, &app.action_log, action_add_bubble(&app.controller, panel_id)) app.is_dirty = true } } if clicked(clay.ID("field_bubble_text")) { page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) layout_val := app.controller.state.page_layouts[page_idx] if len(layout_val.panels) > 0 { panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) panel_id := layout_val.panels[panel_idx].panel_id bubble_count := count_bubbles_for_panel(app.controller.state.speech_bubbles, panel_id) if bubble_count > 0 && app.summary_opts.bubble_edit_cursor < bubble_count { bubbles := app.controller.state.speech_bubbles[panel_id] app.bubble_edit_text = bubbles[app.summary_opts.bubble_edit_cursor].text app.selected_field = 7 } } } if clicked(clay.ID("btn_bubble_save_text")) { page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) layout_val := app.controller.state.page_layouts[page_idx] if len(layout_val.panels) > 0 && len(app.bubble_edit_text) > 0 { panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) panel_id := layout_val.panels[panel_idx].panel_id if bubbles, ok := app.controller.state.speech_bubbles[panel_id]; ok { if app.summary_opts.bubble_edit_cursor < len(bubbles) { current_type := bubbles[app.summary_opts.bubble_edit_cursor].type push_status(&app.status_msg, &app.action_log, action_update_bubble(&app.controller, panel_id, app.summary_opts.bubble_edit_cursor, current_type, app.bubble_edit_text)) app.is_dirty = true } } } app.selected_field = 0 } } if layout_count > 0 { page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) layout_val := app.controller.state.page_layouts[page_idx] if len(layout_val.panels) > 0 { panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) panel_id := layout_val.panels[panel_idx].panel_id panel_panel_count := count_bubbles_for_panel(app.controller.state.speech_bubbles, panel_id) for i in 0.. 0 { app.summary_opts.bubble_edit_cursor -= 1 } } } bubble_count := count_bubbles_for_panel(app.controller.state.speech_bubbles, panel_id) if bubble_count > 0 && app.summary_opts.bubble_edit_cursor < bubble_count { types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect} for t in types { btn_id := fmt.tprintf("btn_btype_%d", int(t)) if clicked(clay.ID(btn_id)) { push_status(&app.status_msg, &app.action_log, action_update_bubble(&app.controller, panel_id, app.summary_opts.bubble_edit_cursor, t, "")) app.is_dirty = true } } b := app.controller.state.speech_bubbles[panel_id][app.summary_opts.bubble_edit_cursor] if clicked(clay.ID("btn_bubble_left")) { push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x - 0.05, b.position.y)) app.is_dirty = true } if clicked(clay.ID("btn_bubble_right")) { push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x + 0.05, b.position.y)) app.is_dirty = true } if clicked(clay.ID("btn_bubble_up")) { push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x, b.position.y - 0.05)) app.is_dirty = true } if clicked(clay.ID("btn_bubble_down")) { push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x, b.position.y + 0.05)) app.is_dirty = true } } } } } } process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek, has_fal_key: bool, pages_count: int, shift_down: bool, autosave_secs: int, compact_mode: bool, bp: shared.Breakpoint) { handle_nav_clicks(app) handle_field_clicks(app) handle_format_clicks(app, has_deepseek) handle_action_clicks(app, can_gen_panels, can_layout, can_export, pages_count, shift_down, proj_ok, export_ok, autosave_secs, has_fal_key) handle_workspace_nav(app) handle_detail_clicks(app) // Overlay clicks if app.show_confirm_overlay { confirm_yes := rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y) || clicked(clay.ID("confirm_yes")) confirm_no := rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N) || clicked(clay.ID("confirm_no")) if confirm_no { app.show_confirm_overlay = false app.pending_confirm = .None push_status(&app.status_msg, &app.action_log, "Cancelled destructive action") } else if confirm_yes { app.show_confirm_overlay = false msg := resolve_confirm_action_with_message(app.pending_confirm, &app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at) push_status(&app.status_msg, &app.action_log, msg) app.pending_confirm = .None } } if app.show_help_overlay { if rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.SLASH) { app.show_help_overlay = false push_status(&app.status_msg, &app.action_log, "Closed help overlay") } } } // ─── Help Overlay (Clay floating, animated alpha) ────────────────── declare_help_overlay :: proc(bp: shared.Breakpoint, alpha: f32 = 1) { if clay.UI(clay.ID("HelpBackdrop"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, backgroundColor = {0, 0, 0, f32(200 * alpha)}, floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 5}, }) {} card_w_pct: f32 = 0.7 card_h_pct: f32 = 0.8 if bp == .Compact { card_w_pct = 0.92; card_h_pct = 0.92 } if clay.UI(clay.ID("HelpCard"))({ layout = {sizing = {width = clay.SizingPercent(card_w_pct), height = clay.SizingPercent(card_h_pct)}, layoutDirection = .TopToBottom, padding = {top = 24, right = 28, bottom = 24, left = 28}, childGap = CLAY_SPACE_12}, backgroundColor = CLAY_BG_CARD, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XL), border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(1)}, floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .CenterCenter, parent = .CenterCenter}, zIndex = 10}, }) { if clay.UI(clay.ID("HelpHeader"))({ layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}, }) { clay_title_text("Keyboard Shortcuts", color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_XL) clay.UI(clay.ID("HelpHeaderSpacer"))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) clay_body_text("Esc or /", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } declare_divider("HelpDiv1") clay_label_text("NAVIGATION") clay_body_text("1..8 screens | TAB fields | click to focus | F11 pages | F12 project", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) clay_label_text("CORE ACTIONS") clay_body_text("F5 script F6 panels F7 layout F8 export F9 next F10 auto-all", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) clay_body_text("Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) clay_label_text("CLIPBOARD + LOGS") clay_body_text("Ctrl+L clear log | Ctrl+V paste | Ctrl+0 reset helpers | Ctrl+Backspace clear", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) clay_label_text("PATHS") clay_body_text("Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) clay_label_text("AUTOSAVE") clay_body_text("Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) clay_label_text("SAFETY") clay_body_text("Dirty guard: Shift-click New/Open | Ctrl+Shift+N / Ctrl+Shift+O", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) } } // ─── Confirm Overlay (Clay floating, animated alpha) ──────────────── declare_confirm_overlay :: proc(action: Pending_Confirm_Action, bp: shared.Breakpoint, alpha: f32 = 1) { if clay.UI(clay.ID("ConfirmBackdrop"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, backgroundColor = {0, 0, 0, f32(200 * alpha)}, floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 15}, }) {} if clay.UI(clay.ID("ConfirmCard"))({ layout = {sizing = {width = clay.SizingPercent(0.6), height = clay.SizingFit({min = 160, max = 380})}, layoutDirection = .TopToBottom, padding = {top = 24, right = 28, bottom = 24, left = 28}, childGap = CLAY_SPACE_12}, backgroundColor = CLAY_BG_CARD, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XL), border = {color = CLAY_ERROR_DIM, width = clay.BorderOutside(2)}, floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .CenterCenter, parent = .CenterCenter}, zIndex = 20}, }) { if clay.UI(clay.ID("ConfirmAccentBar"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(3)}}, backgroundColor = CLAY_ERROR, cornerRadius = clay.CornerRadiusAll(2), }) {} clay_title_text("Confirm Action", color = CLAY_ERROR, size = CLAY_FONT_SIZE_XL) action_label := "reset" if action == .Open_Project { action_label = "open a different project" } clay_body_text(fmt.tprintf("You have unsaved changes. Do you want to %s?", action_label), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) if clay.UI(clay.ID("ConfirmBtnRow"))({layout = clay_row_layout()}) { declare_button_danger("confirm_yes", "Confirm") declare_button_soft("confirm_no", "Cancel") } clay_body_text("Enter/Y confirm | Esc/N cancel", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } } // ─── Toast (Clay floating) ────────────────────────────────────────── declare_toast :: proc(log: ^Action_Log) { if log.count == 0 { return } age := rl.GetTime() - log.last_push_at if age > 2.8 { return } idx := (log.count - 1) % len(log.entries) if idx < 0 { idx += len(log.entries) } msg := log.entries[idx] bg := CLAY_ERROR if is_warning_message(msg) { bg = CLAY_WARNING } else if !is_error_message(msg) { bg = CLAY_SUCCESS } if clay.UI(clay.ID("Toast"))({ layout = {sizing = {width = clay.SizingFit({min = 280, max = 0}), height = clay.SizingFixed(f32(CLAY_HEIGHT_SM))}, padding = {top = 4, right = 12, bottom = 4, left = 12}, childAlignment = {y = .Center}}, backgroundColor = bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), floating = {offset = {f32(228), 70}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 30}, }) { clay_body_text(msg, color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_XS) } } // ─── Raygui Field Overlay ────────────────────────────────────────── field_id_for_index :: proc(idx: int) -> string { switch idx { case 0: return "field_idea" case 1: return "field_genre" case 2: return "field_audience" case 3: return "field_export" case 4: return "field_pages" case 5: return "field_project" case 6: return "field_autosave" case 7: return "field_bubble_text" case 8: return "field_char_desc" case 9: return "field_panel_prompt" } return "" } field_ptr_for_index :: proc(app: ^GUI_App_State, idx: int) -> ^string { switch idx { case 0: return &app.controller.state.story_idea case 1: return &app.controller.state.story_genre case 2: return &app.controller.state.target_audience case 3: return &app.export_path case 4: return &app.local_script_pages case 5: return &app.project_path case 6: return &app.autosave_interval_text case 7: return &app.bubble_edit_text case 8: return &app.char_edit_text case 9: return &app.panel_edit_text } return nil } render_raygui_fields :: proc(app: ^GUI_App_State) { sf := app.selected_field fid := field_id_for_index(sf) if len(fid) == 0 { return } eid := clay.GetElementId(clay.MakeString(fid)) data := clay.GetElementData(eid) if !data.found { return } bounds := rl.Rectangle { x = f32(data.boundingBox.x), y = f32(data.boundingBox.y), width = f32(data.boundingBox.width), height = f32(data.boundingBox.height), } fp := field_ptr_for_index(app, sf) if fp == nil { return } // Copy field string → buffer for GuiTextBox src := fp^ if len(src) >= FIELD_BUF_SIZE { src = src[:FIELD_BUF_SIZE-1] } for i in 0 ..< len(src) { app.field_buf[i] = src[i] } app.field_buf[len(src)] = 0 // Call raygui GuiTextBox — handles rendering + input, modifies buffer in-place rl.GuiTextBox(bounds, cstring(&app.field_buf[0]), c.int(FIELD_BUF_SIZE), true) // Sync buffer → field string (pick up GuiTextBox edits) new_len := 0 for app.field_buf[new_len] != 0 && new_len < FIELD_BUF_SIZE-1 { new_len += 1 } if new_len != len(src) { fp^ = string(app.field_buf[:new_len]) app.is_dirty = true } } // ─── Animation Updates ───────────────────────────────────────────── lerp_f32 :: proc(a, b, t: f32) -> f32 { return a + (b - a) * t } smooth_towards :: proc(current, target, dt, duration: f32) -> f32 { if abs(current - target) < 0.5 { return target } k := 1 - math.pow_f32(0.01, dt / duration) return lerp_f32(current, target, k) } update_sidebar_anim :: proc(app: ^GUI_App_State, bp: shared.Breakpoint, dt: f32) { target_w := f32(sidebar_width(bp, app.sidebar_collapsed, 0)) app.sidebar_anim = smooth_towards(app.sidebar_anim, target_w, dt, ANIM_DURATION_SIDEBAR) } update_overlay_anim :: proc(app: ^GUI_App_State, dt: f32) { target_alpha: f32 = 0 if app.show_help_overlay || app.show_confirm_overlay { target_alpha = 1 } app.overlay_alpha = smooth_towards(app.overlay_alpha, target_alpha, dt, ANIM_DURATION_OVERLAY) } update_slide_anim :: proc(app: ^GUI_App_State, dt: f32) { current := app.controller.active_screen if current != app.prev_screen && app.slide_progress >= 1 { app.slide_progress = 0 } if app.slide_progress < 1 { app.slide_progress += dt / ANIM_DURATION_SLIDE if app.slide_progress > 1 { app.slide_progress = 1 app.slide_offset = 0 app.prev_screen = current } else { // Ease-out cubic: offset goes from 60 → 0 t := app.slide_progress eased := 1 - (1 - t) * (1 - t) * (1 - t) app.slide_offset = 60 * (1 - eased) } } }