package gui import clay "clay:." import "core:fmt" 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. GUI_App_State :: struct { controller: ui.App_Controller, selected_field: int, // 0 idea, 1 genre, 2 audience, 3 export_path, 4 pages, 5 project_path, 6 autosave_interval export_path: string, project_path: string, local_script_pages: string, autosave_interval_text: string, export_format: core.Export_Format, use_deepseek_script: 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, } clicked :: proc(id: clay.ElementId) -> bool { return clay.PointerOver(id) && rl.IsMouseButtonPressed(.LEFT) } 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.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_deepseek_script = 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{} push_status(&app.status_msg, &app.action_log, app.status_msg) defer action_log_dispose(&app.action_log) defer delete(app.status_msg) for !rl.WindowShouldClose() { screen_w := rl.GetScreenWidth() screen_h := rl.GetScreenHeight() compact_mode := shared.is_compact(screen_h) cfg := shared.load_config() has_deepseek_key := len(cfg.deepseek_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) } if !interaction_locked { 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 ──────────────────────────────────────────── if !interaction_locked { for { ch := rl.GetCharPressed() if ch == 0 { break } if ch < 32 || ch > 126 { continue } if app.selected_field == 6 && (ch < '0' || ch > '9') { continue } switch app.selected_field { case 0: append_char(&app.controller.state.story_idea, ch) case 1: append_char(&app.controller.state.story_genre, ch) case 2: append_char(&app.controller.state.target_audience, ch) case 3: append_char(&app.export_path, ch) case 4: append_char(&app.local_script_pages, ch) case 5: append_char(&app.project_path, ch) case 6: append_char(&app.autosave_interval_text, ch) } app.is_dirty = true } if rl.IsKeyPressed(.BACKSPACE) { switch app.selected_field { case 0: pop_char(&app.controller.state.story_idea) case 1: pop_char(&app.controller.state.story_genre) case 2: pop_char(&app.controller.state.target_audience) case 3: pop_char(&app.export_path) case 4: pop_char(&app.local_script_pages) case 5: pop_char(&app.project_path) case 6: pop_char(&app.autosave_interval_text) } app.is_dirty = true } } // ─── 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(.G) { if app.use_deepseek_script { app.use_deepseek_script = false push_status(&app.status_msg, &app.action_log, "Script source: Local") } else { if !has_deepseek_key { push_status(&app.status_msg, &app.action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)") } else { app.use_deepseek_script = true push_status(&app.status_msg, &app.action_log, "Script source: DeepSeek") } } } 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, run_script_action(&app.controller, pages_count, app.use_deepseek_script, &app.is_dirty)) } if rl.IsKeyPressed(.F6) { push_status(&app.status_msg, &app.action_log, run_panels_action(&app.controller, can_generate_panels, &app.is_dirty)) } 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.use_deepseek_script, &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.use_deepseek_script, &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 ────────────────────────────── 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 ─────────────────────────────────────── declare_sidebar(&app) // ─── Main Content ──────────────────────────────── if clay.UI(clay.ID("MainArea"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom}, }) { declare_pipeline_bar(&app) declare_workspace(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, pages_count) next_hint := gui_next_hint_with_source(app.controller, app.use_deepseek_script) declare_bottom_bar(&app, can_generate_panels, can_layout, can_export, next_hint) } } // ─── Floating Overlays (Clay) ────────────────────────────── if app.show_help_overlay { declare_help_overlay() } if app.show_confirm_overlay { declare_confirm_overlay(app.pending_confirm) } 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, pages_count, shift_down, autosave_secs, compact_mode) // ─── Render ──────────────────────────────────────────────────── rl.BeginDrawing() rl.ClearBackground(BG_BASE) clay_raylib_render(&render_commands) 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)) } if clicked(clay.ID("btn_local")) { app.use_deepseek_script = false; push_status(&app.status_msg, &app.action_log, "Script source: Local") } if clicked(clay.ID("btn_deepseek")) { if !has_deepseek { push_status(&app.status_msg, &app.action_log, "DeepSeek key missing") } else { app.use_deepseek_script = true; push_status(&app.status_msg, &app.action_log, "Script source: DeepSeek") } } } 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) { 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, run_script_action(&app.controller, pages_count, app.use_deepseek_script, &app.is_dirty)) } if clicked(clay.ID("btn_panels")) { push_status(&app.status_msg, &app.action_log, run_panels_action(&app.controller, can_gen_panels, &app.is_dirty)) } 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.use_deepseek_script, &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.use_deepseek_script, &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_help")) { toggle_help_overlay(&app.show_help_overlay) } 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) 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 } } 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 } } } 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 == .Panels { if clicked(clay.ID("btn_panel_regenerate")) { panel_count := count_script_panels(app.controller.state.script) if panel_count > 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 { push_status(&app.status_msg, &app.action_log, action_regenerate_panel(&app.controller, panel.panel_id)) } } } panel_count := count_script_panels(app.controller.state.script) if panel_count > 0 { for i in 0.. 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 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 } } } } } } } process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool, pages_count: int, shift_down: bool, autosave_secs: int, compact_mode: bool) { 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) 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") } } } // ─── Clay UI Primitives ─────────────────────────────────────────── declare_nav_chip :: proc(id: string, label: string, active: bool) { bg: clay.Color = clay.Color{0, 0, 0, 0} if active { bg = CLAY_NAV_HOVER_BG } if clay.UI(clay.ID(id))({ layout = {sizing = {width = clay.SizingFit({min = 70, max = 34}), height = clay.SizingFixed(34)}, padding = {top = 4, right = 12, bottom = 4, left = 12}, childAlignment = {x = .Center, y = .Center}}, backgroundColor = bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), }) { text_color: clay.Color = CLAY_TEXT_SECONDARY if active { text_color = CLAY_TEXT_BRIGHT } clay_body_text(label, color = text_color) } } declare_button :: proc(id: string, label: string, bg, hover_bg: clay.Color) { is_hovered := clay.Hovered() current_bg: clay.Color = bg if is_hovered { current_bg = hover_bg } if clay.UI(clay.ID(id))({ layout = clay_button_layout(), backgroundColor = current_bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), }) { clay_body_text(label, color = CLAY_TEXT_PRIMARY) } } declare_button_default :: proc(id: string, label: string) { declare_button(id, label, CLAY_BTN_DEFAULT, CLAY_BTN_DEFAULT_HOVER) } declare_button_danger :: proc(id: string, label: string) { declare_button(id, label, CLAY_BTN_DANGER, CLAY_BTN_DANGER_HOVER) } declare_button_soft :: proc(id: string, label: string) { declare_button(id, label, CLAY_BTN_SOFT, CLAY_BTN_SOFT_HOVER) } declare_button_primary :: proc(id: string, label: string) { declare_button(id, label, CLAY_ACCENT, CLAY_ACCENT_HOVER) } declare_button_small :: proc(id: string, label: string) { if clay.UI(clay.ID(id))({ layout = {sizing = {width = clay.SizingFit({min = 40, max = 24}), height = clay.SizingFixed(24)}, padding = {top = 2, right = 8, bottom = 2, left = 8}, childAlignment = {x = .Center, y = .Center}}, backgroundColor = CLAY_BTN_DEFAULT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), }) { clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = CLAY_TEXT_SECONDARY}) } } declare_button_recommended :: proc(id: string, label: string) { if clay.UI(clay.ID(id))({ layout = clay_button_layout(), backgroundColor = CLAY_ACCENT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), border = {color = CLAY_ACCENT_GLOW, width = clay.BorderOutside(2)}, }) { clay_body_text(label, color = CLAY_TEXT_BRIGHT) } } declare_status_badge :: proc(id: string, label: string, ok: bool) { badge_color: clay.Color = CLAY_ERROR if ok { badge_color = CLAY_SUCCESS } badge_bg: clay.Color = CLAY_ERROR_DIM if ok { badge_bg = CLAY_SUCCESS_DIM } if clay.UI(clay.ID(id))({ layout = {sizing = {width = clay.SizingFit({min = 70, max = 28}), height = clay.SizingFixed(26)}, padding = {top = 2, right = 8, bottom = 2, left = 8}, childAlignment = {x = .Center, y = .Center}}, backgroundColor = badge_bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), border = {color = badge_color, width = clay.BorderOutside(1)}, }) { clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = badge_color}) } } // ─── Help Overlay (Clay floating) ────────────────────────────────── declare_help_overlay :: proc() { // Backdrop if clay.UI(clay.ID("HelpBackdrop"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, backgroundColor = {0, 0, 0, 180}, }) {} // Card if clay.UI(clay.ID("HelpCard"))({ layout = {sizing = {width = clay.SizingFixed(860), height = clay.SizingFixed(642)}, layoutDirection = .TopToBottom, padding = {top = 28, right = 30, bottom = 28, left = 30}, childGap = 16}, backgroundColor = CLAY_BG_CARD, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(2)}, floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .CenterCenter, parent = .CenterCenter}, zIndex = 10}, }) { clay_title_text("Keyboard Shortcuts", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_2XL) clay_body_text("Navigation", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) clay_muted_text("1..8 screens | TAB fields | click to focus | F11 pages | F12 project") clay_body_text("Core Actions", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) clay_muted_text("F5 script F6 panels F7 layout F8 export F9 next F10 auto-all") clay_muted_text("Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary") clay_body_text("Clipboard + Logs", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) clay_muted_text("Ctrl+L clear log | Ctrl+Shift+L copy log | Ctrl+Shift+T/B log view | Ctrl+Shift+Z reset") clay_muted_text("Ctrl+Shift+C status | Ctrl+Shift+Y diag copy | Ctrl+Shift+R diag file | Ctrl+Shift+W report") clay_muted_text("Ctrl+0 reset helpers | Ctrl+V paste | Ctrl+Shift+I copy | Ctrl+Backspace clear") clay_body_text("Paths", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) clay_muted_text("Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export") clay_muted_text("Ctrl+Shift+J fix project | F fix export | K/M quick-fix P/E | U fix all") clay_body_text("Autosave", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) clay_muted_text("Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60") clay_body_text("Safety", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) clay_muted_text("Dirty guard: Shift-click New/Open | Keyboard confirm: Ctrl+Shift+N / Ctrl+Shift+O") clay_body_text("Close help: Esc or /", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_MD) } } // ─── Confirm Overlay (Clay floating) ──────────────────────────────── declare_confirm_overlay :: proc(action: Pending_Confirm_Action) { // Backdrop if clay.UI(clay.ID("ConfirmBackdrop"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, backgroundColor = {0, 0, 0, 180}, }) {} // Card if clay.UI(clay.ID("ConfirmCard"))({ layout = {sizing = {width = clay.SizingFixed(520), height = clay.SizingFixed(230)}, layoutDirection = .TopToBottom, padding = {top = 34, right = 30, bottom = 24, left = 30}, childGap = 12}, backgroundColor = CLAY_BG_CARD, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(2)}, floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .CenterCenter, parent = .CenterCenter}, zIndex = 20}, }) { // Accent bar at top if clay.UI(clay.ID("ConfirmAccentBar"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(8)}}, backgroundColor = CLAY_ACCENT, cornerRadius = clay.CornerRadiusAll(4), }) {} clay_title_text("Confirm destructive 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) if clay.UI(clay.ID("ConfirmBtnRow"))({layout = clay_row_layout()}) { declare_button_danger("confirm_yes", "Confirm") declare_button_soft("confirm_no", "Cancel") } clay_muted_text("Enter/Y confirm | Esc/N cancel") } } // ─── 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 = 300, max = 0}), height = clay.SizingFixed(34)}, padding = {top = 6, right = 12, bottom = 6, left = 12}, childAlignment = {y = .Center}}, backgroundColor = bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), border = {color = clay.Color{255, 255, 255, 40}, width = clay.BorderOutside(1)}, floating = {offset = {f32(shared.LAYOUT.sidebar_width + 8), 70}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 30}, }) { clay_body_text(msg, color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_SM) } } // ─── Stat Chip (Clay) ────────────────────────────────────────────── declare_stat_chip :: proc(label: string, value: int) { value_text := fmt.tprintf("%d", value) if clay.UI(clay.ID("StatChip", u32(label[0])))({ layout = {sizing = {width = clay.SizingFixed(90), height = clay.SizingFixed(34)}, padding = {top = 4, right = 8, bottom = 4, left = 8}, childAlignment = {x = .Center, y = .Center}}, backgroundColor = clay.Color{40, 40, 55, 255}, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), border = {color = clay.Color{55, 55, 75, 255}, width = clay.BorderOutside(1)}, }) { clay_muted_text(label) clay_body_text(value_text, color = CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) } }