diff --git a/odin/src/adapters/deepseek.odin b/odin/src/adapters/deepseek.odin index 50e0253..3d4ab41 100644 --- a/odin/src/adapters/deepseek.odin +++ b/odin/src/adapters/deepseek.odin @@ -4,6 +4,7 @@ import json "core:encoding/json" import "core:fmt" import "core:os" import "core:strings" +import "core:time" import "../core" import "../shared" @@ -816,7 +817,8 @@ generate_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: } if attempt < attempts && shared.should_retry(last_err) { - _ = backoff_ms(client.initial_backoff_ms, attempt) + delay_ms := backoff_ms(client.initial_backoff_ms, attempt) + time.sleep(time.Duration(delay_ms) * time.Millisecond) continue } break diff --git a/odin/src/adapters/fal.odin b/odin/src/adapters/fal.odin index 412e3f1..dd7709d 100644 --- a/odin/src/adapters/fal.odin +++ b/odin/src/adapters/fal.odin @@ -4,6 +4,7 @@ import json "core:encoding/json" import "core:fmt" import "core:os" import "core:strings" +import "core:time" import "../core" import "../shared" @@ -299,7 +300,8 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: } if attempt < attempts && shared.should_retry(last_err) { - _ = fal_backoff_ms(client.initial_backoff_ms, attempt) + delay_ms := fal_backoff_ms(client.initial_backoff_ms, attempt) + time.sleep(time.Duration(delay_ms) * time.Millisecond) continue } break @@ -387,7 +389,8 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core } if attempt < attempts && shared.should_retry(last_err) { - _ = fal_backoff_ms(client.initial_backoff_ms, attempt) + delay_ms := fal_backoff_ms(client.initial_backoff_ms, attempt) + time.sleep(time.Duration(delay_ms) * time.Millisecond) continue } break diff --git a/odin/src/gui/actions.odin b/odin/src/gui/actions.odin index ed33756..fb3645d 100644 --- a/odin/src/gui/actions.odin +++ b/odin/src/gui/actions.odin @@ -78,8 +78,8 @@ action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string if controller.state.panel_images == nil { controller.state.panel_images = make(map[string]core.Panel_Image) } - // Clone URL to persistent pool to survive frame resets - img.url = pool_clone(img.url) + // Clone URL into owned heap storage so it survives across frames. + img.url = strings.clone(img.url) controller.state.panel_images[panel_id] = img return fmt.aprintf("Generated panel %s", panel_id) } @@ -393,15 +393,15 @@ run_panels_action :: proc(controller: ^ui.App_Controller, queue: ^adapters.Fal_G delete(img.prompt) } delete(controller.state.panel_images) - // Clone URLs to persistent pool before storing + // Clone URLs/prompts into owned heap storage before storing cloned: map[string]core.Panel_Image for pid, img in images { cloned[pid] = core.Panel_Image{ - url = pool_clone(img.url), + url = strings.clone(img.url), width = img.width, height = img.height, seed = img.seed, - prompt = pool_clone(img.prompt), + prompt = strings.clone(img.prompt), } } controller.state.panel_images = cloned diff --git a/odin/src/gui/detail_panels.odin b/odin/src/gui/detail_panels.odin index 2f321d9..f9d6f51 100644 --- a/odin/src/gui/detail_panels.odin +++ b/odin/src/gui/detail_panels.odin @@ -564,7 +564,7 @@ draw_layout_wireframe :: proc(app: ^GUI_App_State) { drawn_image := false if panel_img, img_ok := app.controller.state.panel_images[panel_layout.panel_id]; img_ok { - img_url := pool_clone(panel_img.url) + img_url := frame_pool_clone(panel_img.url) tex, loaded := load_panel_texture(&app.panel_textures, panel_layout.panel_id, img_url) if loaded && tex.id != 0 && cw > 4 && ch > 4 { scale_x := cw / f32(tex.width) diff --git a/odin/src/gui/panel_editor_core.odin b/odin/src/gui/panel_editor_core.odin index e7dd54a..d501a9c 100644 --- a/odin/src/gui/panel_editor_core.odin +++ b/odin/src/gui/panel_editor_core.odin @@ -15,7 +15,7 @@ editor_open :: proc(app: ^GUI_App_State, panel_id: string) -> bool { panel_img, has_img := app.controller.state.panel_images[panel_id] if !has_img { return false } - img_url := pool_clone(panel_img.url) + img_url := frame_pool_clone(panel_img.url) local_path := resolve_image_path(img_url) if len(local_path) == 0 { return false } diff --git a/odin/src/gui/runtime.odin b/odin/src/gui/runtime.odin index d03c263..bcf3873 100644 --- a/odin/src/gui/runtime.odin +++ b/odin/src/gui/runtime.odin @@ -9,6 +9,7 @@ import filepath "core:path/filepath" import "core:strings" import rl "vendor:raylib" import "../core" +import "../osdialog" import "../shared" import "../ui" @@ -63,33 +64,53 @@ GUI_App_State :: struct { prev_screen: ui.App_Screen, slide_offset: f32, // horizontal offset for screen transitions slide_progress: f32, // 0→1 progress of slide animation + // ─── Cached API-key presence (refreshed ~1/sec) ─────────────── + // load_config() returns temp-allocated strings that only live one frame, + // so we can't keep the Config around — but we only need the booleans for + // the UI. Refreshing on a time interval avoids re-reading .env every frame. + cached_has_deepseek: bool, + cached_has_fal: bool, + config_check_time: f64, // wall-clock time of last refresh } clicked :: proc(id: clay.ElementId) -> bool { return clay.PointerOver(id) && rl.IsMouseButtonPressed(.LEFT) } -// ─── Persistent String Pool (survives temp-allocator resets) ──── +// ─── Per-frame String Pool ──────────────────────────────────────── +// This pool holds strings that must survive across calls *within* a single +// frame (e.g. a downloaded panel path handed from resolve_image_path to +// LoadTexture). It is reset every frame, so it never grows unbounded. Strings +// that must outlive the frame are stored via strings.clone into owned storage. @(private) -persistent_pool: [256 * 1024]u8 +frame_pool: [256 * 1024]u8 @(private) -persistent_offset: int +frame_pool_offset: int +// frame_pool_clone copies a string into the per-frame pool. The result is only +// valid until the next reset_frame_pool() call (once per frame). @(private) -pool_clone :: proc(s: string) -> string { +frame_pool_clone :: proc(s: string) -> string { if len(s) == 0 { return "" } total := len(s) + 1 - if persistent_offset + total > len(persistent_pool) { + if frame_pool_offset + total > len(frame_pool) { // Pool full — fall back to strings.clone (heap) instead of corrupting old data return strings.clone(s) } - start := persistent_offset + start := frame_pool_offset for c, i in s { - persistent_pool[start + i] = u8(c) + frame_pool[start + i] = u8(c) } - persistent_pool[start + len(s)] = 0 - persistent_offset += total - return string(persistent_pool[start:start+len(s)]) + frame_pool[start + len(s)] = 0 + frame_pool_offset += total + return string(frame_pool[start:start+len(s)]) +} + +// reset_frame_pool reclaims the entire per-frame pool. Called once per frame +// at the top of the loop, before any frame_pool_clone use. +@(private) +reset_frame_pool :: proc() { + frame_pool_offset = 0 } // ─── Panel Image Loading ───────────────────────────────────────── @@ -117,7 +138,8 @@ resolve_image_path :: proc(url: string) -> string { } if cached_path, ok := download_path_cache[url]; ok { - return pool_clone(cached_path) + // Cache stores heap-owned strings; return a per-frame clone for the caller. + return frame_pool_clone(cached_path) } if _, failed := download_failed_cache[url]; failed { @@ -138,8 +160,9 @@ resolve_image_path :: proc(url: string) -> string { 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) + // Store a heap-owned copy in the persistent cache map. + download_path_cache[url] = strings.clone(local_path) + return frame_pool_clone(local_path) } cmd := [6]string{"curl", "-L", "-sS", "-o", local_path, url} @@ -150,8 +173,8 @@ resolve_image_path :: proc(url: string) -> string { return "" } - download_path_cache[url] = pool_clone(local_path) - return pool_clone(local_path) + download_path_cache[url] = strings.clone(local_path) + return frame_pool_clone(local_path) } @(private) @@ -240,15 +263,27 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { compact_mode := shared.is_compact(screen_h) dt := rl.GetFrameTime() + // Reclaim the per-frame string pool before any frame_pool_clone use. + reset_frame_pool() + 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 + + // load_config() returns temp-allocated strings (one frame of life), so + // we only cache the booleans we actually need and refresh ~1/sec instead + // of re-reading .env + environment every frame. + if rl.GetTime() - app.config_check_time >= 1.0 { + cfg := shared.load_config() + app.cached_has_deepseek = len(cfg.deepseek_api_key) > 0 + app.cached_has_fal = len(cfg.fal_api_key) > 0 + app.config_check_time = rl.GetTime() + } + has_deepseek_key := app.cached_has_deepseek + has_fal_key := app.cached_has_fal clay_update_dimensions(screen_w, screen_h) clay_update_input() @@ -584,8 +619,10 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { 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) + if app.editor.show_debug_overlay { + 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) @@ -653,9 +690,13 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { rl.EndDrawing() } - core.dispose_state(state) - state^ = app.controller.state - app.controller.state = core.Comic_State{} + // The controller took the state by value in new_controller, so it owns its + // own copy of every heap-backed field it allocated or reassigned during the + // session. Dispose that copy here. The caller's `state` (owned by main()) + // is left untouched for its own defer to clean up. We must NOT move the + // controller's state back into `state` — the two copies may share backing + // arrays (e.g. workflow.completed_steps), which would double-free. + core.dispose_state(&app.controller.state) return shared.ok() } @@ -689,6 +730,41 @@ handle_format_clicks :: proc(app: ^GUI_App_State, has_deepseek: bool) { 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)) } } +// ─── Native file dialogs (osdialog) ────────────────────────────────── +// The dialog returns a temp-allocator string; we clone it into the persistent +// path field. Guard with WINDOW_TOPMOST so the native dialog stays above the +// borderless raylib window on platforms that lose focus otherwise. +handle_browse_clicks :: proc(app: ^GUI_App_State) { + // Open Project — native .comic.json picker + if clicked(clay.ID("btn_browse_project")) { + picked := osdialog.open_file_dialog("", "Project Files:comic.json") + if len(picked) > 0 { + delete(app.project_path) + app.project_path = strings.clone(picked) + app.is_dirty = true + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Project: %s", app.project_path)) + } + } + // Export path — native save dialog, filter by current format + if clicked(clay.ID("btn_browse_export")) { + ext: string = "pdf" + switch app.export_format { + case .PDF: ext = "pdf" + case .PNG: ext = "png" + case .CBZ: ext = "cbz" + } + filters_str := fmt.tprintf("Export:%s", ext) + picked := osdialog.save_file_dialog("", "comic", filters_str) + if len(picked) > 0 { + delete(app.export_path) + app.export_path = strings.clone(picked) + app.is_dirty = true + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Export: %s", app.export_path)) + } + } +} + + 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?")) } @@ -1115,6 +1191,7 @@ process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_expo handle_nav_clicks(app) handle_field_clicks(app) handle_format_clicks(app, has_deepseek) + handle_browse_clicks(app) 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) diff --git a/odin/src/gui/workspaces.odin b/odin/src/gui/workspaces.odin index 2d3bbcc..e7f0ca4 100644 --- a/odin/src/gui/workspaces.odin +++ b/odin/src/gui/workspaces.odin @@ -149,7 +149,7 @@ declare_panel_card :: proc(app: ^GUI_App_State, panel: core.Panel, page_num, pan } if panel_img, has_img := app.controller.state.panel_images[panel.panel_id]; has_img { - img_url := pool_clone(panel_img.url) + img_url := frame_pool_clone(panel_img.url) _, loaded := load_panel_texture(&app.panel_textures, panel.panel_id, img_url) if loaded { tex_ptr := &app.panel_textures[panel.panel_id]