822 lines
25 KiB
Odin
822 lines
25 KiB
Odin
package gui
|
|
|
|
import "core:fmt"
|
|
import "core:strconv"
|
|
import "core:strings"
|
|
import rl "vendor:raylib"
|
|
import "../adapters"
|
|
import "../core"
|
|
import "../shared"
|
|
import "../ui"
|
|
|
|
action_generate_deepseek_script :: proc(controller: ^ui.App_Controller, pages: int) -> string {
|
|
cfg := shared.load_config()
|
|
if len(cfg.deepseek_api_key) == 0 {
|
|
return "DeepSeek key missing (set DEEPSEEK_API_KEY)"
|
|
}
|
|
opts := adapters.Generate_Script_Options{
|
|
story_idea = controller.state.story_idea,
|
|
genre = controller.state.story_genre,
|
|
art_style = controller.state.art_style,
|
|
num_pages = pages,
|
|
audience = controller.state.target_audience,
|
|
}
|
|
script, gerr := adapters.generate_comic_script_stub(cfg, opts)
|
|
if !shared.is_ok(gerr) {
|
|
return fmt.aprintf("DeepSeek script failed: %s", gerr.message)
|
|
}
|
|
core.dispose_script(&controller.state.script)
|
|
controller.state.script = script
|
|
|
|
// Auto-parse character descriptions into structured prompt templates
|
|
parsed_chars := core.update_all_characters_from_descriptions(script.characters)
|
|
controller.state.characters = parsed_chars
|
|
|
|
controller.active_screen = .Script
|
|
controller.state.workflow.current_step = .Script_Review
|
|
return fmt.aprintf("Generated DeepSeek script with %d characters, %d pages", len(parsed_chars), len(script.pages))
|
|
}
|
|
|
|
action_parse_character_descriptions :: proc(controller: ^ui.App_Controller) -> string {
|
|
if len(controller.state.characters) == 0 {
|
|
return "No characters to parse"
|
|
}
|
|
|
|
updated := core.update_all_characters_from_descriptions(controller.state.characters)
|
|
// Dispose old characters array and replace
|
|
delete(controller.state.characters)
|
|
controller.state.characters = updated
|
|
|
|
return fmt.aprintf("Parsed %d character descriptions", len(updated))
|
|
}
|
|
|
|
action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string {
|
|
cfg := shared.load_config()
|
|
if len(cfg.fal_api_key) == 0 {
|
|
return "FAL_API_KEY is missing"
|
|
}
|
|
|
|
target_panel: core.Panel
|
|
found := false
|
|
for page in controller.state.script.pages {
|
|
for p in page.panels {
|
|
if p.panel_id == panel_id {
|
|
target_panel = p
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found { break }
|
|
}
|
|
if !found { return "Panel not found" }
|
|
|
|
img, err := adapters.generate_panel_image_stub(cfg, target_panel, controller.state.characters, controller.state.art_style, "gui-project", controller.state.story_genre, controller.state.target_audience)
|
|
if !shared.is_ok(err) {
|
|
return fmt.aprintf("FAL fail: %s", err.message)
|
|
}
|
|
|
|
if controller.state.panel_images == nil {
|
|
controller.state.panel_images = make(map[string]core.Panel_Image)
|
|
}
|
|
// 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)
|
|
}
|
|
|
|
action_update_panel_prompt :: proc(controller: ^ui.App_Controller, panel_id: string, new_desc: string) -> string {
|
|
for i in 0..<len(controller.state.script.pages) {
|
|
for j in 0..<len(controller.state.script.pages[i].panels) {
|
|
if controller.state.script.pages[i].panels[j].panel_id == panel_id {
|
|
controller.state.script.pages[i].panels[j].description = new_desc
|
|
return "Panel description updated"
|
|
}
|
|
}
|
|
}
|
|
return "Panel not found"
|
|
}
|
|
|
|
action_layout_auto :: proc(controller: ^ui.App_Controller) -> string {
|
|
panels := collect_script_panels(controller.state.script)
|
|
defer delete(panels)
|
|
if len(panels) == 0 {
|
|
return "No script panels for layout"
|
|
}
|
|
core.dispose_page_layouts(&controller.state.page_layouts)
|
|
controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "")
|
|
controller.active_screen = .Layout
|
|
controller.state.workflow.current_step = .Layout
|
|
return "Auto layout generated"
|
|
}
|
|
|
|
action_regenerate_page_layout :: proc(controller: ^ui.App_Controller, page_num: int) -> string {
|
|
if page_num < 0 || page_num >= len(controller.state.page_layouts) {
|
|
return "Invalid layout page"
|
|
}
|
|
|
|
layout := &controller.state.page_layouts[page_num]
|
|
panel_count := len(layout.panels)
|
|
if panel_count == 0 {
|
|
return "Page has no panels"
|
|
}
|
|
|
|
// For a simple visible regenerate effect, pick a random pattern that fits
|
|
pattern_ids := []string{
|
|
"single-splash", "grid-2x2", "grid-3x3", "dynamic-action",
|
|
"manga-spread", "dialogue-heavy", "cinematic-widescreen",
|
|
}
|
|
|
|
// Just pick the next valid pattern in the list (or wrap around)
|
|
start_idx := 0
|
|
for id, i in pattern_ids {
|
|
if id == layout.pattern_id {
|
|
start_idx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
new_pattern_id := layout.pattern_id
|
|
for i in 1..=len(pattern_ids) {
|
|
idx := (start_idx + i) % len(pattern_ids)
|
|
candidate := pattern_ids[idx]
|
|
if core.pattern_max_panels(candidate) >= panel_count {
|
|
new_pattern_id = candidate
|
|
break
|
|
}
|
|
}
|
|
|
|
pattern := core.get_layout_pattern_by_id(new_pattern_id)
|
|
layout.pattern_id = new_pattern_id
|
|
for i in 0..<panel_count {
|
|
cell_index := i
|
|
if cell_index >= len(pattern.cells) {
|
|
cell_index = len(pattern.cells) - 1
|
|
}
|
|
layout.panels[i].layout_cell = pattern.cells[cell_index]
|
|
}
|
|
|
|
return "Layout page regenerated"
|
|
}
|
|
|
|
export_format_name :: proc(f: core.Export_Format) -> string {
|
|
switch f {
|
|
case .PDF: return "PDF"
|
|
case .PNG: return "PNG"
|
|
case .CBZ: return "CBZ"
|
|
}
|
|
return "PDF"
|
|
}
|
|
|
|
parse_pages_or_default :: proc(s: string, def: int) -> int {
|
|
v, ok := strconv.parse_int(strings.trim_space(s))
|
|
if !ok || v <= 0 {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
parse_autosave_interval :: proc(s: string, def: int) -> int {
|
|
v, ok := strconv.parse_int(strings.trim_space(s))
|
|
if !ok {
|
|
return def
|
|
}
|
|
if v < 5 {
|
|
return 5
|
|
}
|
|
if v > 300 {
|
|
return 300
|
|
}
|
|
return v
|
|
}
|
|
|
|
set_autosave_interval_text :: proc(dst: ^string, seconds: int) -> string {
|
|
v := seconds
|
|
if v < 5 {
|
|
v = 5
|
|
}
|
|
if v > 300 {
|
|
v = 300
|
|
}
|
|
dst^ = fmt.aprintf("%d", v)
|
|
return fmt.aprintf("Autosave interval: %ds", v)
|
|
}
|
|
|
|
yn :: proc(v: bool) -> string {
|
|
if v {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
}
|
|
|
|
toggle_summary_show :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
|
|
#partial switch active_screen {
|
|
case .Script:
|
|
opts.script_show_all = !opts.script_show_all
|
|
return fmt.aprintf("Script summary show-all: %s", yn(opts.script_show_all))
|
|
case .Layout:
|
|
opts.layout_show_all = !opts.layout_show_all
|
|
return fmt.aprintf("Layout summary show-all: %s", yn(opts.layout_show_all))
|
|
}
|
|
return "Summary show toggle unavailable on this screen"
|
|
}
|
|
|
|
toggle_summary_sort :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
|
|
#partial switch active_screen {
|
|
case .Script:
|
|
opts.script_desc = !opts.script_desc
|
|
sort_name := "asc"
|
|
if opts.script_desc {
|
|
sort_name = "desc"
|
|
}
|
|
return fmt.aprintf("Script summary sort: %s", sort_name)
|
|
case .Layout:
|
|
opts.layout_desc = !opts.layout_desc
|
|
sort_name := "asc"
|
|
if opts.layout_desc {
|
|
sort_name = "desc"
|
|
}
|
|
return fmt.aprintf("Layout summary sort: %s", sort_name)
|
|
}
|
|
return "Summary sort toggle unavailable on this screen"
|
|
}
|
|
|
|
toggle_summary_show_if_supported :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
|
|
if active_screen == .Script || active_screen == .Layout {
|
|
return toggle_summary_show(active_screen, opts)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
toggle_summary_sort_if_supported :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
|
|
if active_screen == .Script || active_screen == .Layout {
|
|
return toggle_summary_sort(active_screen, opts)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
reset_project_session :: proc(controller: ^ui.App_Controller, is_dirty: ^bool, last_autosave_at: ^f64, touch_time: bool) -> string {
|
|
core.dispose_state(&controller.state)
|
|
controller.state = core.new_initial_state()
|
|
controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step)
|
|
is_dirty^ = false
|
|
if touch_time {
|
|
last_autosave_at^ = rl.GetTime()
|
|
}
|
|
return "Reset project"
|
|
}
|
|
|
|
open_project_session :: proc(controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, is_dirty: ^bool, last_autosave_at: ^f64) -> string {
|
|
normalize_project_path_field(project_path)
|
|
loaded, lerr := adapters.load_project(project_path^)
|
|
if !shared.is_ok(lerr) {
|
|
return lerr.message
|
|
}
|
|
core.dispose_state(&controller.state)
|
|
controller.state = loaded
|
|
controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step)
|
|
sync_export_path_to_project_dir(project_path^, export_path, export_format)
|
|
is_dirty^ = false
|
|
last_autosave_at^ = rl.GetTime()
|
|
return fmt.aprintf("Opened project: %s (export -> %s)", project_path^, export_path^)
|
|
}
|
|
|
|
resolve_confirm_action_with_message :: proc(action: Pending_Confirm_Action, controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, is_dirty: ^bool, last_autosave_at: ^f64) -> string {
|
|
switch action {
|
|
case .Reset_Project:
|
|
return reset_project_session(controller, is_dirty, last_autosave_at, true)
|
|
case .Open_Project:
|
|
return open_project_session(controller, project_path, export_path, export_format, is_dirty, last_autosave_at)
|
|
case .None:
|
|
return "No pending destructive action"
|
|
}
|
|
return "No pending destructive action"
|
|
}
|
|
|
|
save_project_session_with_message :: proc(project_path: ^string, state: core.Comic_State, is_dirty: ^bool, last_autosave_at, last_save_at: ^f64, success_prefix: string) -> string {
|
|
normalize_project_path_field(project_path)
|
|
err := adapters.save_project(project_path^, state)
|
|
if !shared.is_ok(err) {
|
|
return err.message
|
|
}
|
|
is_dirty^ = false
|
|
last_autosave_at^ = rl.GetTime()
|
|
last_save_at^ = last_autosave_at^
|
|
return fmt.aprintf("%s: %s", success_prefix, project_path^)
|
|
}
|
|
|
|
action_export :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format) -> string {
|
|
controller.state.workflow.is_generating = true
|
|
controller.state.workflow.generation_progress = 10
|
|
|
|
opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90}
|
|
controller.state.workflow.generation_progress = 40
|
|
|
|
err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts)
|
|
controller.state.workflow.generation_progress = 90
|
|
|
|
if !shared.is_ok(err) {
|
|
controller.state.workflow.is_generating = false
|
|
return err.message
|
|
}
|
|
controller.state.workflow.generation_progress = 100
|
|
controller.state.workflow.is_generating = false
|
|
controller.active_screen = .Export
|
|
controller.state.workflow.current_step = .Complete
|
|
controller.state.export_format = export_format
|
|
return fmt.aprintf("Exported %s", export_format_name(export_format))
|
|
}
|
|
|
|
gui_next_hint :: proc(controller: ui.App_Controller) -> string {
|
|
if len(controller.state.script.pages) == 0 {
|
|
return "generate script"
|
|
}
|
|
if len(controller.state.panel_images) == 0 {
|
|
return "generate panels"
|
|
}
|
|
if len(controller.state.page_layouts) == 0 {
|
|
return "layout auto"
|
|
}
|
|
return "export pdf"
|
|
}
|
|
|
|
action_run_next :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int) -> string {
|
|
hint := gui_next_hint(controller^)
|
|
switch hint {
|
|
case "generate script":
|
|
return action_generate_deepseek_script(controller, script_pages)
|
|
case "generate panels":
|
|
cfg := shared.load_config()
|
|
if len(cfg.fal_api_key) == 0 { return "FAL API key missing" }
|
|
return run_panels_action(controller, nil, nil)
|
|
case "layout auto":
|
|
return action_layout_auto(controller)
|
|
case "export pdf":
|
|
return action_export(controller, export_path, export_format)
|
|
}
|
|
return "No next action"
|
|
}
|
|
|
|
action_run_auto_all :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int) -> string {
|
|
for _ in 0..<4 {
|
|
msg := action_run_next(controller, export_path, export_format, script_pages)
|
|
if controller.active_screen == .Export {
|
|
return fmt.aprintf("Auto-all complete: %s", msg)
|
|
}
|
|
}
|
|
return "Auto-all could not complete"
|
|
}
|
|
|
|
run_panels_action :: proc(controller: ^ui.App_Controller, queue: ^adapters.Fal_Generation_Queue, is_dirty: ^bool) -> string {
|
|
if len(controller.state.script.pages) == 0 {
|
|
return "Generate script before panels"
|
|
}
|
|
cfg := shared.load_config()
|
|
if len(cfg.fal_api_key) == 0 {
|
|
return "FAL_API_KEY not set"
|
|
}
|
|
// Create default queue if none provided
|
|
default_q: adapters.Fal_Generation_Queue
|
|
q_ptr := queue
|
|
if q_ptr == nil {
|
|
default_q = adapters.new_fal_queue(2)
|
|
q_ptr = &default_q
|
|
}
|
|
panels := collect_script_panels(controller.state.script)
|
|
client := adapters.new_fal_client(q_ptr)
|
|
images, err := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, "gui-project", controller.state.story_genre, controller.state.target_audience, nil)
|
|
if strings.contains(err.message, "error") || strings.contains(err.message, "fail") {
|
|
return fmt.tprintf("FAL panels failed: %s", err.message)
|
|
}
|
|
is_dirty^ = true
|
|
for _, img in controller.state.panel_images {
|
|
delete(img.url)
|
|
delete(img.prompt)
|
|
}
|
|
delete(controller.state.panel_images)
|
|
// 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 = strings.clone(img.url),
|
|
width = img.width,
|
|
height = img.height,
|
|
seed = img.seed,
|
|
prompt = strings.clone(img.prompt),
|
|
}
|
|
}
|
|
controller.state.panel_images = cloned
|
|
return fmt.tprintf("Generated %d panels via FAL", len(cloned))
|
|
}
|
|
|
|
run_layout_action :: proc(controller: ^ui.App_Controller, can_layout: bool, is_dirty: ^bool) -> string {
|
|
if !can_layout {
|
|
return "Generate panels before layout"
|
|
}
|
|
is_dirty^ = true
|
|
return action_layout_auto(controller)
|
|
}
|
|
|
|
run_export_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, can_export: bool, is_dirty: ^bool, last_export_at: ^f64) -> string {
|
|
normalize_export_path_field(export_path, export_format)
|
|
if !can_export {
|
|
return "Export blocked: generate panels + layout first"
|
|
}
|
|
msg := action_export(controller, export_path^, export_format)
|
|
is_dirty^ = true
|
|
if strings.has_prefix(msg, "Exported ") {
|
|
last_export_at^ = rl.GetTime()
|
|
}
|
|
return msg
|
|
}
|
|
|
|
run_next_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, pages_count: int, is_dirty: ^bool, last_export_at: ^f64) -> string {
|
|
normalize_export_path_field(export_path, export_format)
|
|
msg := action_run_next(controller, export_path^, export_format, pages_count)
|
|
is_dirty^ = true
|
|
if controller.active_screen == .Export {
|
|
last_export_at^ = rl.GetTime()
|
|
}
|
|
return msg
|
|
}
|
|
|
|
run_auto_all_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, pages_count: int, is_dirty: ^bool, last_export_at: ^f64) -> string {
|
|
normalize_export_path_field(export_path, export_format)
|
|
msg := action_run_auto_all(controller, export_path^, export_format, pages_count)
|
|
is_dirty^ = true
|
|
if controller.active_screen == .Export {
|
|
last_export_at^ = rl.GetTime()
|
|
}
|
|
return msg
|
|
}
|
|
|
|
run_auto_all_save_action :: proc(controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, pages_count: int, is_dirty: ^bool, last_export_at, last_autosave_at, last_save_at: ^f64) -> string {
|
|
normalize_export_path_field(export_path, export_format)
|
|
msg := action_run_auto_all(controller, export_path^, export_format, pages_count)
|
|
if controller.active_screen == .Export {
|
|
last_export_at^ = rl.GetTime()
|
|
return save_project_session_with_message(project_path, controller.state, is_dirty, last_autosave_at, last_save_at, "Auto-all + saved")
|
|
}
|
|
return msg
|
|
}
|
|
|
|
set_export_format_with_message :: proc(export_format: ^core.Export_Format, export_path: ^string, next: core.Export_Format, is_dirty: ^bool) -> string {
|
|
export_format^ = next
|
|
export_path^ = export_path_for_format(export_path^, export_format^)
|
|
is_dirty^ = true
|
|
return fmt.aprintf("Export format: %s (%s)", export_format_name(export_format^), export_path^)
|
|
}
|
|
|
|
autosave_tick_with_message :: proc(project_path: ^string, state: core.Comic_State, autosave_enabled: bool, is_dirty: ^bool, last_autosave_at: ^f64, last_save_at: ^f64, autosave_interval_s: f64) -> string {
|
|
if !autosave_enabled || !is_dirty^ {
|
|
return ""
|
|
}
|
|
now := rl.GetTime()
|
|
if now-last_autosave_at^ < autosave_interval_s {
|
|
return ""
|
|
}
|
|
normalize_project_path_field(project_path)
|
|
err := adapters.save_project(project_path^, state)
|
|
last_autosave_at^ = now
|
|
if shared.is_ok(err) {
|
|
is_dirty^ = false
|
|
last_save_at^ = now
|
|
return fmt.aprintf("Autosaved: %s", project_path^)
|
|
}
|
|
return fmt.aprintf("Autosave failed: %s", err.message)
|
|
}
|
|
|
|
action_add_bubble :: proc(controller: ^ui.App_Controller, panel_id: string) -> string {
|
|
id := fmt.aprintf("bubble_%s_new", panel_id)
|
|
bubble := core.Speech_Bubble{
|
|
id = id,
|
|
panel_id = panel_id,
|
|
type = .Normal,
|
|
text = "New bubble",
|
|
position = core.Position{x = 0.1, y = 0.1},
|
|
size = core.Size{width = 120, height = 40},
|
|
tail_direction = "bottom",
|
|
tail_target = core.Position{x = 0.5, y = 0.5},
|
|
style = core.DEFAULT_BUBBLE_STYLE,
|
|
speaker_id = "",
|
|
}
|
|
|
|
if controller.state.speech_bubbles == nil {
|
|
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
|
|
}
|
|
|
|
existing: [dynamic]core.Speech_Bubble
|
|
if slice, ok := controller.state.speech_bubbles[panel_id]; ok {
|
|
for b in slice {
|
|
append(&existing, b)
|
|
}
|
|
}
|
|
append(&existing, bubble)
|
|
controller.state.speech_bubbles[panel_id] = existing[:]
|
|
|
|
return "Added bubble"
|
|
}
|
|
|
|
action_delete_bubble :: proc(controller: ^ui.App_Controller, panel_id: string, bubble_idx: int) -> string {
|
|
if controller.state.speech_bubbles == nil {
|
|
return "No bubbles to delete"
|
|
}
|
|
slice, ok := controller.state.speech_bubbles[panel_id]
|
|
if !ok || bubble_idx < 0 || bubble_idx >= len(slice) {
|
|
return "Bubble not found"
|
|
}
|
|
|
|
// Remove by shifting elements
|
|
new_slice: [dynamic]core.Speech_Bubble
|
|
for i in 0..<len(slice) {
|
|
if i != bubble_idx {
|
|
append(&new_slice, slice[i])
|
|
}
|
|
}
|
|
if len(new_slice) == 0 {
|
|
delete_key(&controller.state.speech_bubbles, panel_id)
|
|
} else {
|
|
controller.state.speech_bubbles[panel_id] = new_slice[:]
|
|
}
|
|
|
|
return "Deleted bubble"
|
|
}
|
|
|
|
action_auto_place_bubbles_for_panel :: proc(controller: ^ui.App_Controller, panel_id: string, layout: core.Page_Layout) -> string {
|
|
// Find the script panel to get dialogue
|
|
script_panel: core.Panel
|
|
found := false
|
|
for page in controller.state.script.pages {
|
|
for p in page.panels {
|
|
if p.panel_id == panel_id {
|
|
script_panel = p
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found { break }
|
|
}
|
|
if !found {
|
|
return "Script panel not found for auto-place"
|
|
}
|
|
|
|
// Find the layout cell for this panel to get dimensions
|
|
cell_w: f32 = 1
|
|
cell_h: f32 = 1
|
|
for lp in layout.panels {
|
|
if lp.panel_id == panel_id {
|
|
cell_w = lp.layout_cell.w
|
|
cell_h = lp.layout_cell.h
|
|
break
|
|
}
|
|
}
|
|
|
|
panel_w := cell_w * f32(layout.width)
|
|
panel_h := cell_h * f32(layout.height)
|
|
|
|
bubbles := core.auto_place_panel_bubbles(script_panel, panel_w, panel_h)
|
|
defer delete(bubbles)
|
|
|
|
if len(bubbles) == 0 {
|
|
return "No dialogue to auto-place"
|
|
}
|
|
|
|
if controller.state.speech_bubbles == nil {
|
|
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
|
|
}
|
|
controller.state.speech_bubbles[panel_id] = bubbles
|
|
|
|
return fmt.tprintf("Auto-placed %d bubbles", len(bubbles))
|
|
}
|
|
|
|
action_update_bubble :: proc(controller: ^ui.App_Controller, panel_id: string, bubble_idx: int, new_type: core.Bubble_Type, new_text: string) -> string {
|
|
if controller.state.speech_bubbles == nil {
|
|
return "No bubbles to update"
|
|
}
|
|
slice, ok := controller.state.speech_bubbles[panel_id]
|
|
if !ok || bubble_idx < 0 || bubble_idx >= len(slice) {
|
|
return "Bubble not found"
|
|
}
|
|
|
|
// Copy to dynamic slice, update, then reassign
|
|
new_slice: [dynamic]core.Speech_Bubble
|
|
for i in 0..<len(slice) {
|
|
b := slice[i]
|
|
if i == bubble_idx {
|
|
if new_text != "" {
|
|
b.text = new_text
|
|
}
|
|
b.type = new_type
|
|
}
|
|
append(&new_slice, b)
|
|
}
|
|
controller.state.speech_bubbles[panel_id] = new_slice[:]
|
|
return "Bubble updated"
|
|
}
|
|
|
|
action_generate_character_reference :: proc(controller: ^ui.App_Controller, character_id: string) -> string {
|
|
cfg := shared.load_config()
|
|
if len(cfg.fal_api_key) == 0 {
|
|
return "FAL_API_KEY is missing"
|
|
}
|
|
|
|
// Find the character
|
|
target_char: core.Character
|
|
found := false
|
|
for c in controller.state.characters {
|
|
if c.id == character_id {
|
|
target_char = c
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return "Character not found"
|
|
}
|
|
|
|
url, err := adapters.generate_character_reference_stub(cfg, target_char, controller.state.art_style, controller.state.story_genre, controller.state.target_audience)
|
|
if !shared.is_ok(err) {
|
|
return err.message
|
|
}
|
|
|
|
// Update character with reference image
|
|
new_chars: [dynamic]core.Character
|
|
for c in controller.state.characters {
|
|
new_c := c
|
|
if c.id == character_id {
|
|
new_c.reference_image_url = url
|
|
}
|
|
append(&new_chars, new_c)
|
|
}
|
|
delete(controller.state.characters)
|
|
controller.state.characters = new_chars[:]
|
|
|
|
return fmt.tprintf("Generated reference for %s", target_char.name)
|
|
}
|
|
|
|
action_generate_character_sheet :: proc(controller: ^ui.App_Controller, character_id: string) -> string {
|
|
cfg := shared.load_config()
|
|
if len(cfg.fal_api_key) == 0 {
|
|
return "FAL_API_KEY is missing"
|
|
}
|
|
|
|
// Find the character
|
|
target_char: core.Character
|
|
found := false
|
|
for c in controller.state.characters {
|
|
if c.id == character_id {
|
|
target_char = c
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return "Character not found"
|
|
}
|
|
|
|
sheet_urls, err := adapters.generate_character_sheet_stub(cfg, target_char, controller.state.art_style, controller.state.story_genre, controller.state.target_audience)
|
|
if !shared.is_ok(err) {
|
|
return err.message
|
|
}
|
|
|
|
// Update character with sheet URLs
|
|
new_chars: [dynamic]core.Character
|
|
for c in controller.state.characters {
|
|
new_c := c
|
|
if c.id == character_id {
|
|
// Free old sheet URLs
|
|
for u in c.character_sheet_urls {
|
|
delete(u)
|
|
}
|
|
delete(c.character_sheet_urls)
|
|
new_c.character_sheet_urls = sheet_urls
|
|
}
|
|
append(&new_chars, new_c)
|
|
}
|
|
delete(controller.state.characters)
|
|
controller.state.characters = new_chars[:]
|
|
|
|
return fmt.tprintf("Generated %d-pose sheet for %s", len(sheet_urls), target_char.name)
|
|
}
|
|
|
|
action_generate_all_character_refs :: proc(controller: ^ui.App_Controller) -> string {
|
|
cfg := shared.load_config()
|
|
if len(cfg.fal_api_key) == 0 {
|
|
return "FAL_API_KEY is missing"
|
|
}
|
|
if len(controller.state.characters) == 0 {
|
|
return "No characters to generate"
|
|
}
|
|
|
|
count := 0
|
|
for c in controller.state.characters {
|
|
if len(c.reference_image_url) > 0 { continue }
|
|
url, err := adapters.generate_character_reference_stub(cfg, c, controller.state.art_style, controller.state.story_genre, controller.state.target_audience)
|
|
if shared.is_ok(err) {
|
|
new_chars: [dynamic]core.Character
|
|
for ch in controller.state.characters {
|
|
nc := ch
|
|
if ch.id == c.id { nc.reference_image_url = url }
|
|
append(&new_chars, nc)
|
|
}
|
|
delete(controller.state.characters)
|
|
controller.state.characters = new_chars[:]
|
|
count += 1
|
|
} else {
|
|
return fmt.aprintf("Failed on %s: %s", c.name, err.message)
|
|
}
|
|
}
|
|
return fmt.aprintf("Generated %d character references", count)
|
|
}
|
|
|
|
action_update_character_desc :: proc(controller: ^ui.App_Controller, character_id: string, new_desc: string) -> string {
|
|
new_chars: [dynamic]core.Character
|
|
found := false
|
|
for c in controller.state.characters {
|
|
new_c := c
|
|
if c.id == character_id {
|
|
// Free old description? We don't have deep free logic everywhere, but we can assign the new one.
|
|
new_c.description = new_desc // Assume new_desc is cloned elsewhere if needed, or just static string inside GUI buffer
|
|
found = true
|
|
}
|
|
append(&new_chars, new_c)
|
|
}
|
|
if !found {
|
|
return "Character not found"
|
|
}
|
|
delete(controller.state.characters)
|
|
controller.state.characters = new_chars[:]
|
|
return "Character description updated"
|
|
}
|
|
|
|
action_update_bubble_text :: proc(controller: ^ui.App_Controller, bubble_id: string, new_text: string) -> string {
|
|
if controller.state.speech_bubbles == nil {
|
|
return "No bubbles to update"
|
|
}
|
|
|
|
for panel_id, slice in controller.state.speech_bubbles {
|
|
for i in 0..<len(slice) {
|
|
if slice[i].id == bubble_id {
|
|
new_slice: [dynamic]core.Speech_Bubble
|
|
for j in 0..<len(slice) {
|
|
b := slice[j]
|
|
if j == i {
|
|
b.text = new_text
|
|
}
|
|
append(&new_slice, b)
|
|
}
|
|
controller.state.speech_bubbles[panel_id] = new_slice[:]
|
|
return "Bubble text updated"
|
|
}
|
|
}
|
|
}
|
|
return "Bubble not found"
|
|
}
|
|
|
|
action_reposition_bubble :: proc(controller: ^ui.App_Controller, bubble_id: string, x, y: f32) -> string {
|
|
if controller.state.speech_bubbles == nil {
|
|
return "No bubbles to reposition"
|
|
}
|
|
|
|
for panel_id, slice in controller.state.speech_bubbles {
|
|
for i in 0..<len(slice) {
|
|
if slice[i].id == bubble_id {
|
|
new_slice: [dynamic]core.Speech_Bubble
|
|
for j in 0..<len(slice) {
|
|
b := slice[j]
|
|
if j == i {
|
|
b.position = core.Position{x = x, y = y}
|
|
}
|
|
append(&new_slice, b)
|
|
}
|
|
controller.state.speech_bubbles[panel_id] = new_slice[:]
|
|
return "Bubble repositioned"
|
|
}
|
|
}
|
|
}
|
|
return "Bubble not found"
|
|
}
|
|
|
|
action_reset_bubble_position :: proc(controller: ^ui.App_Controller, bubble_id: string) -> string {
|
|
if controller.state.speech_bubbles == nil {
|
|
return "No bubbles to reset"
|
|
}
|
|
|
|
for panel_id, slice in controller.state.speech_bubbles {
|
|
for i in 0..<len(slice) {
|
|
if slice[i].id == bubble_id {
|
|
new_slice: [dynamic]core.Speech_Bubble
|
|
for j in 0..<len(slice) {
|
|
b := slice[j]
|
|
if j == i {
|
|
b.position = core.Position{x = 0, y = 0}
|
|
}
|
|
append(&new_slice, b)
|
|
}
|
|
controller.state.speech_bubbles[panel_id] = new_slice[:]
|
|
return "Bubble position reset"
|
|
}
|
|
}
|
|
}
|
|
return "Bubble not found"
|
|
}
|