check point
This commit is contained in:
1 parent
ae1cae967b
commit
1e85df5193
72 files changed
+11452
No files matched your search
@@ -0,0 +1,789 @@
|
||||
package adapters
|
||||
|
||||
import json "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
Generate_Script_Options :: struct {
|
||||
story_idea: string,
|
||||
genre: string,
|
||||
art_style: string,
|
||||
num_pages: int,
|
||||
audience: string,
|
||||
}
|
||||
|
||||
Deepseek_Request_Message :: struct {
|
||||
role: string,
|
||||
content: string,
|
||||
}
|
||||
|
||||
Deepseek_Request_Response_Format :: struct {
|
||||
type: string,
|
||||
}
|
||||
|
||||
Deepseek_Request_Body :: struct {
|
||||
model: string,
|
||||
messages: []Deepseek_Request_Message,
|
||||
response_format: Deepseek_Request_Response_Format,
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
Deepseek_Transport :: #type proc(cfg: shared.Config, request_json: string) -> (response_json: string, status_code: int, err: shared.App_Error)
|
||||
|
||||
Deepseek_Client :: struct {
|
||||
transport: Deepseek_Transport,
|
||||
max_retries: int,
|
||||
initial_backoff_ms: int,
|
||||
}
|
||||
|
||||
Deepseek_Response_Message :: struct {
|
||||
content: string,
|
||||
}
|
||||
|
||||
Deepseek_Response_Choice :: struct {
|
||||
message: Deepseek_Response_Message,
|
||||
}
|
||||
|
||||
Deepseek_Response :: struct {
|
||||
choices: []Deepseek_Response_Choice,
|
||||
}
|
||||
|
||||
Raw_Dialogue :: struct {
|
||||
speakerId: string,
|
||||
text: string,
|
||||
bubbleType: string,
|
||||
emotion: string,
|
||||
}
|
||||
|
||||
Raw_Panel :: struct {
|
||||
panelId: string,
|
||||
panelNumber: int,
|
||||
shotType: string,
|
||||
description: string,
|
||||
charactersPresent: []string,
|
||||
dialogue: []Raw_Dialogue,
|
||||
caption: string,
|
||||
soundEffects: []string,
|
||||
transitionFromPrevious: string,
|
||||
}
|
||||
|
||||
Raw_Page :: struct {
|
||||
pageNumber: int,
|
||||
layoutType: string,
|
||||
panels: []Raw_Panel,
|
||||
}
|
||||
|
||||
Raw_Character :: struct {
|
||||
id: string,
|
||||
name: string,
|
||||
role: string,
|
||||
description: string,
|
||||
firstAppearancePanel: string,
|
||||
}
|
||||
|
||||
Raw_Script :: struct {
|
||||
title: string,
|
||||
synopsis: string,
|
||||
characters: []Raw_Character,
|
||||
pages: []Raw_Page,
|
||||
}
|
||||
|
||||
Raw_Dialogue_Alt :: struct {
|
||||
speaker_id: string,
|
||||
text: string,
|
||||
bubble_type: string,
|
||||
emotion: string,
|
||||
}
|
||||
|
||||
Raw_Panel_Alt :: struct {
|
||||
panel_id: string,
|
||||
panel_number: int,
|
||||
shot_type: string,
|
||||
description: string,
|
||||
characters_present: []string,
|
||||
dialogue: []Raw_Dialogue_Alt,
|
||||
caption: string,
|
||||
sound_effects: []string,
|
||||
transition_from_previous: string,
|
||||
}
|
||||
|
||||
Raw_Page_Alt :: struct {
|
||||
page_number: int,
|
||||
layout_type: string,
|
||||
panels: []Raw_Panel_Alt,
|
||||
}
|
||||
|
||||
Raw_Character_Alt :: struct {
|
||||
id: string,
|
||||
name: string,
|
||||
role: string,
|
||||
description: string,
|
||||
first_appearance_panel: string,
|
||||
}
|
||||
|
||||
Raw_Script_Alt :: struct {
|
||||
title: string,
|
||||
synopsis: string,
|
||||
characters: []Raw_Character_Alt,
|
||||
pages: []Raw_Page_Alt,
|
||||
}
|
||||
|
||||
Raw_Script_Wrapper :: struct {
|
||||
script: Raw_Script,
|
||||
}
|
||||
|
||||
Raw_Script_Alt_Wrapper :: struct {
|
||||
script: Raw_Script_Alt,
|
||||
}
|
||||
|
||||
deepseek_parse_curl_output :: proc(output: string) -> (body: string, status_code: int, ok: bool) {
|
||||
marker := "__STATUS__:"
|
||||
idx := strings.last_index(output, marker)
|
||||
if idx < 0 {
|
||||
return output, 0, false
|
||||
}
|
||||
|
||||
body = output[:idx]
|
||||
status_str := output[idx+len(marker):]
|
||||
status_code = 0
|
||||
for c in status_str {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
status_code = status_code*10 + int(c-'0')
|
||||
}
|
||||
return body, status_code, true
|
||||
}
|
||||
|
||||
default_deepseek_transport :: proc(cfg: shared.Config, request_json: string) -> (string, int, shared.App_Error) {
|
||||
url := fmt.aprintf("%s/chat/completions", cfg.deepseek_base_url)
|
||||
auth := fmt.aprintf("Authorization: Bearer %s", cfg.deepseek_api_key)
|
||||
|
||||
cmd := [13]string{
|
||||
"curl", "-sS", "-X", "POST", url,
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", auth,
|
||||
"-d", request_json,
|
||||
"-w", "\\n__STATUS__:%{http_code}",
|
||||
}
|
||||
desc := os.Process_Desc{command = cmd[:]}
|
||||
state, stdout, stderr, exec_err := os.process_exec(desc, context.temp_allocator)
|
||||
if exec_err != nil {
|
||||
return "", 0, shared.network_error(fmt.aprintf("curl execution failed: %v", exec_err))
|
||||
}
|
||||
if !state.exited || state.exit_code != 0 {
|
||||
return "", 0, shared.network_error(fmt.aprintf("curl failed: %s", string(stderr)))
|
||||
}
|
||||
|
||||
body, status_code, ok := deepseek_parse_curl_output(string(stdout))
|
||||
if !ok {
|
||||
return string(stdout), 0, shared.network_error("unable to parse curl status output")
|
||||
}
|
||||
return body, status_code, shared.ok()
|
||||
}
|
||||
|
||||
new_deepseek_client :: proc() -> Deepseek_Client {
|
||||
return Deepseek_Client{
|
||||
transport = default_deepseek_transport,
|
||||
max_retries = 3,
|
||||
initial_backoff_ms = 500,
|
||||
}
|
||||
}
|
||||
|
||||
backoff_ms :: proc(initial_ms, attempt: int) -> int {
|
||||
if attempt <= 0 {
|
||||
return 0
|
||||
}
|
||||
mul := 1 << u32(attempt-1)
|
||||
return initial_ms * mul
|
||||
}
|
||||
|
||||
extract_deepseek_error_message :: proc(body: string) -> string {
|
||||
trimmed := strings.trim_space(body)
|
||||
if len(trimmed) == 0 {
|
||||
return ""
|
||||
}
|
||||
marker := "\"message\":\""
|
||||
idx := strings.index(trimmed, marker)
|
||||
if idx < 0 {
|
||||
if len(trimmed) > 180 {
|
||||
return fmt.aprintf("%s…", trimmed[:179])
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
start := idx + len(marker)
|
||||
end := start
|
||||
for end < len(trimmed) {
|
||||
if trimmed[end] == '"' && (end == start || trimmed[end-1] != '\\') {
|
||||
break
|
||||
}
|
||||
end += 1
|
||||
}
|
||||
msg := trimmed[start:end]
|
||||
if len(msg) == 0 {
|
||||
if len(trimmed) > 180 {
|
||||
return fmt.aprintf("%s…", trimmed[:179])
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
map_http_error :: proc(status_code: int, response_body: string) -> shared.App_Error {
|
||||
detail := extract_deepseek_error_message(response_body)
|
||||
if status_code == 429 {
|
||||
if len(detail) > 0 {
|
||||
return shared.rate_limit_error(fmt.aprintf("deepseek rate-limited (429): %s", detail))
|
||||
}
|
||||
return shared.rate_limit_error("deepseek rate-limited (429)")
|
||||
}
|
||||
if status_code >= 500 {
|
||||
if len(detail) > 0 {
|
||||
return shared.network_error(fmt.aprintf("deepseek server error (%d): %s", status_code, detail))
|
||||
}
|
||||
return shared.network_error(fmt.aprintf("deepseek server error (%d)", status_code))
|
||||
}
|
||||
if status_code >= 400 {
|
||||
if len(detail) > 0 {
|
||||
return shared.validation_error(fmt.aprintf("deepseek request failed (%d): %s", status_code, detail))
|
||||
}
|
||||
return shared.validation_error(fmt.aprintf("deepseek request failed (%d)", status_code))
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
deepseek_json_escape :: proc(s: string) -> string {
|
||||
out: [dynamic]u8
|
||||
for c in s {
|
||||
switch c {
|
||||
case '"':
|
||||
append(&out, '\\')
|
||||
append(&out, '"')
|
||||
case '\\':
|
||||
append(&out, '\\')
|
||||
append(&out, '\\')
|
||||
case '\n':
|
||||
append(&out, '\\')
|
||||
append(&out, 'n')
|
||||
case '\r':
|
||||
append(&out, '\\')
|
||||
append(&out, 'r')
|
||||
case '\t':
|
||||
append(&out, '\\')
|
||||
append(&out, 't')
|
||||
case:
|
||||
append(&out, u8(c))
|
||||
}
|
||||
}
|
||||
return string(out[:])
|
||||
}
|
||||
|
||||
build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string {
|
||||
user_content := fmt.aprintf(
|
||||
"Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.",
|
||||
opts.num_pages,
|
||||
opts.story_idea,
|
||||
opts.genre,
|
||||
opts.art_style,
|
||||
opts.audience,
|
||||
)
|
||||
defer delete(user_content)
|
||||
|
||||
messages := [2]Deepseek_Request_Message{
|
||||
{role = "system", content = "You are an expert comic writer. Return JSON only."},
|
||||
{role = "user", content = user_content},
|
||||
}
|
||||
body := Deepseek_Request_Body{
|
||||
model = "deepseek-chat",
|
||||
messages = messages[:],
|
||||
response_format = Deepseek_Request_Response_Format{type = "json_object"},
|
||||
temperature = 0.8,
|
||||
}
|
||||
request_json, merr := json.marshal(body, {}, context.allocator)
|
||||
if merr == nil {
|
||||
return string(request_json)
|
||||
}
|
||||
|
||||
escaped := deepseek_json_escape(user_content)
|
||||
defer delete(escaped)
|
||||
return fmt.aprintf(
|
||||
"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8}",
|
||||
escaped,
|
||||
)
|
||||
}
|
||||
|
||||
validate_generate_script_options :: proc(opts: Generate_Script_Options) -> shared.App_Error {
|
||||
if len(opts.story_idea) == 0 {
|
||||
return shared.validation_error("story_idea is required")
|
||||
}
|
||||
if opts.num_pages <= 0 {
|
||||
return shared.validation_error("num_pages must be > 0")
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
build_fallback_script :: proc(opts: Generate_Script_Options) -> core.Comic_Script {
|
||||
dialogues: [1]core.Dialogue = [1]core.Dialogue{
|
||||
{speaker_id = "char_001", text = "Let's begin.", bubble_type = .Normal, emotion = "neutral"},
|
||||
}
|
||||
chars_present: [1]string = [1]string{"char_001"}
|
||||
panels: [1]core.Panel = [1]core.Panel{
|
||||
{
|
||||
panel_id = "panel_001_001",
|
||||
panel_number = 1,
|
||||
shot_type = .Medium,
|
||||
description = opts.story_idea,
|
||||
characters_present = chars_present[:],
|
||||
dialogue = dialogues[:],
|
||||
caption = "",
|
||||
sound_effects = nil,
|
||||
transition_from_previous = .None,
|
||||
},
|
||||
}
|
||||
pages: [1]core.Page = [1]core.Page{
|
||||
{page_number = 1, layout_type = .Grid, panels = panels[:]},
|
||||
}
|
||||
chars: [1]core.Character = [1]core.Character{
|
||||
{name = "Protagonist", description = "A determined lead character"},
|
||||
}
|
||||
|
||||
raw := core.Comic_Script{
|
||||
title = "Generated Comic",
|
||||
synopsis = opts.story_idea,
|
||||
characters = chars[:],
|
||||
pages = pages[:],
|
||||
}
|
||||
return core.normalize_script(raw)
|
||||
}
|
||||
|
||||
extract_json_block :: proc(s: string) -> string {
|
||||
trimmed := strings.trim(s, " \n\r\t")
|
||||
start := strings.index(trimmed, "{")
|
||||
finish := strings.last_index(trimmed, "}")
|
||||
if start >= 0 && finish >= start {
|
||||
return trimmed[start : finish+1]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
character_role_from_string :: proc(role: string) -> core.Character_Role {
|
||||
switch role {
|
||||
case "protagonist":
|
||||
return .Protagonist
|
||||
case "antagonist":
|
||||
return .Antagonist
|
||||
case "supporting":
|
||||
return .Supporting
|
||||
case "extra":
|
||||
return .Extra
|
||||
}
|
||||
return .Supporting
|
||||
}
|
||||
|
||||
layout_type_from_string :: proc(layout: string) -> core.Layout_Type {
|
||||
switch layout {
|
||||
case "grid":
|
||||
return .Grid
|
||||
case "manga":
|
||||
return .Manga
|
||||
case "western":
|
||||
return .Western
|
||||
case "action":
|
||||
return .Action
|
||||
case "dialogue":
|
||||
return .Dialogue
|
||||
case "splash":
|
||||
return .Splash
|
||||
}
|
||||
return .Grid
|
||||
}
|
||||
|
||||
shot_type_from_string :: proc(shot: string) -> core.Shot_Type {
|
||||
switch shot {
|
||||
case "establishing":
|
||||
return .Establishing
|
||||
case "wide":
|
||||
return .Wide
|
||||
case "medium":
|
||||
return .Medium
|
||||
case "close-up":
|
||||
return .Close_Up
|
||||
case "extreme-close-up":
|
||||
return .Extreme_Close_Up
|
||||
case "over-shoulder":
|
||||
return .Over_Shoulder
|
||||
case "aerial":
|
||||
return .Aerial
|
||||
}
|
||||
return .Medium
|
||||
}
|
||||
|
||||
bubble_type_from_string :: proc(bt: string) -> core.Bubble_Type {
|
||||
switch bt {
|
||||
case "normal":
|
||||
return .Normal
|
||||
case "thought":
|
||||
return .Thought
|
||||
case "shout":
|
||||
return .Shout
|
||||
case "whisper":
|
||||
return .Whisper
|
||||
case "narration":
|
||||
return .Narration
|
||||
case "sound-effect":
|
||||
return .Sound_Effect
|
||||
}
|
||||
return .Normal
|
||||
}
|
||||
|
||||
transition_from_string :: proc(t: string) -> core.Transition_Type {
|
||||
switch t {
|
||||
case "none":
|
||||
return .None
|
||||
case "fade":
|
||||
return .Fade
|
||||
case "wipe":
|
||||
return .Wipe
|
||||
case "dissolve":
|
||||
return .Dissolve
|
||||
case "action-lines":
|
||||
return .Action_Lines
|
||||
}
|
||||
return .None
|
||||
}
|
||||
|
||||
clone_string_array :: proc(items: []string) -> []string {
|
||||
out: [dynamic]string
|
||||
for s in items {
|
||||
append(&out, strings.clone(s, context.allocator))
|
||||
}
|
||||
return out[:]
|
||||
}
|
||||
|
||||
dispose_deepseek_response :: proc(resp: ^Deepseek_Response) {
|
||||
for c in resp.choices {
|
||||
delete(c.message.content)
|
||||
}
|
||||
delete(resp.choices)
|
||||
}
|
||||
|
||||
dispose_raw_script :: proc(raw: ^Raw_Script) {
|
||||
delete(raw.title)
|
||||
delete(raw.synopsis)
|
||||
|
||||
for c in raw.characters {
|
||||
delete(c.id)
|
||||
delete(c.name)
|
||||
delete(c.role)
|
||||
delete(c.description)
|
||||
delete(c.firstAppearancePanel)
|
||||
}
|
||||
delete(raw.characters)
|
||||
|
||||
for p in raw.pages {
|
||||
delete(p.layoutType)
|
||||
for pan in p.panels {
|
||||
delete(pan.panelId)
|
||||
delete(pan.shotType)
|
||||
delete(pan.description)
|
||||
delete(pan.caption)
|
||||
delete(pan.transitionFromPrevious)
|
||||
for d in pan.dialogue {
|
||||
delete(d.speakerId)
|
||||
delete(d.text)
|
||||
delete(d.bubbleType)
|
||||
delete(d.emotion)
|
||||
}
|
||||
delete(pan.dialogue)
|
||||
for s in pan.charactersPresent {
|
||||
delete(s)
|
||||
}
|
||||
delete(pan.charactersPresent)
|
||||
for s in pan.soundEffects {
|
||||
delete(s)
|
||||
}
|
||||
delete(pan.soundEffects)
|
||||
}
|
||||
delete(p.panels)
|
||||
}
|
||||
delete(raw.pages)
|
||||
}
|
||||
|
||||
dispose_raw_script_alt :: proc(raw: ^Raw_Script_Alt) {
|
||||
delete(raw.title)
|
||||
delete(raw.synopsis)
|
||||
|
||||
for c in raw.characters {
|
||||
delete(c.id)
|
||||
delete(c.name)
|
||||
delete(c.role)
|
||||
delete(c.description)
|
||||
delete(c.first_appearance_panel)
|
||||
}
|
||||
delete(raw.characters)
|
||||
|
||||
for p in raw.pages {
|
||||
delete(p.layout_type)
|
||||
for pan in p.panels {
|
||||
delete(pan.panel_id)
|
||||
delete(pan.shot_type)
|
||||
delete(pan.description)
|
||||
delete(pan.caption)
|
||||
delete(pan.transition_from_previous)
|
||||
for d in pan.dialogue {
|
||||
delete(d.speaker_id)
|
||||
delete(d.text)
|
||||
delete(d.bubble_type)
|
||||
delete(d.emotion)
|
||||
}
|
||||
delete(pan.dialogue)
|
||||
for s in pan.characters_present {
|
||||
delete(s)
|
||||
}
|
||||
delete(pan.characters_present)
|
||||
for s in pan.sound_effects {
|
||||
delete(s)
|
||||
}
|
||||
delete(pan.sound_effects)
|
||||
}
|
||||
delete(p.panels)
|
||||
}
|
||||
delete(raw.pages)
|
||||
}
|
||||
|
||||
convert_raw_script :: proc(raw: Raw_Script) -> core.Comic_Script {
|
||||
chars: [dynamic]core.Character
|
||||
for c in raw.characters {
|
||||
append(&chars, core.Character{
|
||||
id = strings.clone(c.id, context.allocator),
|
||||
name = strings.clone(c.name, context.allocator),
|
||||
role = character_role_from_string(c.role),
|
||||
description = strings.clone(c.description, context.allocator),
|
||||
first_appearance_panel = strings.clone(c.firstAppearancePanel, context.allocator),
|
||||
})
|
||||
}
|
||||
|
||||
pages: [dynamic]core.Page
|
||||
for p in raw.pages {
|
||||
panels: [dynamic]core.Panel
|
||||
for pan in p.panels {
|
||||
dialogues: [dynamic]core.Dialogue
|
||||
for d in pan.dialogue {
|
||||
append(&dialogues, core.Dialogue{
|
||||
speaker_id = strings.clone(d.speakerId, context.allocator),
|
||||
text = strings.clone(d.text, context.allocator),
|
||||
bubble_type = bubble_type_from_string(d.bubbleType),
|
||||
emotion = strings.clone(d.emotion, context.allocator),
|
||||
})
|
||||
}
|
||||
|
||||
append(&panels, core.Panel{
|
||||
panel_id = strings.clone(pan.panelId, context.allocator),
|
||||
panel_number = pan.panelNumber,
|
||||
shot_type = shot_type_from_string(pan.shotType),
|
||||
description = strings.clone(pan.description, context.allocator),
|
||||
characters_present = clone_string_array(pan.charactersPresent),
|
||||
dialogue = dialogues[:],
|
||||
caption = strings.clone(pan.caption, context.allocator),
|
||||
sound_effects = clone_string_array(pan.soundEffects),
|
||||
transition_from_previous = transition_from_string(pan.transitionFromPrevious),
|
||||
})
|
||||
}
|
||||
|
||||
append(&pages, core.Page{
|
||||
page_number = p.pageNumber,
|
||||
layout_type = layout_type_from_string(p.layoutType),
|
||||
panels = panels[:],
|
||||
})
|
||||
}
|
||||
|
||||
return core.Comic_Script{
|
||||
title = strings.clone(raw.title, context.allocator),
|
||||
synopsis = strings.clone(raw.synopsis, context.allocator),
|
||||
characters = chars[:],
|
||||
pages = pages[:],
|
||||
}
|
||||
}
|
||||
|
||||
convert_raw_script_alt :: proc(raw: Raw_Script_Alt) -> core.Comic_Script {
|
||||
chars: [dynamic]core.Character
|
||||
for c in raw.characters {
|
||||
append(&chars, core.Character{
|
||||
id = strings.clone(c.id, context.allocator),
|
||||
name = strings.clone(c.name, context.allocator),
|
||||
role = character_role_from_string(c.role),
|
||||
description = strings.clone(c.description, context.allocator),
|
||||
first_appearance_panel = strings.clone(c.first_appearance_panel, context.allocator),
|
||||
})
|
||||
}
|
||||
|
||||
pages: [dynamic]core.Page
|
||||
for p in raw.pages {
|
||||
panels: [dynamic]core.Panel
|
||||
for pan in p.panels {
|
||||
dialogues: [dynamic]core.Dialogue
|
||||
for d in pan.dialogue {
|
||||
append(&dialogues, core.Dialogue{
|
||||
speaker_id = strings.clone(d.speaker_id, context.allocator),
|
||||
text = strings.clone(d.text, context.allocator),
|
||||
bubble_type = bubble_type_from_string(d.bubble_type),
|
||||
emotion = strings.clone(d.emotion, context.allocator),
|
||||
})
|
||||
}
|
||||
|
||||
append(&panels, core.Panel{
|
||||
panel_id = strings.clone(pan.panel_id, context.allocator),
|
||||
panel_number = pan.panel_number,
|
||||
shot_type = shot_type_from_string(pan.shot_type),
|
||||
description = strings.clone(pan.description, context.allocator),
|
||||
characters_present = clone_string_array(pan.characters_present),
|
||||
dialogue = dialogues[:],
|
||||
caption = strings.clone(pan.caption, context.allocator),
|
||||
sound_effects = clone_string_array(pan.sound_effects),
|
||||
transition_from_previous = transition_from_string(pan.transition_from_previous),
|
||||
})
|
||||
}
|
||||
|
||||
append(&pages, core.Page{
|
||||
page_number = p.page_number,
|
||||
layout_type = layout_type_from_string(p.layout_type),
|
||||
panels = panels[:],
|
||||
})
|
||||
}
|
||||
|
||||
return core.Comic_Script{
|
||||
title = strings.clone(raw.title, context.allocator),
|
||||
synopsis = strings.clone(raw.synopsis, context.allocator),
|
||||
characters = chars[:],
|
||||
pages = pages[:],
|
||||
}
|
||||
}
|
||||
|
||||
invalid_normalized_script_err :: proc(script: core.Comic_Script) -> shared.App_Error {
|
||||
return shared.generation_error(fmt.aprintf("normalized script failed minimal validation (title:%d synopsis:%d pages:%d)", len(script.title), len(script.synopsis), len(script.pages)))
|
||||
}
|
||||
|
||||
parse_deepseek_script_response :: proc(response_json: string) -> (core.Comic_Script, shared.App_Error) {
|
||||
outer: Deepseek_Response
|
||||
if err := json.unmarshal_string(response_json, &outer); err != nil {
|
||||
return core.Comic_Script{}, shared.generation_error(fmt.aprintf("failed to parse deepseek envelope: %v", err))
|
||||
}
|
||||
defer dispose_deepseek_response(&outer)
|
||||
if len(outer.choices) == 0 {
|
||||
return core.Comic_Script{}, shared.generation_error("deepseek response has no choices")
|
||||
}
|
||||
|
||||
content := outer.choices[0].message.content
|
||||
if len(content) == 0 {
|
||||
return core.Comic_Script{}, shared.generation_error("deepseek content is empty")
|
||||
}
|
||||
|
||||
raw_json := extract_json_block(content)
|
||||
|
||||
raw_script: Raw_Script
|
||||
if err := json.unmarshal_string(raw_json, &raw_script); err == nil {
|
||||
defer dispose_raw_script(&raw_script)
|
||||
script := convert_raw_script(raw_script)
|
||||
norm := core.normalize_script(script)
|
||||
if !core.script_is_valid_minimal(norm) {
|
||||
core.dispose_script(&norm)
|
||||
return core.Comic_Script{}, invalid_normalized_script_err(script)
|
||||
}
|
||||
return norm, shared.ok()
|
||||
}
|
||||
|
||||
wrapped: Raw_Script_Wrapper
|
||||
if err := json.unmarshal_string(raw_json, &wrapped); err == nil {
|
||||
defer dispose_raw_script(&wrapped.script)
|
||||
script := convert_raw_script(wrapped.script)
|
||||
norm := core.normalize_script(script)
|
||||
if !core.script_is_valid_minimal(norm) {
|
||||
core.dispose_script(&norm)
|
||||
return core.Comic_Script{}, invalid_normalized_script_err(script)
|
||||
}
|
||||
return norm, shared.ok()
|
||||
}
|
||||
|
||||
raw_alt: Raw_Script_Alt
|
||||
if err := json.unmarshal_string(raw_json, &raw_alt); err == nil {
|
||||
defer dispose_raw_script_alt(&raw_alt)
|
||||
script := convert_raw_script_alt(raw_alt)
|
||||
norm := core.normalize_script(script)
|
||||
if !core.script_is_valid_minimal(norm) {
|
||||
core.dispose_script(&norm)
|
||||
return core.Comic_Script{}, invalid_normalized_script_err(script)
|
||||
}
|
||||
return norm, shared.ok()
|
||||
}
|
||||
|
||||
wrapped_alt: Raw_Script_Alt_Wrapper
|
||||
if err := json.unmarshal_string(raw_json, &wrapped_alt); err == nil {
|
||||
defer dispose_raw_script_alt(&wrapped_alt.script)
|
||||
script := convert_raw_script_alt(wrapped_alt.script)
|
||||
norm := core.normalize_script(script)
|
||||
if !core.script_is_valid_minimal(norm) {
|
||||
core.dispose_script(&norm)
|
||||
return core.Comic_Script{}, invalid_normalized_script_err(script)
|
||||
}
|
||||
return norm, shared.ok()
|
||||
}
|
||||
|
||||
return core.Comic_Script{}, shared.generation_error("failed to parse deepseek script content (unsupported JSON shape)")
|
||||
}
|
||||
|
||||
generate_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: Generate_Script_Options) -> (core.Comic_Script, shared.App_Error) {
|
||||
if len(cfg.deepseek_api_key) == 0 {
|
||||
return core.Comic_Script{}, shared.config_error("DEEPSEEK_API_KEY is missing")
|
||||
}
|
||||
|
||||
if verr := validate_generate_script_options(opts); !shared.is_ok(verr) {
|
||||
return core.Comic_Script{}, verr
|
||||
}
|
||||
|
||||
request_json := build_deepseek_request_json(opts)
|
||||
defer delete(request_json)
|
||||
attempts := client.max_retries
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
|
||||
last_err := shared.generation_error("unknown deepseek error")
|
||||
for attempt in 1..=attempts {
|
||||
response_json, status_code, transport_err := client.transport(cfg, request_json)
|
||||
if !shared.is_ok(transport_err) {
|
||||
last_err = transport_err
|
||||
} else if status_code >= 400 {
|
||||
last_err = map_http_error(status_code, response_json)
|
||||
} else {
|
||||
if len(response_json) == 0 {
|
||||
last_err = shared.generation_error("deepseek returned empty response")
|
||||
} else {
|
||||
script, parse_err := parse_deepseek_script_response(response_json)
|
||||
if shared.is_ok(parse_err) {
|
||||
return script, shared.ok()
|
||||
}
|
||||
if strings.has_prefix(parse_err.message, "normalized script failed minimal validation") {
|
||||
fallback := build_fallback_script(opts)
|
||||
return fallback, shared.ok()
|
||||
}
|
||||
last_err = parse_err
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < attempts && shared.should_retry(last_err) {
|
||||
_ = backoff_ms(client.initial_backoff_ms, attempt)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return core.Comic_Script{}, last_err
|
||||
}
|
||||
|
||||
generate_comic_script_stub :: proc(cfg: shared.Config, opts: Generate_Script_Options) -> (core.Comic_Script, shared.App_Error) {
|
||||
client := new_deepseek_client()
|
||||
return generate_comic_script(client, cfg, opts)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package adapters
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import filepath "core:path/filepath"
|
||||
import "core:strings"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
Export_Options :: struct {
|
||||
format: core.Export_Format,
|
||||
page_size: core.Page_Size_Name,
|
||||
dpi: int,
|
||||
quality: int,
|
||||
}
|
||||
|
||||
Ordered_Panel :: struct {
|
||||
page_number: int,
|
||||
panel_number: int,
|
||||
panel_id: string,
|
||||
}
|
||||
|
||||
collect_ordered_panels :: proc(layouts: []core.Page_Layout) -> []Ordered_Panel {
|
||||
panels: [dynamic]Ordered_Panel
|
||||
for page in layouts {
|
||||
for p in page.panels {
|
||||
append(&panels, Ordered_Panel{
|
||||
page_number = page.page_number,
|
||||
panel_number = p.panel_number,
|
||||
panel_id = p.panel_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
return panels[:]
|
||||
}
|
||||
|
||||
path_join2 :: proc(a, b: string) -> (string, shared.App_Error) {
|
||||
parts := [2]string{a, b}
|
||||
out, err := filepath.join(parts[:], context.allocator)
|
||||
if err != nil {
|
||||
msg := fmt.aprintf("path join failed: %v", err)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return "", err_out
|
||||
}
|
||||
return out, shared.ok()
|
||||
}
|
||||
|
||||
run_command :: proc(args: []string) -> shared.App_Error {
|
||||
desc := os.Process_Desc{command = args}
|
||||
state, _, stderr, err := os.process_exec(desc, context.temp_allocator)
|
||||
if err != nil {
|
||||
msg := fmt.aprintf("command failed to execute: %v", err)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return err_out
|
||||
}
|
||||
if !state.exited || state.exit_code != 0 {
|
||||
err_text := string(stderr)
|
||||
msg := fmt.aprintf("command failed: %s", err_text)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return err_out
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
file_ext_from_url :: proc(url: string) -> string {
|
||||
base := url
|
||||
if q := strings.index(base, "?"); q >= 0 {
|
||||
base = base[:q]
|
||||
}
|
||||
ext := filepath.ext(base)
|
||||
if len(ext) == 0 {
|
||||
return ".png"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error {
|
||||
staged_count := 0
|
||||
for p, idx in ordered {
|
||||
img := panel_images[p.panel_id]
|
||||
if len(img.url) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := file_ext_from_url(img.url)
|
||||
filename := fmt.aprintf("%03d_page%03d_panel%03d%s", idx+1, p.page_number, p.panel_number, ext)
|
||||
out_path, jerr := path_join2(temp_dir, filename)
|
||||
delete(filename)
|
||||
if !shared.is_ok(jerr) {
|
||||
return jerr
|
||||
}
|
||||
|
||||
if strings.has_prefix(img.url, "file://") {
|
||||
src_path := img.url[len("file://"):]
|
||||
if cerr := os.copy_file(out_path, src_path); cerr != nil {
|
||||
msg := fmt.aprintf("failed to copy local panel image %s: %v", p.panel_id, cerr)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
delete(out_path)
|
||||
return err_out
|
||||
}
|
||||
} else {
|
||||
cmd := [6]string{"curl", "-L", "-sS", "-o", out_path, img.url}
|
||||
cerr := run_command(cmd[:])
|
||||
if !shared.is_ok(cerr) {
|
||||
msg := fmt.aprintf("failed to download panel %s: %s", p.panel_id, cerr.message)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
delete(out_path)
|
||||
return err_out
|
||||
}
|
||||
}
|
||||
|
||||
delete(out_path)
|
||||
staged_count += 1
|
||||
}
|
||||
|
||||
if staged_count == 0 {
|
||||
return shared.new_error(.Export, "no panel images available for export", false)
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
write_comic_info_xml :: proc(temp_dir: string, page_count: int) -> shared.App_Error {
|
||||
comic_info := fmt.aprintf("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<ComicInfo>\n <Title>Generated Comic</Title>\n <PageCount>%d</PageCount>\n</ComicInfo>\n", page_count)
|
||||
defer delete(comic_info)
|
||||
path, jerr := path_join2(temp_dir, "ComicInfo.xml")
|
||||
if !shared.is_ok(jerr) {
|
||||
return jerr
|
||||
}
|
||||
defer delete(path)
|
||||
if err := os.write_entire_file(path, comic_info); err != nil {
|
||||
msg := fmt.aprintf("failed to write ComicInfo.xml: %v", err)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return err_out
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
zip_directory_with_python :: proc(output_path, source_dir: string, include_comic_info: bool) -> shared.App_Error {
|
||||
script := "import os,sys,zipfile\nout=sys.argv[1]; src=sys.argv[2]; inc=sys.argv[3]=='1'\nwith zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as z:\n for n in sorted(os.listdir(src)):\n if (not inc) and n=='ComicInfo.xml':\n continue\n p=os.path.join(src,n)\n if os.path.isfile(p):\n z.write(p,arcname=n)"
|
||||
inc := "0"
|
||||
if include_comic_info {
|
||||
inc = "1"
|
||||
}
|
||||
cmd := [6]string{"python3", "-c", script, output_path, source_dir, inc}
|
||||
return run_command(cmd[:])
|
||||
}
|
||||
|
||||
write_simple_pdf :: proc(output_path: string, ordered: []Ordered_Panel) -> shared.App_Error {
|
||||
text := fmt.aprintf("Comic Export - Panels: %d", len(ordered))
|
||||
defer delete(text)
|
||||
content := fmt.aprintf("BT /F1 12 Tf 50 780 Td (%s) Tj ET", text)
|
||||
defer delete(content)
|
||||
stream := fmt.aprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)
|
||||
defer delete(stream)
|
||||
|
||||
obj1 := "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
|
||||
obj2 := "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
|
||||
obj3 := "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n"
|
||||
obj4 := fmt.aprintf("4 0 obj\n%s\nendobj\n", stream)
|
||||
defer delete(obj4)
|
||||
obj5 := "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
|
||||
|
||||
xref_start := len("%PDF-1.4\n") + len(obj1) + len(obj2) + len(obj3) + len(obj4) + len(obj5)
|
||||
pdf := fmt.aprintf(
|
||||
"%%PDF-1.4\n%s%s%s%s%sxref\n0 6\n0000000000 65535 f \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
|
||||
obj1, obj2, obj3, obj4, obj5, xref_start,
|
||||
)
|
||||
defer delete(pdf)
|
||||
|
||||
if err := os.write_entire_file(output_path, pdf); err != nil {
|
||||
msg := fmt.aprintf("failed to write pdf: %v", err)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return err_out
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
ensure_export_output_parent_dir :: proc(output_path: string) -> shared.App_Error {
|
||||
dir, _ := filepath.split(output_path)
|
||||
if len(dir) == 0 {
|
||||
return shared.ok()
|
||||
}
|
||||
if err := os.mkdir_all(dir); err != nil && err != .Exist {
|
||||
msg := fmt.aprintf("failed to create export output directory: %v", err)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return err_out
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
export_comic :: proc(output_path: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image, opts: Export_Options) -> shared.App_Error {
|
||||
if len(output_path) == 0 {
|
||||
return shared.new_error(.Export, "output path is empty", false)
|
||||
}
|
||||
if len(layouts) == 0 {
|
||||
return shared.new_error(.Export, "no layouts available for export", false)
|
||||
}
|
||||
if derr := ensure_export_output_parent_dir(output_path); !shared.is_ok(derr) {
|
||||
return derr
|
||||
}
|
||||
|
||||
ordered := collect_ordered_panels(layouts)
|
||||
defer delete(ordered)
|
||||
if len(ordered) == 0 {
|
||||
return shared.new_error(.Export, "no panels available for export", false)
|
||||
}
|
||||
|
||||
switch opts.format {
|
||||
case .PDF:
|
||||
return write_simple_pdf(output_path, ordered)
|
||||
case .CBZ, .PNG:
|
||||
temp_dir, terr := os.make_directory_temp("", "comic-export-*", context.temp_allocator)
|
||||
if terr != nil {
|
||||
msg := fmt.aprintf("failed to create temp dir: %v", terr)
|
||||
err_out := shared.new_error(.Export, msg, true)
|
||||
delete(msg)
|
||||
return err_out
|
||||
}
|
||||
defer os.remove_all(temp_dir)
|
||||
|
||||
if serr := stage_panel_images(temp_dir, ordered, panel_images); !shared.is_ok(serr) {
|
||||
return serr
|
||||
}
|
||||
|
||||
include_comic_info := opts.format == .CBZ
|
||||
if include_comic_info {
|
||||
if ierr := write_comic_info_xml(temp_dir, len(layouts)); !shared.is_ok(ierr) {
|
||||
return ierr
|
||||
}
|
||||
}
|
||||
|
||||
if zerr := zip_directory_with_python(output_path, temp_dir, include_comic_info); !shared.is_ok(zerr) {
|
||||
return zerr
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
return shared.new_error(.Export, "unknown export format", false)
|
||||
}
|
||||
|
||||
export_comic_stub :: proc(output_path: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image, opts: Export_Options) -> shared.App_Error {
|
||||
return export_comic(output_path, layouts, panel_images, opts)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package adapters
|
||||
|
||||
import json "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, seed: i64) -> (image_url: string, status_code: int, err: shared.App_Error)
|
||||
|
||||
Fal_Generation_Queue :: struct {
|
||||
max_concurrency: int,
|
||||
in_flight: int,
|
||||
}
|
||||
|
||||
new_fal_queue :: proc(max_concurrency: int) -> Fal_Generation_Queue {
|
||||
cap := max_concurrency
|
||||
if cap < 1 {
|
||||
cap = 1
|
||||
}
|
||||
return Fal_Generation_Queue{max_concurrency = cap, in_flight = 0}
|
||||
}
|
||||
|
||||
try_acquire_slot :: proc(q: ^Fal_Generation_Queue) -> bool {
|
||||
if q.in_flight >= q.max_concurrency {
|
||||
return false
|
||||
}
|
||||
q.in_flight += 1
|
||||
return true
|
||||
}
|
||||
|
||||
release_slot :: proc(q: ^Fal_Generation_Queue) {
|
||||
if q.in_flight > 0 {
|
||||
q.in_flight -= 1
|
||||
}
|
||||
}
|
||||
|
||||
Fal_Client :: struct {
|
||||
transport: Fal_Transport,
|
||||
max_retries: int,
|
||||
initial_backoff_ms: int,
|
||||
queue: ^Fal_Generation_Queue,
|
||||
}
|
||||
|
||||
fal_parse_curl_output :: proc(output: string) -> (body: string, status_code: int, ok: bool) {
|
||||
marker := "__STATUS__:"
|
||||
idx := strings.last_index(output, marker)
|
||||
if idx < 0 {
|
||||
return output, 0, false
|
||||
}
|
||||
|
||||
body = output[:idx]
|
||||
status_str := output[idx+len(marker):]
|
||||
status_code = 0
|
||||
for c in status_str {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
status_code = status_code*10 + int(c-'0')
|
||||
}
|
||||
return body, status_code, true
|
||||
}
|
||||
|
||||
Fal_Image :: struct {
|
||||
url: string,
|
||||
width: int,
|
||||
height: int,
|
||||
}
|
||||
|
||||
Fal_Response :: struct {
|
||||
images: []Fal_Image,
|
||||
}
|
||||
|
||||
dispose_fal_response :: proc(resp: ^Fal_Response) {
|
||||
for img in resp.images {
|
||||
delete(img.url)
|
||||
}
|
||||
delete(resp.images)
|
||||
}
|
||||
|
||||
fal_parse_response_body :: proc(body: string) -> (Fal_Response, shared.App_Error) {
|
||||
resp: Fal_Response
|
||||
if err := json.unmarshal_string(body, &resp); err != nil {
|
||||
return Fal_Response{}, shared.generation_error(fmt.aprintf("failed to parse fal response JSON: %v", err))
|
||||
}
|
||||
if len(resp.images) == 0 {
|
||||
return Fal_Response{}, shared.generation_error("fal response missing images array")
|
||||
}
|
||||
if len(resp.images[0].url) == 0 {
|
||||
return Fal_Response{}, shared.generation_error("fal response image url is empty")
|
||||
}
|
||||
return resp, shared.ok()
|
||||
}
|
||||
|
||||
fal_json_escape :: proc(s: string) -> string {
|
||||
out: [dynamic]u8
|
||||
for c in s {
|
||||
switch c {
|
||||
case '"':
|
||||
append(&out, '\\')
|
||||
append(&out, '"')
|
||||
case '\\':
|
||||
append(&out, '\\')
|
||||
append(&out, '\\')
|
||||
case '\n':
|
||||
append(&out, '\\')
|
||||
append(&out, 'n')
|
||||
case '\r':
|
||||
append(&out, '\\')
|
||||
append(&out, 'r')
|
||||
case '\t':
|
||||
append(&out, '\\')
|
||||
append(&out, 't')
|
||||
case:
|
||||
append(&out, u8(c))
|
||||
}
|
||||
}
|
||||
return string(out[:])
|
||||
}
|
||||
|
||||
default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt: string, seed: i64) -> (string, int, shared.App_Error) {
|
||||
url := fmt.aprintf("https://fal.run/fal-ai/%s", endpoint)
|
||||
auth := fmt.aprintf("Authorization: Key %s", cfg.fal_api_key)
|
||||
payload := fmt.aprintf("{\"prompt\":\"%s\",\"seed\":%d}", fal_json_escape(prompt), seed)
|
||||
|
||||
cmd := [13]string{
|
||||
"curl", "-sS", "-X", "POST", url,
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", auth,
|
||||
"-d", payload,
|
||||
"-w", "\\n__STATUS__:%{http_code}",
|
||||
}
|
||||
desc := os.Process_Desc{command = cmd[:]}
|
||||
state, stdout, stderr, exec_err := os.process_exec(desc, context.temp_allocator)
|
||||
if exec_err != nil {
|
||||
return "", 0, shared.network_error(fmt.aprintf("curl execution failed: %v", exec_err))
|
||||
}
|
||||
if !state.exited || state.exit_code != 0 {
|
||||
return "", 0, shared.network_error(fmt.aprintf("curl failed: %s", string(stderr)))
|
||||
}
|
||||
|
||||
body, status_code, ok := fal_parse_curl_output(string(stdout))
|
||||
if !ok {
|
||||
return "", 0, shared.network_error("unable to parse curl status output")
|
||||
}
|
||||
|
||||
if status_code >= 400 {
|
||||
return "", status_code, shared.ok()
|
||||
}
|
||||
|
||||
resp, parse_err := fal_parse_response_body(body)
|
||||
if !shared.is_ok(parse_err) {
|
||||
return "", status_code, parse_err
|
||||
}
|
||||
return resp.images[0].url, status_code, shared.ok()
|
||||
}
|
||||
|
||||
new_fal_client :: proc(queue: ^Fal_Generation_Queue) -> Fal_Client {
|
||||
return Fal_Client{
|
||||
transport = default_fal_transport,
|
||||
max_retries = 3,
|
||||
initial_backoff_ms = 500,
|
||||
queue = queue,
|
||||
}
|
||||
}
|
||||
|
||||
fal_http_error :: proc(status_code: int) -> shared.App_Error {
|
||||
if status_code == 429 {
|
||||
return shared.rate_limit_error("fal rate-limited (429)")
|
||||
}
|
||||
if status_code >= 500 {
|
||||
return shared.network_error(fmt.aprintf("fal server error (%d)", status_code))
|
||||
}
|
||||
if status_code >= 400 {
|
||||
return shared.validation_error(fmt.aprintf("fal request failed (%d)", status_code))
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
fal_backoff_ms :: proc(initial_ms, attempt: int) -> int {
|
||||
if attempt <= 0 {
|
||||
return 0
|
||||
}
|
||||
mul := 1 << u32(attempt-1)
|
||||
return initial_ms * mul
|
||||
}
|
||||
|
||||
generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) {
|
||||
if len(cfg.fal_api_key) == 0 {
|
||||
return "", shared.config_error("FAL_API_KEY is missing")
|
||||
}
|
||||
if client.queue == nil {
|
||||
return "", shared.config_error("fal queue is not configured")
|
||||
}
|
||||
if !try_acquire_slot(client.queue) {
|
||||
return "", shared.generation_error("fal queue saturated")
|
||||
}
|
||||
defer release_slot(client.queue)
|
||||
|
||||
prompt := core.build_character_prompt(c, "standing in neutral pose", "clean background", "studio lighting", art_style)
|
||||
|
||||
attempts := client.max_retries
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
|
||||
last_err := shared.generation_error("unknown fal character generation error")
|
||||
for attempt in 1..=attempts {
|
||||
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, c.seed)
|
||||
if !shared.is_ok(transport_err) {
|
||||
last_err = transport_err
|
||||
} else if status_code >= 400 {
|
||||
last_err = fal_http_error(status_code)
|
||||
} else if len(url) == 0 {
|
||||
last_err = shared.generation_error("fal returned empty image url")
|
||||
} else {
|
||||
return url, shared.ok()
|
||||
}
|
||||
|
||||
if attempt < attempts && shared.should_retry(last_err) {
|
||||
_ = fal_backoff_ms(client.initial_backoff_ms, attempt)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return "", last_err
|
||||
}
|
||||
|
||||
generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) {
|
||||
if len(cfg.fal_api_key) == 0 {
|
||||
return core.Panel_Image{}, shared.config_error("FAL_API_KEY is missing")
|
||||
}
|
||||
if client.queue == nil {
|
||||
return core.Panel_Image{}, shared.config_error("fal queue is not configured")
|
||||
}
|
||||
if !try_acquire_slot(client.queue) {
|
||||
return core.Panel_Image{}, shared.generation_error("fal queue saturated")
|
||||
}
|
||||
defer release_slot(client.queue)
|
||||
|
||||
_ = characters
|
||||
seed := core.generate_panel_seed(project_id, 1, panel.panel_number, panel.panel_id)
|
||||
prompt := fmt.aprintf("%s comic panel. %s", art_style, panel.description)
|
||||
|
||||
attempts := client.max_retries
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
|
||||
last_err := shared.generation_error("unknown fal panel generation error")
|
||||
for attempt in 1..=attempts {
|
||||
url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, seed)
|
||||
if !shared.is_ok(transport_err) {
|
||||
last_err = transport_err
|
||||
} else if status_code >= 400 {
|
||||
last_err = fal_http_error(status_code)
|
||||
} else if len(url) == 0 {
|
||||
last_err = shared.generation_error("fal returned empty image url")
|
||||
} else {
|
||||
return core.Panel_Image{url = url, width = 1024, height = 1024, seed = seed, prompt = prompt}, shared.ok()
|
||||
}
|
||||
|
||||
if attempt < attempts && shared.should_retry(last_err) {
|
||||
_ = fal_backoff_ms(client.initial_backoff_ms, attempt)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return core.Panel_Image{}, last_err
|
||||
}
|
||||
|
||||
generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id: string) -> (map[string]core.Panel_Image, shared.App_Error) {
|
||||
results := make(map[string]core.Panel_Image)
|
||||
|
||||
for p in panels {
|
||||
img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id)
|
||||
if !shared.is_ok(err) {
|
||||
return results, err
|
||||
}
|
||||
results[p.panel_id] = img
|
||||
}
|
||||
|
||||
return results, shared.ok()
|
||||
}
|
||||
|
||||
generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) {
|
||||
q := new_fal_queue(2)
|
||||
client := new_fal_client(&q)
|
||||
return generate_character_reference(client, cfg, c, art_style)
|
||||
}
|
||||
|
||||
generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) {
|
||||
q := new_fal_queue(2)
|
||||
client := new_fal_client(&q)
|
||||
return generate_panel_image(client, cfg, panel, characters, art_style, project_id)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package adapters
|
||||
|
||||
import json "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import filepath "core:path/filepath"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
CURRENT_PROJECT_SCHEMA_VERSION :: 1
|
||||
|
||||
Comic_Project_Document :: struct {
|
||||
schemaVersion: int,
|
||||
assetCacheDir: string,
|
||||
state: core.Comic_State,
|
||||
}
|
||||
|
||||
join_paths :: proc(a, b: string) -> (string, shared.App_Error) {
|
||||
parts := [2]string{a, b}
|
||||
joined, err := filepath.join(parts[:], context.temp_allocator)
|
||||
if err != nil {
|
||||
return "", shared.new_error(.Storage, fmt.aprintf("path join failed: %v", err), false)
|
||||
}
|
||||
return joined, shared.ok()
|
||||
}
|
||||
|
||||
derive_asset_cache_dir :: proc(project_file_path: string) -> (string, shared.App_Error) {
|
||||
dir, _ := filepath.split(project_file_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "."
|
||||
}
|
||||
return join_paths(dir, "assets")
|
||||
}
|
||||
|
||||
ensure_project_layout :: proc(project_file_path: string) -> shared.App_Error {
|
||||
dir, _ := filepath.split(project_file_path)
|
||||
if len(dir) > 0 {
|
||||
if err := os.mkdir_all(dir); err != nil && err != .Exist {
|
||||
return shared.new_error(.Storage, fmt.aprintf("failed to create project directory: %v", err), true)
|
||||
}
|
||||
}
|
||||
|
||||
asset_dir, aerr := derive_asset_cache_dir(project_file_path)
|
||||
if !shared.is_ok(aerr) {
|
||||
return aerr
|
||||
}
|
||||
if err := os.mkdir_all(asset_dir); err != nil && err != .Exist {
|
||||
return shared.new_error(.Storage, fmt.aprintf("failed to create asset cache directory: %v", err), true)
|
||||
}
|
||||
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
migrate_document_to_current_state :: proc(doc: Comic_Project_Document) -> (core.Comic_State, shared.App_Error) {
|
||||
switch doc.schemaVersion {
|
||||
case 1:
|
||||
return doc.state, shared.ok()
|
||||
case:
|
||||
return core.Comic_State{}, shared.new_error(.Storage, fmt.aprintf("unsupported schemaVersion: %d", doc.schemaVersion), false)
|
||||
}
|
||||
}
|
||||
|
||||
save_project :: proc(path: string, state: core.Comic_State) -> shared.App_Error {
|
||||
if len(path) == 0 {
|
||||
return shared.new_error(.Storage, "save path is empty", false)
|
||||
}
|
||||
|
||||
if lerr := ensure_project_layout(path); !shared.is_ok(lerr) {
|
||||
return lerr
|
||||
}
|
||||
|
||||
asset_dir, derr := derive_asset_cache_dir(path)
|
||||
if !shared.is_ok(derr) {
|
||||
return derr
|
||||
}
|
||||
|
||||
doc := Comic_Project_Document{
|
||||
schemaVersion = CURRENT_PROJECT_SCHEMA_VERSION,
|
||||
assetCacheDir = asset_dir,
|
||||
state = state,
|
||||
}
|
||||
|
||||
payload, merr := json.marshal(doc, json.Marshal_Options{pretty = true, use_spaces = true, spaces = 2}, context.temp_allocator)
|
||||
if merr != nil {
|
||||
return shared.new_error(.Storage, fmt.aprintf("failed to marshal project: %v", merr), false)
|
||||
}
|
||||
|
||||
if werr := os.write_entire_file(path, payload); werr != nil {
|
||||
return shared.new_error(.Storage, fmt.aprintf("failed to write project file: %v", werr), true)
|
||||
}
|
||||
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
load_project :: proc(path: string) -> (core.Comic_State, shared.App_Error) {
|
||||
if len(path) == 0 {
|
||||
return core.Comic_State{}, shared.new_error(.Storage, "load path is empty", false)
|
||||
}
|
||||
if !os.exists(path) {
|
||||
return core.Comic_State{}, shared.new_error(.Storage, "project file does not exist", false)
|
||||
}
|
||||
|
||||
payload, rerr := os.read_entire_file(path, context.temp_allocator)
|
||||
if rerr != nil {
|
||||
return core.Comic_State{}, shared.new_error(.Storage, fmt.aprintf("failed to read project file: %v", rerr), true)
|
||||
}
|
||||
|
||||
doc: Comic_Project_Document
|
||||
if err := json.unmarshal(payload, &doc); err == nil {
|
||||
if doc.schemaVersion <= 0 {
|
||||
return core.Comic_State{}, shared.new_error(.Storage, "invalid project schemaVersion", false)
|
||||
}
|
||||
state, merr := migrate_document_to_current_state(doc)
|
||||
delete(doc.assetCacheDir)
|
||||
return state, merr
|
||||
}
|
||||
|
||||
// Legacy fallback: raw Comic_State payload without wrapper
|
||||
legacy: core.Comic_State
|
||||
if err := json.unmarshal(payload, &legacy); err == nil {
|
||||
return legacy, shared.ok()
|
||||
}
|
||||
|
||||
return core.Comic_State{}, shared.new_error(.Storage, "failed to decode project payload", false)
|
||||
}
|
||||
|
||||
save_project_stub :: proc(path: string, state: core.Comic_State) -> shared.App_Error {
|
||||
return save_project(path, state)
|
||||
}
|
||||
|
||||
load_project_stub :: proc(path: string) -> (core.Comic_State, shared.App_Error) {
|
||||
return load_project(path)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
main :: proc() {
|
||||
cfg := shared.load_config()
|
||||
state := core.new_initial_state()
|
||||
defer core.dispose_state(&state)
|
||||
|
||||
fmt.println("comic-odin: phase6 cli runtime")
|
||||
fmt.printf("DeepSeek configured: %v\n", len(cfg.deepseek_api_key) > 0)
|
||||
|
||||
out, err := run_cli_from_process_args(&state)
|
||||
if !shared.is_ok(err) {
|
||||
fmt.printf("Error: %s\n", err.message)
|
||||
if len(out) > 0 {
|
||||
fmt.println(out)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.println(out)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package core
|
||||
|
||||
import "core:fmt"
|
||||
|
||||
DEFAULT_BUBBLE_STYLE := Speech_Bubble_Style{
|
||||
background_color = "#ffffff",
|
||||
border_color = "#000000",
|
||||
border_width = 2,
|
||||
border_radius = 16,
|
||||
font_family = "Comic Sans MS, cursive, sans-serif",
|
||||
font_size = 14,
|
||||
text_color = "#000000",
|
||||
padding = 12,
|
||||
}
|
||||
|
||||
clampf :: proc(v, minv, maxv: f32) -> f32 {
|
||||
if v < minv { return minv }
|
||||
if v > maxv { return maxv }
|
||||
return v
|
||||
}
|
||||
|
||||
calculate_bubble_size :: proc(text: string, style: Speech_Bubble_Style) -> Size {
|
||||
char_width := style.font_size * 0.6
|
||||
char_height := style.font_size * 1.4
|
||||
chars_per_line: f32 = 25
|
||||
lines := f32(len(text)) / chars_per_line
|
||||
if lines < 1 {
|
||||
lines = 1
|
||||
}
|
||||
|
||||
width := f32(len(text))*char_width/lines + style.padding*2
|
||||
width = clampf(width, 100, 300)
|
||||
height := lines*char_height + style.padding*2 + 20
|
||||
|
||||
return Size{width = width, height = height}
|
||||
}
|
||||
|
||||
auto_place_bubble :: proc(bubble: Speech_Bubble, panel_w, panel_h: f32, speaker_pos: Position) -> Speech_Bubble {
|
||||
updated := bubble
|
||||
size := calculate_bubble_size(bubble.text, bubble.style)
|
||||
updated.size = size
|
||||
|
||||
speaker := speaker_pos
|
||||
if speaker.x == 0 && speaker.y == 0 {
|
||||
speaker = Position{x = panel_w * 0.5, y = panel_h * 0.7}
|
||||
}
|
||||
|
||||
switch bubble.type {
|
||||
case .Thought:
|
||||
updated.position = Position{
|
||||
x = clampf(speaker.x-size.width/2, 20, panel_w-size.width-20),
|
||||
y = clampf(speaker.y-size.height-40, 20, panel_h-size.height-10),
|
||||
}
|
||||
updated.tail_target = Position{x = speaker.x, y = speaker.y - 20}
|
||||
updated.tail_direction = "bottom"
|
||||
case .Shout:
|
||||
updated.size = Size{width = size.width * 1.1, height = size.height * 1.1}
|
||||
updated.position = Position{
|
||||
x = clampf(speaker.x-updated.size.width/2, 10, panel_w-updated.size.width-10),
|
||||
y = clampf(speaker.y-updated.size.height-30, 10, panel_h-updated.size.height-10),
|
||||
}
|
||||
updated.tail_target = Position{x = speaker.x, y = speaker.y - 10}
|
||||
updated.tail_direction = "bottom"
|
||||
case .Whisper:
|
||||
updated.position = Position{
|
||||
x = clampf(speaker.x-size.width/2, 20, panel_w-size.width-20),
|
||||
y = clampf(speaker.y-size.height-30, 20, panel_h-size.height-10),
|
||||
}
|
||||
updated.tail_target = Position{x = speaker.x, y = speaker.y - 20}
|
||||
updated.tail_direction = "bottom"
|
||||
case .Narration:
|
||||
updated.position = Position{x = panel_w * 0.1, y = 20}
|
||||
updated.size = Size{width = panel_w * 0.8, height = size.height}
|
||||
updated.tail_direction = "bottom"
|
||||
case .Sound_Effect:
|
||||
updated.position = Position{
|
||||
x = clampf(speaker.x+20, 10, panel_w-size.width-10),
|
||||
y = clampf(speaker.y-size.height-50, 10, panel_h-size.height-10),
|
||||
}
|
||||
updated.tail_target = Position{x = speaker.x + 30, y = speaker.y - 30}
|
||||
updated.tail_direction = "bottom-left"
|
||||
case .Normal:
|
||||
updated.position = Position{
|
||||
x = clampf(speaker.x-size.width/2, 20, panel_w-size.width-20),
|
||||
y = clampf(speaker.y-size.height-30, 20, panel_h-size.height-10),
|
||||
}
|
||||
updated.tail_target = Position{x = speaker.x, y = speaker.y - 20}
|
||||
updated.tail_direction = "bottom"
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
auto_place_panel_bubbles :: proc(panel: Panel, panel_w, panel_h: f32) -> []Speech_Bubble {
|
||||
if len(panel.dialogue) == 0 && len(panel.caption) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bubbles: [dynamic]Speech_Bubble
|
||||
|
||||
for dialogue, idx in panel.dialogue {
|
||||
speaker_count := len(panel.characters_present)
|
||||
speaker_idx := 0
|
||||
for char_id, j in panel.characters_present {
|
||||
if char_id == dialogue.speaker_id {
|
||||
speaker_idx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
speaker_x := panel_w * 0.5
|
||||
if speaker_count > 1 {
|
||||
speaker_x = (panel_w / f32(speaker_count + 1)) * f32(speaker_idx + 1)
|
||||
}
|
||||
speaker_y := panel_h * 0.75
|
||||
|
||||
bubble := Speech_Bubble{
|
||||
id = fmt.aprintf("bubble_%s_%d", panel.panel_id, idx),
|
||||
panel_id = panel.panel_id,
|
||||
type = dialogue.bubble_type,
|
||||
text = dialogue.text,
|
||||
position = Position{},
|
||||
size = Size{width = 100, height = 50},
|
||||
tail_direction = "bottom",
|
||||
tail_target = Position{x = speaker_x, y = speaker_y},
|
||||
style = DEFAULT_BUBBLE_STYLE,
|
||||
speaker_id = dialogue.speaker_id,
|
||||
}
|
||||
|
||||
placed := auto_place_bubble(bubble, panel_w, panel_h, Position{x = speaker_x, y = speaker_y})
|
||||
placed.position.y = clampf(placed.position.y-f32(idx*10), 10, panel_h-placed.size.height-10)
|
||||
placed.position.x = clampf(placed.position.x, 10, panel_w-placed.size.width-10)
|
||||
|
||||
append(&bubbles, placed)
|
||||
}
|
||||
|
||||
if len(panel.caption) > 0 {
|
||||
caption_bubble := Speech_Bubble{
|
||||
id = fmt.aprintf("caption_%s", panel.panel_id),
|
||||
panel_id = panel.panel_id,
|
||||
type = .Narration,
|
||||
text = panel.caption,
|
||||
position = Position{x = panel_w * 0.1, y = 20},
|
||||
size = Size{width = panel_w * 0.8, height = 40},
|
||||
tail_direction = "bottom",
|
||||
tail_target = Position{x = panel_w * 0.5, y = 60},
|
||||
style = Speech_Bubble_Style{
|
||||
background_color = "#f5f5f5",
|
||||
border_color = "#999999",
|
||||
border_width = DEFAULT_BUBBLE_STYLE.border_width,
|
||||
border_radius = DEFAULT_BUBBLE_STYLE.border_radius,
|
||||
font_family = DEFAULT_BUBBLE_STYLE.font_family,
|
||||
font_size = DEFAULT_BUBBLE_STYLE.font_size,
|
||||
text_color = DEFAULT_BUBBLE_STYLE.text_color,
|
||||
padding = DEFAULT_BUBBLE_STYLE.padding,
|
||||
},
|
||||
speaker_id = "",
|
||||
}
|
||||
append(&bubbles, caption_bubble)
|
||||
}
|
||||
|
||||
return bubbles[:]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package core
|
||||
|
||||
import "core:fmt"
|
||||
|
||||
DEFAULT_PROMPT_AGE :: "young adult"
|
||||
DEFAULT_PROMPT_GENDER :: "person"
|
||||
DEFAULT_PROMPT_BODY :: "average"
|
||||
DEFAULT_PROMPT_OUTFIT :: "casual clothing"
|
||||
DEFAULT_PROMPT_ACCESSORIES :: "no accessories"
|
||||
|
||||
build_character_prompt :: proc(c: Character, action, setting, lighting, art_style: string) -> string {
|
||||
t := c.prompt_template
|
||||
|
||||
age := t.age
|
||||
if len(age) == 0 { age = DEFAULT_PROMPT_AGE }
|
||||
gender := t.gender
|
||||
if len(gender) == 0 { gender = DEFAULT_PROMPT_GENDER }
|
||||
body := t.body_type
|
||||
if len(body) == 0 { body = DEFAULT_PROMPT_BODY }
|
||||
outfit := t.outfit
|
||||
if len(outfit) == 0 { outfit = DEFAULT_PROMPT_OUTFIT }
|
||||
accessories := t.accessories
|
||||
if len(accessories) == 0 { accessories = DEFAULT_PROMPT_ACCESSORIES }
|
||||
|
||||
return fmt.aprintf(
|
||||
"%s style. %s-year-old %s, %s %s hair, %s eyes, %s skin, %s build, wearing %s, %s, %s, %s, %s, %s",
|
||||
art_style,
|
||||
age,
|
||||
gender,
|
||||
t.hair_color,
|
||||
t.hair_style,
|
||||
t.eye_color,
|
||||
t.skin_tone,
|
||||
body,
|
||||
outfit,
|
||||
accessories,
|
||||
t.distinguishing_features,
|
||||
action,
|
||||
setting,
|
||||
lighting,
|
||||
)
|
||||
}
|
||||
|
||||
derive_seed_from_string :: proc(s: string) -> i64 {
|
||||
hash: i32 = 0
|
||||
for r in s {
|
||||
hash = ((hash << 5) - hash) + i32(r)
|
||||
}
|
||||
|
||||
res := i64(hash)
|
||||
if res < 0 {
|
||||
res = -res
|
||||
}
|
||||
return res % 2147483647
|
||||
}
|
||||
|
||||
hash_char :: proc(hash: ^i32, c: u8) {
|
||||
hash^ = ((hash^ << 5) - hash^) + i32(c)
|
||||
}
|
||||
|
||||
hash_string :: proc(hash: ^i32, s: string) {
|
||||
for r in s {
|
||||
hash^ = ((hash^ << 5) - hash^) + i32(r)
|
||||
}
|
||||
}
|
||||
|
||||
hash_decimal_int :: proc(hash: ^i32, n: int) {
|
||||
if n == 0 {
|
||||
hash_char(hash, '0')
|
||||
return
|
||||
}
|
||||
|
||||
value := n
|
||||
if value < 0 {
|
||||
hash_char(hash, '-')
|
||||
value = -value
|
||||
}
|
||||
|
||||
buf: [20]u8
|
||||
i := len(buf)
|
||||
for value > 0 {
|
||||
i -= 1
|
||||
d := value % 10
|
||||
buf[i] = u8('0' + d)
|
||||
value /= 10
|
||||
}
|
||||
|
||||
for ; i < len(buf); i += 1 {
|
||||
hash_char(hash, buf[i])
|
||||
}
|
||||
}
|
||||
|
||||
generate_panel_seed :: proc(project_id: string, page_number, panel_number: int, panel_id: string) -> i64 {
|
||||
hash: i32 = 0
|
||||
hash_string(&hash, project_id)
|
||||
hash_char(&hash, '_')
|
||||
hash_decimal_int(&hash, page_number)
|
||||
hash_char(&hash, '_')
|
||||
hash_decimal_int(&hash, panel_number)
|
||||
hash_char(&hash, '_')
|
||||
hash_string(&hash, panel_id)
|
||||
|
||||
res := i64(hash)
|
||||
if res < 0 {
|
||||
res = -res
|
||||
}
|
||||
return res % 2147483647
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package core
|
||||
|
||||
dispose_character :: proc(c: Character) {
|
||||
delete(c.character_sheet_urls)
|
||||
}
|
||||
|
||||
dispose_character_owned :: proc(c: Character) {
|
||||
delete(c.id)
|
||||
delete(c.name)
|
||||
delete(c.description)
|
||||
delete(c.reference_image_url)
|
||||
delete(c.first_appearance_panel)
|
||||
delete(c.character_sheet_urls)
|
||||
}
|
||||
|
||||
dispose_script :: proc(script: ^Comic_Script) {
|
||||
for c in script.characters {
|
||||
dispose_character(c)
|
||||
}
|
||||
for p in script.pages {
|
||||
for pan in p.panels {
|
||||
delete(pan.characters_present)
|
||||
delete(pan.dialogue)
|
||||
delete(pan.sound_effects)
|
||||
}
|
||||
delete(p.panels)
|
||||
}
|
||||
delete(script.characters)
|
||||
delete(script.pages)
|
||||
}
|
||||
|
||||
dispose_script_owned :: proc(script: ^Comic_Script) {
|
||||
delete(script.title)
|
||||
delete(script.synopsis)
|
||||
|
||||
for c in script.characters {
|
||||
dispose_character_owned(c)
|
||||
}
|
||||
for p in script.pages {
|
||||
for pan in p.panels {
|
||||
delete(pan.panel_id)
|
||||
delete(pan.description)
|
||||
delete(pan.caption)
|
||||
for id in pan.characters_present { delete(id) }
|
||||
delete(pan.characters_present)
|
||||
for d in pan.dialogue {
|
||||
delete(d.speaker_id)
|
||||
delete(d.text)
|
||||
delete(d.emotion)
|
||||
}
|
||||
delete(pan.dialogue)
|
||||
for s in pan.sound_effects { delete(s) }
|
||||
delete(pan.sound_effects)
|
||||
}
|
||||
delete(p.panels)
|
||||
}
|
||||
delete(script.characters)
|
||||
delete(script.pages)
|
||||
}
|
||||
|
||||
dispose_page_layouts :: proc(layouts: ^[]Page_Layout) {
|
||||
for l in layouts^ {
|
||||
delete(l.panels)
|
||||
}
|
||||
delete(layouts^)
|
||||
}
|
||||
|
||||
dispose_speech_bubbles :: proc(bubbles: ^map[string][]Speech_Bubble) {
|
||||
for _, s in bubbles^ {
|
||||
delete(s)
|
||||
}
|
||||
delete(bubbles^)
|
||||
}
|
||||
|
||||
dispose_state :: proc(state: ^Comic_State) {
|
||||
dispose_script(&state.script)
|
||||
|
||||
for c in state.characters {
|
||||
dispose_character(c)
|
||||
}
|
||||
if raw_data(state.characters) != raw_data(state.script.characters) {
|
||||
delete(state.characters)
|
||||
}
|
||||
|
||||
delete(state.panel_images)
|
||||
delete(state.panel_errors)
|
||||
dispose_page_layouts(&state.page_layouts)
|
||||
dispose_speech_bubbles(&state.speech_bubbles)
|
||||
delete(state.workflow.completed_steps)
|
||||
|
||||
state.panel_images = nil
|
||||
state.panel_errors = nil
|
||||
state.speech_bubbles = nil
|
||||
state.page_layouts = nil
|
||||
state.characters = nil
|
||||
state.workflow.completed_steps = nil
|
||||
}
|
||||
|
||||
dispose_state_owned :: proc(state: ^Comic_State) {
|
||||
delete(state.project.project_id)
|
||||
delete(state.project.project_name)
|
||||
delete(state.project.created_at_iso)
|
||||
delete(state.project.last_modified_iso)
|
||||
delete(state.story_idea)
|
||||
delete(state.story_genre)
|
||||
delete(state.target_audience)
|
||||
delete(state.art_style)
|
||||
delete(state.workflow.error_message)
|
||||
|
||||
dispose_script_owned(&state.script)
|
||||
|
||||
for c in state.characters {
|
||||
dispose_character_owned(c)
|
||||
}
|
||||
if raw_data(state.characters) != raw_data(state.script.characters) {
|
||||
delete(state.characters)
|
||||
}
|
||||
|
||||
for _, img in state.panel_images {
|
||||
delete(img.url)
|
||||
delete(img.prompt)
|
||||
}
|
||||
delete(state.panel_images)
|
||||
|
||||
for _, msg in state.panel_errors {
|
||||
delete(msg)
|
||||
}
|
||||
delete(state.panel_errors)
|
||||
|
||||
dispose_page_layouts(&state.page_layouts)
|
||||
dispose_speech_bubbles(&state.speech_bubbles)
|
||||
delete(state.workflow.completed_steps)
|
||||
|
||||
state.panel_images = nil
|
||||
state.panel_errors = nil
|
||||
state.speech_bubbles = nil
|
||||
state.page_layouts = nil
|
||||
state.characters = nil
|
||||
state.workflow.completed_steps = nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package core
|
||||
|
||||
Page_Size :: struct {
|
||||
name: string,
|
||||
width: int,
|
||||
height: int,
|
||||
dpi: int,
|
||||
}
|
||||
|
||||
get_page_size :: proc(name: Page_Size_Name) -> Page_Size {
|
||||
switch name {
|
||||
case .A4:
|
||||
return Page_Size{"A4", 2480, 3508, 300}
|
||||
case .Letter:
|
||||
return Page_Size{"US Letter", 2550, 3300, 300}
|
||||
case .Manga:
|
||||
return Page_Size{"B5 Manga", 2158, 3035, 300}
|
||||
case .Webtoon:
|
||||
return Page_Size{"Webtoon", 800, 1280, 72}
|
||||
case .Square:
|
||||
return Page_Size{"Square", 1080, 1080, 72}
|
||||
}
|
||||
return Page_Size{"A4", 2480, 3508, 300}
|
||||
}
|
||||
|
||||
GRID_2X2_CELLS : [4]Layout_Cell = [4]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.47, h = 0.47},
|
||||
{x = 0.51, y = 0.02, w = 0.47, h = 0.47},
|
||||
{x = 0.02, y = 0.51, w = 0.47, h = 0.47},
|
||||
{x = 0.51, y = 0.51, w = 0.47, h = 0.47},
|
||||
}
|
||||
|
||||
MANGA_3_TIER_CELLS : [6]Layout_Cell = [6]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.3, h = 0.3},
|
||||
{x = 0.35, y = 0.02, w = 0.3, h = 0.3},
|
||||
{x = 0.68, y = 0.02, w = 0.3, h = 0.3},
|
||||
{x = 0.02, y = 0.35, w = 0.47, h = 0.3},
|
||||
{x = 0.51, y = 0.35, w = 0.47, h = 0.3},
|
||||
{x = 0.02, y = 0.68, w = 0.96, h = 0.3},
|
||||
}
|
||||
|
||||
ACTION_DYNAMIC_CELLS : [5]Layout_Cell = [5]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.6, h = 0.35},
|
||||
{x = 0.64, y = 0.02, w = 0.34, h = 0.35},
|
||||
{x = 0.02, y = 0.4, w = 0.34, h = 0.28},
|
||||
{x = 0.38, y = 0.4, w = 0.6, h = 0.28},
|
||||
{x = 0.02, y = 0.71, w = 0.96, h = 0.27},
|
||||
}
|
||||
|
||||
SPLASH_CELLS : [1]Layout_Cell = [1]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.96, h = 0.96},
|
||||
}
|
||||
|
||||
WEBTOON_CELLS : [3]Layout_Cell = [3]Layout_Cell{
|
||||
{x = 0.02, y = 0.01, w = 0.96, h = 0.32},
|
||||
{x = 0.02, y = 0.34, w = 0.96, h = 0.32},
|
||||
{x = 0.02, y = 0.67, w = 0.96, h = 0.32},
|
||||
}
|
||||
|
||||
WESTERN_3X3_CELLS : [9]Layout_Cell = [9]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.3, h = 0.29},
|
||||
{x = 0.35, y = 0.02, w = 0.3, h = 0.29},
|
||||
{x = 0.68, y = 0.02, w = 0.3, h = 0.29},
|
||||
{x = 0.02, y = 0.35, w = 0.3, h = 0.29},
|
||||
{x = 0.35, y = 0.35, w = 0.3, h = 0.29},
|
||||
{x = 0.68, y = 0.35, w = 0.3, h = 0.29},
|
||||
{x = 0.02, y = 0.68, w = 0.3, h = 0.29},
|
||||
{x = 0.35, y = 0.68, w = 0.3, h = 0.29},
|
||||
{x = 0.68, y = 0.68, w = 0.3, h = 0.29},
|
||||
}
|
||||
|
||||
DIALOGUE_HEAVY_CELLS : [8]Layout_Cell = [8]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.47, h = 0.22},
|
||||
{x = 0.51, y = 0.02, w = 0.47, h = 0.22},
|
||||
{x = 0.02, y = 0.26, w = 0.47, h = 0.22},
|
||||
{x = 0.51, y = 0.26, w = 0.47, h = 0.22},
|
||||
{x = 0.02, y = 0.5, w = 0.47, h = 0.22},
|
||||
{x = 0.51, y = 0.5, w = 0.47, h = 0.22},
|
||||
{x = 0.02, y = 0.74, w = 0.47, h = 0.22},
|
||||
{x = 0.51, y = 0.74, w = 0.47, h = 0.22},
|
||||
}
|
||||
|
||||
CINEMATIC_CELLS : [4]Layout_Cell = [4]Layout_Cell{
|
||||
{x = 0.02, y = 0.02, w = 0.96, h = 0.22},
|
||||
{x = 0.02, y = 0.26, w = 0.96, h = 0.22},
|
||||
{x = 0.02, y = 0.5, w = 0.96, h = 0.22},
|
||||
{x = 0.02, y = 0.74, w = 0.96, h = 0.22},
|
||||
}
|
||||
|
||||
pattern_max_panels :: proc(id: string) -> int {
|
||||
switch id {
|
||||
case "grid-2x2":
|
||||
return 4
|
||||
case "manga-3-tier":
|
||||
return 6
|
||||
case "action-dynamic":
|
||||
return 5
|
||||
case "splash-page":
|
||||
return 1
|
||||
case "webtoon-scroll":
|
||||
return 3
|
||||
case "western-3x3":
|
||||
return 9
|
||||
case "dialogue-heavy":
|
||||
return 8
|
||||
case "cinematic-widescreen":
|
||||
return 4
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
pattern_matches_genre :: proc(id: string, genre: string) -> bool {
|
||||
if len(genre) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch id {
|
||||
case "grid-2x2":
|
||||
return true
|
||||
case "manga-3-tier":
|
||||
return genre == "manga" || genre == "action"
|
||||
case "action-dynamic":
|
||||
return genre == "action" || genre == "superhero"
|
||||
case "splash-page":
|
||||
return true
|
||||
case "webtoon-scroll":
|
||||
return genre == "webtoon" || genre == "slice-of-life"
|
||||
case "western-3x3":
|
||||
return genre == "western-comic"
|
||||
case "dialogue-heavy":
|
||||
return genre == "drama" || genre == "slice-of-life" || genre == "romance"
|
||||
case "cinematic-widescreen":
|
||||
return genre == "action" || genre == "scifi" || genre == "noir"
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
get_layout_pattern_by_id :: proc(id: string) -> Layout_Pattern {
|
||||
switch id {
|
||||
case "grid-2x2":
|
||||
return Layout_Pattern{id = id, name = "Classic Grid", description = "2x2 equal panels", genres = nil, max_panels = 4, cells = GRID_2X2_CELLS[:]}
|
||||
case "manga-3-tier":
|
||||
return Layout_Pattern{id = id, name = "Manga 3-Tier", description = "Three horizontal tiers", genres = nil, max_panels = 6, cells = MANGA_3_TIER_CELLS[:]}
|
||||
case "action-dynamic":
|
||||
return Layout_Pattern{id = id, name = "Action Dynamic", description = "Varied action panels", genres = nil, max_panels = 5, cells = ACTION_DYNAMIC_CELLS[:]}
|
||||
case "splash-page":
|
||||
return Layout_Pattern{id = id, name = "Splash Page", description = "Single full-page panel", genres = nil, max_panels = 1, cells = SPLASH_CELLS[:]}
|
||||
case "webtoon-scroll":
|
||||
return Layout_Pattern{id = id, name = "Webtoon Scroll", description = "Vertical mobile panels", genres = nil, max_panels = 3, cells = WEBTOON_CELLS[:]}
|
||||
case "western-3x3":
|
||||
return Layout_Pattern{id = id, name = "Western 3x3", description = "Classic 3x3", genres = nil, max_panels = 9, cells = WESTERN_3X3_CELLS[:]}
|
||||
case "dialogue-heavy":
|
||||
return Layout_Pattern{id = id, name = "Dialogue Heavy", description = "Conversation-focused", genres = nil, max_panels = 8, cells = DIALOGUE_HEAVY_CELLS[:]}
|
||||
case "cinematic-widescreen":
|
||||
return Layout_Pattern{id = id, name = "Cinematic Widescreen", description = "Wide cinematic panels", genres = nil, max_panels = 4, cells = CINEMATIC_CELLS[:]}
|
||||
}
|
||||
|
||||
return Layout_Pattern{id = "grid-2x2", name = "Classic Grid", description = "2x2 equal panels", genres = nil, max_panels = 4, cells = GRID_2X2_CELLS[:]}
|
||||
}
|
||||
|
||||
select_best_pattern :: proc(panel_count: int, genre: string, preference: string) -> Layout_Pattern {
|
||||
pattern_ids := [8]string{
|
||||
"grid-2x2",
|
||||
"manga-3-tier",
|
||||
"action-dynamic",
|
||||
"splash-page",
|
||||
"webtoon-scroll",
|
||||
"western-3x3",
|
||||
"dialogue-heavy",
|
||||
"cinematic-widescreen",
|
||||
}
|
||||
|
||||
if len(preference) > 0 {
|
||||
if pattern_max_panels(preference) >= panel_count {
|
||||
return get_layout_pattern_by_id(preference)
|
||||
}
|
||||
}
|
||||
|
||||
best_genre_id := ""
|
||||
best_genre_max := 1 << 30
|
||||
for id in pattern_ids {
|
||||
max_panels := pattern_max_panels(id)
|
||||
if max_panels < panel_count {
|
||||
continue
|
||||
}
|
||||
if !pattern_matches_genre(id, genre) {
|
||||
continue
|
||||
}
|
||||
if max_panels < best_genre_max {
|
||||
best_genre_max = max_panels
|
||||
best_genre_id = id
|
||||
}
|
||||
}
|
||||
if len(best_genre_id) > 0 {
|
||||
return get_layout_pattern_by_id(best_genre_id)
|
||||
}
|
||||
|
||||
best_id := ""
|
||||
best_max := 1 << 30
|
||||
for id in pattern_ids {
|
||||
max_panels := pattern_max_panels(id)
|
||||
if max_panels < panel_count {
|
||||
continue
|
||||
}
|
||||
if max_panels < best_max {
|
||||
best_max = max_panels
|
||||
best_id = id
|
||||
}
|
||||
}
|
||||
if len(best_id) > 0 {
|
||||
return get_layout_pattern_by_id(best_id)
|
||||
}
|
||||
|
||||
return get_layout_pattern_by_id("grid-2x2")
|
||||
}
|
||||
|
||||
auto_layout_pages :: proc(panels: []Panel, page_size: Page_Size_Name, genre: string, pattern_preference: string) -> []Page_Layout {
|
||||
if len(panels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
size := get_page_size(page_size)
|
||||
pages: [dynamic]Page_Layout
|
||||
panel_index := 0
|
||||
page_number := 1
|
||||
|
||||
for panel_index < len(panels) {
|
||||
remaining := len(panels) - panel_index
|
||||
pattern := select_best_pattern(remaining, genre, pattern_preference)
|
||||
take := pattern.max_panels
|
||||
if take > remaining {
|
||||
take = remaining
|
||||
}
|
||||
|
||||
layout_panels: [dynamic]Page_Layout_Panel
|
||||
for i in 0..<take {
|
||||
p := panels[panel_index+i]
|
||||
cell_index := i
|
||||
if cell_index >= len(pattern.cells) {
|
||||
cell_index = len(pattern.cells) - 1
|
||||
}
|
||||
|
||||
append(&layout_panels, Page_Layout_Panel{
|
||||
panel_id = p.panel_id,
|
||||
panel_number = p.panel_number,
|
||||
layout_cell = pattern.cells[cell_index],
|
||||
})
|
||||
}
|
||||
|
||||
append(&pages, Page_Layout{
|
||||
page_number = page_number,
|
||||
pattern_id = pattern.id,
|
||||
panels = layout_panels[:],
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
})
|
||||
|
||||
panel_index += take
|
||||
page_number += 1
|
||||
}
|
||||
|
||||
return pages[:]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package core
|
||||
|
||||
import "core:fmt"
|
||||
|
||||
script_is_valid_minimal :: proc(script: Comic_Script) -> bool {
|
||||
if len(script.title) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(script.synopsis) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(script.pages) == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
normalize_script :: proc(script: Comic_Script) -> Comic_Script {
|
||||
normalized := script
|
||||
|
||||
if len(normalized.title) == 0 {
|
||||
normalized.title = fmt.aprintf("Untitled Comic")
|
||||
}
|
||||
if len(normalized.synopsis) == 0 {
|
||||
normalized.synopsis = fmt.aprintf("Generated comic synopsis")
|
||||
}
|
||||
|
||||
for idx in 0..<len(normalized.characters) {
|
||||
if len(normalized.characters[idx].id) == 0 {
|
||||
normalized.characters[idx].id = fmt.aprintf("char_%03d", idx+1)
|
||||
}
|
||||
if len(normalized.characters[idx].name) == 0 {
|
||||
normalized.characters[idx].name = fmt.aprintf("Character %d", idx+1)
|
||||
}
|
||||
if normalized.characters[idx].seed == 0 {
|
||||
normalized.characters[idx].seed = derive_seed_from_string(normalized.characters[idx].name)
|
||||
}
|
||||
}
|
||||
|
||||
for page_idx in 0..<len(normalized.pages) {
|
||||
if normalized.pages[page_idx].page_number <= 0 {
|
||||
normalized.pages[page_idx].page_number = page_idx + 1
|
||||
}
|
||||
|
||||
for panel_idx in 0..<len(normalized.pages[page_idx].panels) {
|
||||
if len(normalized.pages[page_idx].panels[panel_idx].panel_id) == 0 {
|
||||
normalized.pages[page_idx].panels[panel_idx].panel_id = fmt.aprintf("panel_%03d_%03d", page_idx+1, panel_idx+1)
|
||||
}
|
||||
if normalized.pages[page_idx].panels[panel_idx].panel_number <= 0 {
|
||||
normalized.pages[page_idx].panels[panel_idx].panel_number = panel_idx + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package core
|
||||
|
||||
new_initial_state :: proc() -> Comic_State {
|
||||
iso := ""
|
||||
|
||||
return Comic_State{
|
||||
project = Project_Metadata{
|
||||
project_id = "proj_todo",
|
||||
project_name = "Untitled Comic",
|
||||
created_at_iso = iso,
|
||||
last_modified_iso = iso,
|
||||
},
|
||||
user_mode = .Casual,
|
||||
story_idea = "",
|
||||
story_genre = "action",
|
||||
target_audience = "general",
|
||||
art_style = "manga",
|
||||
export_format = .PDF,
|
||||
page_size = .A4,
|
||||
color_profile = .RGB,
|
||||
workflow = Workflow_State{
|
||||
current_step = .Story_Input,
|
||||
completed_steps = nil,
|
||||
is_generating = false,
|
||||
generation_progress = 0,
|
||||
error_message = "",
|
||||
},
|
||||
panel_images = nil,
|
||||
panel_errors = nil,
|
||||
speech_bubbles = nil,
|
||||
}
|
||||
}
|
||||
|
||||
set_workflow_step :: proc(state: ^Comic_State, step: Workflow_Step) {
|
||||
state.workflow.current_step = step
|
||||
|
||||
for s in state.workflow.completed_steps {
|
||||
if s == step {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
old_steps := state.workflow.completed_steps
|
||||
steps: [dynamic]Workflow_Step
|
||||
for s in old_steps {
|
||||
append(&steps, s)
|
||||
}
|
||||
append(&steps, step)
|
||||
state.workflow.completed_steps = steps[:]
|
||||
delete(old_steps)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package core
|
||||
|
||||
User_Mode :: enum {
|
||||
Casual,
|
||||
Professional,
|
||||
}
|
||||
|
||||
Workflow_Step :: enum {
|
||||
Story_Input,
|
||||
Generating_Script,
|
||||
Script_Review,
|
||||
Character_Setup,
|
||||
Generating_Panels,
|
||||
Layout,
|
||||
Speech_Bubbles,
|
||||
Complete,
|
||||
}
|
||||
|
||||
Export_Format :: enum {
|
||||
PDF,
|
||||
CBZ,
|
||||
PNG,
|
||||
}
|
||||
|
||||
Page_Size_Name :: enum {
|
||||
A4,
|
||||
Letter,
|
||||
Manga,
|
||||
Webtoon,
|
||||
Square,
|
||||
}
|
||||
|
||||
Color_Profile :: enum {
|
||||
RGB,
|
||||
CMYK,
|
||||
}
|
||||
|
||||
Character_Role :: enum {
|
||||
Protagonist,
|
||||
Antagonist,
|
||||
Supporting,
|
||||
Extra,
|
||||
}
|
||||
|
||||
Shot_Type :: enum {
|
||||
Establishing,
|
||||
Wide,
|
||||
Medium,
|
||||
Close_Up,
|
||||
Extreme_Close_Up,
|
||||
Over_Shoulder,
|
||||
Aerial,
|
||||
}
|
||||
|
||||
Layout_Type :: enum {
|
||||
Grid,
|
||||
Manga,
|
||||
Western,
|
||||
Action,
|
||||
Dialogue,
|
||||
Splash,
|
||||
}
|
||||
|
||||
Bubble_Type :: enum {
|
||||
Normal,
|
||||
Thought,
|
||||
Shout,
|
||||
Whisper,
|
||||
Narration,
|
||||
Sound_Effect,
|
||||
}
|
||||
|
||||
Transition_Type :: enum {
|
||||
None,
|
||||
Fade,
|
||||
Wipe,
|
||||
Dissolve,
|
||||
Action_Lines,
|
||||
}
|
||||
|
||||
Position :: struct {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
Size :: struct {
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
Character_Prompt_Template :: struct {
|
||||
age: string,
|
||||
gender: string,
|
||||
hair_color: string,
|
||||
hair_style: string,
|
||||
skin_tone: string,
|
||||
eye_color: string,
|
||||
body_type: string,
|
||||
outfit: string,
|
||||
accessories: string,
|
||||
distinguishing_features: string,
|
||||
}
|
||||
|
||||
Color_Palette :: struct {
|
||||
hair: string,
|
||||
eyes: string,
|
||||
skin: string,
|
||||
outfit: string,
|
||||
}
|
||||
|
||||
Character :: struct {
|
||||
id: string,
|
||||
name: string,
|
||||
role: Character_Role,
|
||||
description: string,
|
||||
prompt_template: Character_Prompt_Template,
|
||||
reference_image_url: string,
|
||||
character_sheet_urls: []string,
|
||||
seed: i64,
|
||||
color_palette: Color_Palette,
|
||||
appearance_count: int,
|
||||
first_appearance_panel: string,
|
||||
}
|
||||
|
||||
Dialogue :: struct {
|
||||
speaker_id: string,
|
||||
text: string,
|
||||
bubble_type: Bubble_Type,
|
||||
emotion: string,
|
||||
}
|
||||
|
||||
Panel :: struct {
|
||||
panel_id: string,
|
||||
panel_number: int,
|
||||
shot_type: Shot_Type,
|
||||
description: string,
|
||||
characters_present: []string,
|
||||
dialogue: []Dialogue,
|
||||
caption: string,
|
||||
sound_effects: []string,
|
||||
transition_from_previous: Transition_Type,
|
||||
}
|
||||
|
||||
Page :: struct {
|
||||
page_number: int,
|
||||
layout_type: Layout_Type,
|
||||
panels: []Panel,
|
||||
}
|
||||
|
||||
Comic_Script :: struct {
|
||||
title: string,
|
||||
synopsis: string,
|
||||
characters: []Character,
|
||||
pages: []Page,
|
||||
}
|
||||
|
||||
Panel_Image :: struct {
|
||||
url: string,
|
||||
width: int,
|
||||
height: int,
|
||||
seed: i64,
|
||||
prompt: string,
|
||||
}
|
||||
|
||||
Layout_Cell :: struct {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
}
|
||||
|
||||
Layout_Pattern :: struct {
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
genres: []string,
|
||||
max_panels: int,
|
||||
cells: []Layout_Cell,
|
||||
}
|
||||
|
||||
Page_Layout_Panel :: struct {
|
||||
panel_id: string,
|
||||
panel_number: int,
|
||||
layout_cell: Layout_Cell,
|
||||
}
|
||||
|
||||
Page_Layout :: struct {
|
||||
page_number: int,
|
||||
pattern_id: string,
|
||||
panels: []Page_Layout_Panel,
|
||||
width: int,
|
||||
height: int,
|
||||
}
|
||||
|
||||
Speech_Bubble_Style :: struct {
|
||||
background_color: string,
|
||||
border_color: string,
|
||||
border_width: f32,
|
||||
border_radius: f32,
|
||||
font_family: string,
|
||||
font_size: f32,
|
||||
text_color: string,
|
||||
padding: f32,
|
||||
}
|
||||
|
||||
Speech_Bubble :: struct {
|
||||
id: string,
|
||||
panel_id: string,
|
||||
type: Bubble_Type,
|
||||
text: string,
|
||||
position: Position,
|
||||
size: Size,
|
||||
tail_direction: string,
|
||||
tail_target: Position,
|
||||
style: Speech_Bubble_Style,
|
||||
speaker_id: string,
|
||||
}
|
||||
|
||||
Project_Metadata :: struct {
|
||||
project_id: string,
|
||||
project_name: string,
|
||||
created_at_iso: string,
|
||||
last_modified_iso: string,
|
||||
}
|
||||
|
||||
Workflow_State :: struct {
|
||||
current_step: Workflow_Step,
|
||||
completed_steps: []Workflow_Step,
|
||||
is_generating: bool,
|
||||
generation_progress: f32,
|
||||
error_message: string,
|
||||
}
|
||||
|
||||
Comic_State :: struct {
|
||||
project: Project_Metadata,
|
||||
user_mode: User_Mode,
|
||||
story_idea: string,
|
||||
story_genre: string,
|
||||
target_audience: string,
|
||||
art_style: string,
|
||||
script: Comic_Script,
|
||||
characters: []Character,
|
||||
panel_images: map[string]Panel_Image,
|
||||
panel_errors: map[string]string,
|
||||
page_layouts: []Page_Layout,
|
||||
speech_bubbles: map[string][]Speech_Bubble,
|
||||
export_format: Export_Format,
|
||||
page_size: Page_Size_Name,
|
||||
color_profile: Color_Profile,
|
||||
workflow: Workflow_State,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package core
|
||||
|
||||
can_transition :: proc(from, to: Workflow_Step) -> bool {
|
||||
switch from {
|
||||
case .Story_Input:
|
||||
return to == .Generating_Script
|
||||
case .Generating_Script:
|
||||
return to == .Script_Review || to == .Story_Input
|
||||
case .Script_Review:
|
||||
return to == .Character_Setup
|
||||
case .Character_Setup:
|
||||
return to == .Generating_Panels
|
||||
case .Generating_Panels:
|
||||
return to == .Layout
|
||||
case .Layout:
|
||||
return to == .Speech_Bubbles
|
||||
case .Speech_Bubbles:
|
||||
return to == .Complete
|
||||
case .Complete:
|
||||
return to == .Story_Input
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
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_local_script :: proc(controller: ^ui.App_Controller, pages: int) -> string {
|
||||
story := controller.state.story_idea
|
||||
if len(story) == 0 {
|
||||
story = "A local GUI adventure"
|
||||
}
|
||||
script := build_local_script(story, pages)
|
||||
core.dispose_script(&controller.state.script)
|
||||
controller.state.script = script
|
||||
controller.state.characters = controller.state.script.characters
|
||||
controller.active_screen = .Script
|
||||
controller.state.workflow.current_step = .Script_Review
|
||||
return "Generated local script"
|
||||
}
|
||||
|
||||
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
|
||||
controller.state.characters = controller.state.script.characters
|
||||
controller.active_screen = .Script
|
||||
controller.state.workflow.current_step = .Script_Review
|
||||
return "Generated DeepSeek script"
|
||||
}
|
||||
|
||||
action_generate_local_panels :: proc(controller: ^ui.App_Controller) -> string {
|
||||
panels := collect_script_panels(controller.state.script)
|
||||
defer delete(panels)
|
||||
if len(panels) == 0 {
|
||||
return "No script panels available"
|
||||
}
|
||||
images, ierr := build_local_panel_images(panels)
|
||||
if !shared.is_ok(ierr) {
|
||||
return ierr.message
|
||||
}
|
||||
for _, img in controller.state.panel_images {
|
||||
delete(img.url)
|
||||
delete(img.prompt)
|
||||
}
|
||||
delete(controller.state.panel_images)
|
||||
controller.state.panel_images = images
|
||||
controller.active_screen = .Panels
|
||||
return "Generated local panels"
|
||||
}
|
||||
|
||||
action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string {
|
||||
panels := collect_script_panels(controller.state.script)
|
||||
defer delete(panels)
|
||||
|
||||
target_panel: core.Panel
|
||||
found := false
|
||||
for p in panels {
|
||||
if p.panel_id == panel_id {
|
||||
target_panel = p
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "Panel not found in script"
|
||||
}
|
||||
|
||||
single := make([]core.Panel, 1)
|
||||
single[0] = target_panel
|
||||
defer delete(single)
|
||||
|
||||
images, ierr := build_local_panel_images(single)
|
||||
if !shared.is_ok(ierr) {
|
||||
if controller.state.panel_errors == nil {
|
||||
controller.state.panel_errors = make(map[string]string)
|
||||
}
|
||||
controller.state.panel_errors[panel_id] = strings.clone(ierr.message)
|
||||
return "Panel generation failed"
|
||||
}
|
||||
|
||||
if img, has := images[panel_id]; has {
|
||||
if old, exists := controller.state.panel_images[panel_id]; exists {
|
||||
delete(old.url)
|
||||
delete(old.prompt)
|
||||
}
|
||||
if controller.state.panel_images == nil {
|
||||
controller.state.panel_images = make(map[string]core.Panel_Image)
|
||||
}
|
||||
controller.state.panel_images[panel_id] = img
|
||||
|
||||
if err_msg, err_exists := controller.state.panel_errors[panel_id]; err_exists {
|
||||
delete(err_msg)
|
||||
delete_key(&controller.state.panel_errors, panel_id)
|
||||
}
|
||||
}
|
||||
delete(images) // free the map shell returned by build_local_panel_images
|
||||
|
||||
return "Regenerated panel"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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 {
|
||||
opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90}
|
||||
err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts)
|
||||
if !shared.is_ok(err) {
|
||||
return err.message
|
||||
}
|
||||
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_with_source :: proc(controller: ui.App_Controller, use_deepseek_script: bool) -> string {
|
||||
if len(controller.state.script.pages) == 0 {
|
||||
if use_deepseek_script {
|
||||
return "generate script"
|
||||
}
|
||||
return "generate script local"
|
||||
}
|
||||
if len(controller.state.panel_images) == 0 {
|
||||
return "generate panels local"
|
||||
}
|
||||
if len(controller.state.page_layouts) == 0 {
|
||||
return "layout auto"
|
||||
}
|
||||
return "export pdf"
|
||||
}
|
||||
|
||||
gui_next_hint :: proc(controller: ui.App_Controller) -> string {
|
||||
return gui_next_hint_with_source(controller, false)
|
||||
}
|
||||
|
||||
action_run_next :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int, use_deepseek_script: bool) -> string {
|
||||
hint := gui_next_hint_with_source(controller^, use_deepseek_script)
|
||||
switch hint {
|
||||
case "generate script":
|
||||
return action_generate_deepseek_script(controller, script_pages)
|
||||
case "generate script local":
|
||||
return action_generate_local_script(controller, script_pages)
|
||||
case "generate panels local":
|
||||
return action_generate_local_panels(controller)
|
||||
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_local :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int, use_deepseek_script: bool) -> string {
|
||||
for _ in 0..<4 {
|
||||
msg := action_run_next(controller, export_path, export_format, script_pages, use_deepseek_script)
|
||||
if controller.active_screen == .Export {
|
||||
return fmt.aprintf("Auto-all complete: %s", msg)
|
||||
}
|
||||
}
|
||||
return "Auto-all could not complete"
|
||||
}
|
||||
|
||||
run_script_action :: proc(controller: ^ui.App_Controller, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool) -> string {
|
||||
is_dirty^ = true
|
||||
if use_deepseek_script {
|
||||
return action_generate_deepseek_script(controller, pages_count)
|
||||
}
|
||||
return action_generate_local_script(controller, pages_count)
|
||||
}
|
||||
|
||||
run_panels_action :: proc(controller: ^ui.App_Controller, can_generate_panels: bool, is_dirty: ^bool) -> string {
|
||||
if !can_generate_panels {
|
||||
return "Generate script before panels"
|
||||
}
|
||||
is_dirty^ = true
|
||||
return action_generate_local_panels(controller)
|
||||
}
|
||||
|
||||
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, use_deepseek_script: bool, 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, use_deepseek_script)
|
||||
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, use_deepseek_script: bool, is_dirty: ^bool, last_export_at: ^f64) -> string {
|
||||
normalize_export_path_field(export_path, export_format)
|
||||
msg := action_run_auto_all_local(controller, export_path^, export_format, pages_count, use_deepseek_script)
|
||||
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, use_deepseek_script: bool, 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_local(controller, export_path^, export_format, pages_count, use_deepseek_script)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
|
||||
button_clicked :: proc(rec: rl.Rectangle) -> bool {
|
||||
if !rl.IsMouseButtonPressed(.LEFT) {
|
||||
return false
|
||||
}
|
||||
return rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
}
|
||||
|
||||
draw_button :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := BTN_BG
|
||||
border := BTN_BORDER
|
||||
text := BTN_TEXT
|
||||
if hover {
|
||||
bg = BTN_BG_HOVER
|
||||
border = BTN_BORDER_HOVER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
|
||||
}
|
||||
|
||||
draw_button_primary :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := ACCENT
|
||||
border := ACCENT_MUTED
|
||||
if hover {
|
||||
bg = ACCENT_HOVER
|
||||
border = ACCENT
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT)
|
||||
}
|
||||
|
||||
draw_button_danger :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := DANGER_BG
|
||||
border := DANGER_BORDER
|
||||
if hover {
|
||||
bg = DANGER_BG_HOVER
|
||||
border = DANGER_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT)
|
||||
}
|
||||
|
||||
draw_button_warning :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := WARN_BTN_BG
|
||||
border := WARN_BTN_BORDER
|
||||
text := WARN_BTN_TEXT
|
||||
if hover {
|
||||
bg = WARN_BTN_BG_HOVER
|
||||
border = WARN_BTN_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
|
||||
}
|
||||
|
||||
draw_button_soft_accent :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := BTN_SOFT_BG
|
||||
border := BTN_SOFT_BORDER
|
||||
text := BTN_SOFT_TEXT
|
||||
if hover {
|
||||
bg = BTN_SOFT_BG_HOVER
|
||||
border = BTN_SOFT_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
|
||||
}
|
||||
|
||||
draw_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) {
|
||||
if !enabled {
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, BTN_DISABLED_BG)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, BTN_DISABLED_BORDER)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, BTN_DISABLED_TEXT)
|
||||
return
|
||||
}
|
||||
draw_button(rec, label)
|
||||
}
|
||||
|
||||
draw_small_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) {
|
||||
if !enabled {
|
||||
rl.DrawRectangleRounded(rec, 0.20, 6, BTN_DISABLED_BG)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, BTN_DISABLED_BORDER)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, BTN_DISABLED_TEXT)
|
||||
return
|
||||
}
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := SBTN_BG
|
||||
border := SBTN_BORDER
|
||||
if hover {
|
||||
bg = SBTN_BG_HOVER
|
||||
border = SBTN_BORDER_HOVER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, 0.20, 6, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, border)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, SBTN_TEXT)
|
||||
}
|
||||
|
||||
draw_small_button :: proc(rec: rl.Rectangle, label: string) {
|
||||
draw_small_button_state(rec, label, true)
|
||||
}
|
||||
|
||||
button_readiness_hint :: proc(mouse: rl.Vector2, panels_btn, layout_btn, export_btn: rl.Rectangle, can_generate_panels, can_layout, can_export: bool) -> string {
|
||||
if rl.CheckCollisionPointRec(mouse, panels_btn) && !can_generate_panels {
|
||||
return "Panels requires a generated script"
|
||||
}
|
||||
if rl.CheckCollisionPointRec(mouse, layout_btn) && !can_layout {
|
||||
return "Layout requires generated panels"
|
||||
}
|
||||
if rl.CheckCollisionPointRec(mouse, export_btn) && !can_export {
|
||||
return "Export requires panels + layout"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
draw_button_recommended :: proc(rec: rl.Rectangle, label: string) {
|
||||
halo := rl.Rectangle{x = rec.x-2, y = rec.y-2, width = rec.width+4, height = rec.height+4}
|
||||
rl.DrawRectangleRounded(halo, RADIUS_BUTTON, 8, RECOMMEND_HALO_FILL)
|
||||
rl.DrawRectangleRoundedLinesEx(halo, RADIUS_BUTTON, 8, 1.4, RECOMMEND_HALO_BORDER)
|
||||
draw_button(rec, label)
|
||||
}
|
||||
|
||||
draw_nav_item :: proc(rec: rl.Rectangle, label: string, active: bool) {
|
||||
bg := NAV_BG
|
||||
border := NAV_BORDER
|
||||
text := NAV_TEXT
|
||||
if active {
|
||||
bg = NAV_ACTIVE_BG
|
||||
border = NAV_ACTIVE_BG
|
||||
text = NAV_ACTIVE_TEXT
|
||||
} else if rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) {
|
||||
bg = NAV_BG_HOVER
|
||||
border = NAV_BORDER_HOVER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_NAV, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_NAV, 8, 1.0, border)
|
||||
if active {
|
||||
rl.DrawRectangleRounded(rl.Rectangle{x = rec.x+2, y = rec.y+4, width = 4, height = rec.height-8}, 0.5, 8, NAV_ACTIVE_BAR)
|
||||
}
|
||||
label_x := i32(rec.x) + 8
|
||||
label_w := int(rec.width) - 16
|
||||
if active {
|
||||
label_x = i32(rec.x) + 14
|
||||
label_w = int(rec.width) - 22
|
||||
}
|
||||
draw_text_fitted(label, label_x, i32(rec.y)+6, 18, label_w, 8, text)
|
||||
}
|
||||
|
||||
draw_input_field :: proc(rec: rl.Rectangle, value: string, selected: bool) {
|
||||
bg := INPUT_BG
|
||||
border := INPUT_BORDER
|
||||
if selected {
|
||||
halo := rl.Rectangle{x = rec.x - 2, y = rec.y - 2, width = rec.width + 4, height = rec.height + 4}
|
||||
rl.DrawRectangleRounded(halo, RADIUS_INPUT, 8, INPUT_FOCUS_RING)
|
||||
rl.DrawRectangleRoundedLinesEx(halo, RADIUS_INPUT, 8, 1.0, INPUT_FOCUS_BORDER)
|
||||
bg = INPUT_FOCUS_BG
|
||||
border = INPUT_FOCUS_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_INPUT, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_INPUT, 8, 1.2, border)
|
||||
if !selected {
|
||||
draw_text_fitted(value, i32(rec.x)+8, i32(rec.y)+6, 18, int(rec.width)-16, 8, INPUT_TEXT)
|
||||
return
|
||||
}
|
||||
rl.DrawText(fmt.ctprintf("%s", value), i32(rec.x)+8, i32(rec.y)+6, 18, INPUT_TEXT_FOCUS)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:path/filepath"
|
||||
import rl "vendor:raylib"
|
||||
import "../ui"
|
||||
|
||||
build_diagnostics_snapshot :: proc(controller: ui.App_Controller, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string {
|
||||
log_order := "newest"
|
||||
if log_oldest_first {
|
||||
log_order = "oldest"
|
||||
}
|
||||
return fmt.aprintf("screen=%s workflow=%v next=%s dirty=%s autosave=%s(%ds) content=pages:%d,panels:%d,layouts:%d,chars:%d paths=P:%s,E:%s project=%s export=%s log=%d,%s uptime=%.1fs", ui.screen_name(controller.active_screen), controller.state.workflow.current_step, gui_next_hint(controller), yn(is_dirty), yn(autosave_enabled), autosave_secs, len(controller.state.script.pages), len(controller.state.panel_images), len(controller.state.page_layouts), len(controller.state.characters), yn(project_ok), yn(export_ok), project_path, export_path, log_show_lines, log_order, rl.GetTime())
|
||||
}
|
||||
|
||||
build_action_log_snapshot :: proc(log: Action_Log) -> string {
|
||||
if log.count == 0 {
|
||||
return fmt.aprintf("(action log empty)")
|
||||
}
|
||||
now := rl.GetTime()
|
||||
max_lines := len(log.entries)
|
||||
if log.count < max_lines {
|
||||
max_lines = log.count
|
||||
}
|
||||
out := ""
|
||||
for line in 0..<max_lines {
|
||||
idx := (log.count - 1 - line) % len(log.entries)
|
||||
if idx < 0 {
|
||||
idx += len(log.entries)
|
||||
}
|
||||
entry := fmt.aprintf("[%2.0fs] %s", now-log.entry_times[idx], log.entries[idx])
|
||||
if line == 0 {
|
||||
out = entry
|
||||
} else {
|
||||
prev := out
|
||||
out = fmt.aprintf("%s\n%s", prev, entry)
|
||||
delete(prev)
|
||||
delete(entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
build_session_report :: proc(controller: ui.App_Controller, log: Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string {
|
||||
diag := build_diagnostics_snapshot(controller, is_dirty, autosave_enabled, project_ok, export_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
log_text := build_action_log_snapshot(log)
|
||||
report := fmt.aprintf("# comic-odin gui session report\n\n[meta]\ngenerated_uptime=%.1fs\n\n[diagnostics]\n%s\n\n[action_log]\n%s\n", rl.GetTime(), diag, log_text)
|
||||
delete(diag)
|
||||
delete(log_text)
|
||||
return report
|
||||
}
|
||||
|
||||
Diagnostics_Action_Context :: struct {
|
||||
controller: ^ui.App_Controller,
|
||||
action_log: ^Action_Log,
|
||||
is_dirty: bool,
|
||||
autosave_enabled: bool,
|
||||
project_ok: bool,
|
||||
export_ok: bool,
|
||||
autosave_secs: int,
|
||||
project_path: string,
|
||||
export_path: string,
|
||||
log_show_lines: i32,
|
||||
log_oldest_first: bool,
|
||||
}
|
||||
|
||||
make_diagnostics_action_context :: proc(controller: ^ui.App_Controller, action_log: ^Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> Diagnostics_Action_Context {
|
||||
return Diagnostics_Action_Context{
|
||||
controller = controller,
|
||||
action_log = action_log,
|
||||
is_dirty = is_dirty,
|
||||
autosave_enabled = autosave_enabled,
|
||||
project_ok = project_ok,
|
||||
export_ok = export_ok,
|
||||
autosave_secs = autosave_secs,
|
||||
project_path = project_path,
|
||||
export_path = export_path,
|
||||
log_show_lines = log_show_lines,
|
||||
log_oldest_first = log_oldest_first,
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics_path_for_project :: proc(project_path: string) -> string {
|
||||
dir, _ := filepath.split(project_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "./"
|
||||
}
|
||||
parts := []string{dir, "gui_diagnostics.txt"}
|
||||
joined, err := filepath.join(parts)
|
||||
if err != nil {
|
||||
return "./gui_diagnostics.txt"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
session_report_path_for_project :: proc(project_path: string) -> string {
|
||||
dir, _ := filepath.split(project_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "./"
|
||||
}
|
||||
parts := []string{dir, "gui_session_report.txt"}
|
||||
joined, err := filepath.join(parts)
|
||||
if err != nil {
|
||||
return "./gui_session_report.txt"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
write_diagnostics_file :: proc(project_path, diag: string) -> string {
|
||||
diag_path := diagnostics_path_for_project(project_path)
|
||||
defer delete(diag_path)
|
||||
if err := os.write_entire_file(diag_path, diag); err == nil {
|
||||
return fmt.aprintf("Wrote diagnostics file: %s", diag_path)
|
||||
}
|
||||
return fmt.aprintf("Failed writing diagnostics file")
|
||||
}
|
||||
|
||||
write_session_report_file :: proc(project_path, report: string) -> string {
|
||||
report_path := session_report_path_for_project(project_path)
|
||||
defer delete(report_path)
|
||||
if err := os.write_entire_file(report_path, report); err == nil {
|
||||
return fmt.aprintf("Wrote session report: %s", report_path)
|
||||
}
|
||||
return fmt.aprintf("Failed writing session report")
|
||||
}
|
||||
|
||||
write_session_report_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
report := build_session_report(ctx.controller^, ctx.action_log^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
|
||||
msg := write_session_report_file(ctx.project_path, report)
|
||||
delete(report)
|
||||
return msg
|
||||
}
|
||||
|
||||
write_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
|
||||
msg := write_diagnostics_file(ctx.project_path, diag)
|
||||
delete(diag)
|
||||
return msg
|
||||
}
|
||||
|
||||
copy_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
|
||||
msg := copy_text_with_status(diag, "Copied diagnostics snapshot")
|
||||
delete(diag)
|
||||
return msg
|
||||
}
|
||||
|
||||
copy_action_log_snapshot_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
log_text := build_action_log_snapshot(ctx.action_log^)
|
||||
msg := copy_text_with_status(log_text, "Copied action log snapshot")
|
||||
delete(log_text)
|
||||
return msg
|
||||
}
|
||||
|
||||
copy_text_with_status :: proc(text, status: string) -> string {
|
||||
rl.SetClipboardText(fmt.ctprintf("%s", text))
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
collect_script_panels :: proc(script: core.Comic_Script) -> []core.Panel {
|
||||
out: [dynamic]core.Panel
|
||||
for p in script.pages {
|
||||
for pan in p.panels {
|
||||
append(&out, pan)
|
||||
}
|
||||
}
|
||||
return out[:]
|
||||
}
|
||||
|
||||
count_script_panels :: proc(script: core.Comic_Script) -> int {
|
||||
count := 0
|
||||
for p in script.pages {
|
||||
count += len(p.panels)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
local_panel_id_by_index :: proc(i: int) -> string {
|
||||
switch i {
|
||||
case 0: return "panel_local_001"
|
||||
case 1: return "panel_local_002"
|
||||
case 2: return "panel_local_003"
|
||||
case 3: return "panel_local_004"
|
||||
case 4: return "panel_local_005"
|
||||
case 5: return "panel_local_006"
|
||||
}
|
||||
return "panel_local_overflow"
|
||||
}
|
||||
|
||||
build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script {
|
||||
out_pages: [dynamic]core.Page
|
||||
for i in 0..<pages {
|
||||
chars_present := make([]string, 1)
|
||||
chars_present[0] = "char_001"
|
||||
dialogue := make([]core.Dialogue, 1)
|
||||
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = "neutral"}
|
||||
|
||||
panel := core.Panel{
|
||||
panel_id = local_panel_id_by_index(i),
|
||||
panel_number = 1,
|
||||
shot_type = .Medium,
|
||||
description = story_idea,
|
||||
characters_present = chars_present,
|
||||
dialogue = dialogue,
|
||||
caption = "",
|
||||
sound_effects = nil,
|
||||
transition_from_previous = .None,
|
||||
}
|
||||
panels := make([]core.Panel, 1)
|
||||
panels[0] = panel
|
||||
append(&out_pages, core.Page{page_number = i + 1, layout_type = .Grid, panels = panels})
|
||||
}
|
||||
|
||||
chars := make([]core.Character, 1)
|
||||
chars[0] = core.Character{id = "char_001", name = "Protagonist", role = .Protagonist, description = "Main character"}
|
||||
return core.Comic_Script{title = "Local Script", synopsis = story_idea, characters = chars, pages = out_pages[:]}
|
||||
}
|
||||
|
||||
build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel_Image, shared.App_Error) {
|
||||
tmp_dir, terr := os.make_directory_temp("", "comic-gui-local-panels-*", context.temp_allocator)
|
||||
if terr != nil {
|
||||
return nil, shared.new_error(.Generation, "failed to create local panel temp dir", true)
|
||||
}
|
||||
|
||||
images := make(map[string]core.Panel_Image)
|
||||
for p, idx in panels {
|
||||
name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id)
|
||||
out_path := fmt.aprintf("%s/%s", tmp_dir, name)
|
||||
delete(name)
|
||||
if werr := os.write_entire_file(out_path, "LOCAL PANEL IMAGE"); werr != nil {
|
||||
delete(out_path)
|
||||
return nil, shared.new_error(.Generation, "failed writing local panel image", true)
|
||||
}
|
||||
url := fmt.aprintf("file://%s", out_path)
|
||||
prompt := fmt.aprintf("local")
|
||||
images[p.panel_id] = core.Panel_Image{url = url, width = 1024, height = 1024, seed = i64(idx + 1), prompt = prompt}
|
||||
delete(out_path)
|
||||
}
|
||||
return images, shared.ok()
|
||||
}
|
||||
|
||||
append_char :: proc(dst: ^string, ch: rune) {
|
||||
dst^ = fmt.aprintf("%s%c", dst^, ch)
|
||||
}
|
||||
|
||||
pop_char :: proc(dst: ^string) {
|
||||
if len(dst^) == 0 {
|
||||
return
|
||||
}
|
||||
dst^ = dst^[:len(dst^)-1]
|
||||
}
|
||||
|
||||
recommended_label_from_hint :: proc(hint: string) -> string {
|
||||
switch hint {
|
||||
case "generate script":
|
||||
return "Generate Script"
|
||||
case "generate script local":
|
||||
return "Generate Script Local"
|
||||
case "generate panels local":
|
||||
return "Generate Panels Local"
|
||||
case "layout auto":
|
||||
return "Layout"
|
||||
case "export pdf":
|
||||
return "Export"
|
||||
}
|
||||
return "Next"
|
||||
}
|
||||
|
||||
pending_action_name :: proc(a: Pending_Confirm_Action) -> string {
|
||||
switch a {
|
||||
case .Reset_Project: return "reset project"
|
||||
case .Open_Project: return "open project"
|
||||
case .None: return "continue"
|
||||
}
|
||||
return "continue"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import rl "vendor:raylib"
|
||||
|
||||
draw_action_log :: proc(log: Action_Log, x, y, max_visible: i32, oldest_first: bool) {
|
||||
now := rl.GetTime()
|
||||
max_lines := len(log.entries)
|
||||
if log.count < max_lines {
|
||||
max_lines = log.count
|
||||
}
|
||||
if max_visible > 0 && int(max_visible) < max_lines {
|
||||
max_lines = int(max_visible)
|
||||
}
|
||||
for line in 0..<max_lines {
|
||||
idx := 0
|
||||
if oldest_first {
|
||||
start := log.count - max_lines
|
||||
idx = (start + line) % len(log.entries)
|
||||
} else {
|
||||
idx = (log.count - 1 - line) % len(log.entries)
|
||||
}
|
||||
if idx < 0 {
|
||||
idx += len(log.entries)
|
||||
}
|
||||
row_y := y + i32(line*22)
|
||||
if line % 2 == 0 {
|
||||
rl.DrawRectangleRounded(rl.Rectangle{x = f32(x-4), y = f32(row_y-2), width = 442, height = 20}, 0.2, 6, LOG_ROW_ALT)
|
||||
}
|
||||
age := now - log.entry_times[idx]
|
||||
line_text := fmt.tprintf("[%2.0fs] %s", age, log.entries[idx])
|
||||
draw_text_fitted(line_text, x, row_y, 15, 430, 7, LOG_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
is_error_message :: proc(msg: string) -> bool {
|
||||
return strings.contains(msg, "failed") || strings.contains(msg, "blocked") || strings.contains(msg, "No script")
|
||||
}
|
||||
|
||||
is_warning_message :: proc(msg: string) -> bool {
|
||||
return strings.contains(msg, "Unsaved") || strings.contains(msg, "Confirm") || strings.contains(msg, "requires") || strings.contains(msg, "before") || strings.contains(msg, "Cancelled")
|
||||
}
|
||||
|
||||
status_text_color :: proc(msg: string) -> rl.Color {
|
||||
if is_error_message(msg) {
|
||||
return ERROR
|
||||
}
|
||||
if is_warning_message(msg) {
|
||||
return WARNING
|
||||
}
|
||||
return SUCCESS
|
||||
}
|
||||
|
||||
toast_bg_color :: proc(msg: string) -> rl.Color {
|
||||
if is_error_message(msg) {
|
||||
return TOAST_ERROR
|
||||
}
|
||||
if is_warning_message(msg) {
|
||||
return TOAST_WARNING
|
||||
}
|
||||
return TOAST_SUCCESS
|
||||
}
|
||||
|
||||
draw_toast :: proc(log: Action_Log, x, y, w: i32) {
|
||||
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 := toast_bg_color(msg)
|
||||
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 34}
|
||||
shadow := rl.Rectangle{x = f32(x), y = f32(y + 2), width = f32(w), height = 34}
|
||||
rl.DrawRectangleRounded(shadow, RADIUS_TOAST, 8, TOAST_SHADOW)
|
||||
rl.DrawRectangleRounded(rec, RADIUS_TOAST, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_TOAST, 8, 1.0, TOAST_BORDER)
|
||||
draw_text_fitted(msg, x+10, y+8, 16, int(w-20), 8, TEXT_BRIGHT)
|
||||
}
|
||||
|
||||
draw_help_line :: proc(x, y: i32, text: string) {
|
||||
draw_text_fitted(text, x, y, 16, 820, 8, HELP_LINE)
|
||||
}
|
||||
|
||||
draw_help_overlay :: proc() {
|
||||
sw := rl.GetScreenWidth()
|
||||
sh := rl.GetScreenHeight()
|
||||
rec := rl.Rectangle{x = f32((sw-860)/2), y = f32((sh-642)/2), width = 860, height = 642}
|
||||
rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY)
|
||||
draw_card(rec)
|
||||
x := i32(rec.x) + 30
|
||||
y := i32(rec.y) + 28
|
||||
rl.DrawText("Keyboard Shortcuts", x, y, 28, HELP_TITLE)
|
||||
rl.DrawText("Navigation", x, y+44, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+72, "1..8 screens | TAB fields | click to focus | F11 pages | F12 project")
|
||||
rl.DrawText("Core Actions", x, y+106, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+134, "F5 script F6 panels F7 layout F8 export F9 next F10 auto-all")
|
||||
draw_help_line(x, y+160, "Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary")
|
||||
rl.DrawText("Clipboard + Logs", x, y+196, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+224, "Ctrl+L clear log | Ctrl+Shift+L copy log | Ctrl+Shift+T/B log view | Ctrl+Shift+Z reset")
|
||||
draw_help_line(x, y+248, "Ctrl+Shift+C status")
|
||||
draw_help_line(x, y+272, "Ctrl+Shift+Y diag copy | Ctrl+Shift+R diag file | Ctrl+Shift+W report")
|
||||
draw_help_line(x, y+296, "Ctrl+0 reset helpers | Ctrl+V paste | Ctrl+Shift+I copy | Ctrl+Backspace clear")
|
||||
rl.DrawText("Paths", x, y+332, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+360, "Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export")
|
||||
draw_help_line(x, y+386, "Ctrl+Shift+J fix project | F fix export | K/M quick-fix P/E | U fix all")
|
||||
rl.DrawText("Autosave", x, y+422, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+450, "Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60")
|
||||
rl.DrawText("Safety", x, y+486, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+514, "Dirty guard: Shift-click New/Open | Keyboard confirm: Ctrl+Shift+N / Ctrl+Shift+O")
|
||||
rl.DrawText("Close help: Esc or /", x, y+542, 18, HELP_CLOSE)
|
||||
}
|
||||
|
||||
draw_sidebar_shortcut_line :: proc(x, y: i32, text: string, c: rl.Color) {
|
||||
draw_text_fitted(text, x, y, 14, 220, 7, c)
|
||||
}
|
||||
|
||||
draw_sidebar_shortcuts :: proc(screen_h: i32) {
|
||||
base_y := screen_h - 280
|
||||
if base_y < 120 {
|
||||
base_y = 120
|
||||
}
|
||||
draw_card(rl.Rectangle{x = 14, y = f32(base_y), width = 236, height = 210})
|
||||
rl.DrawText("Quick Keys", 26, base_y+12, 18, SIDEBAR_TITLE)
|
||||
draw_sidebar_shortcut_line(26, base_y+36, "F5/F6/F7/F8 generate/layout/export", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+54, "Ctrl+S save Ctrl+O open", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+72, "Ctrl+N new", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+90, "F9 next F10 auto-all", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+108, "/ full shortcut help", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+140, "Press / for all shortcuts", SIDEBAR_FOOTER)
|
||||
}
|
||||
|
||||
draw_confirm_overlay :: proc(action: Pending_Confirm_Action) {
|
||||
sw := rl.GetScreenWidth()
|
||||
sh := rl.GetScreenHeight()
|
||||
rec := rl.Rectangle{x = f32((sw-520)/2), y = f32((sh-230)/2), width = 520, height = 230}
|
||||
rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY)
|
||||
draw_card(rec)
|
||||
rl.DrawRectangleRounded(rl.Rectangle{x = rec.x, y = rec.y, width = rec.width, height = 8}, 0.08, 8, CONFIRM_ACCENT)
|
||||
x := i32(rec.x) + 30
|
||||
y := i32(rec.y) + 34
|
||||
rl.DrawText("Confirm destructive action", x, y, 26, CONFIRM_TITLE)
|
||||
draw_text_fitted(fmt.tprintf("You have unsaved changes. Do you want to %s?", pending_action_name(action)), x, y+42, 18, 470, 8, CONFIRM_BODY)
|
||||
rl.DrawText("Enter/Y confirm • Esc/N cancel", x, y+72, 16, CONFIRM_HINT)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:path/filepath"
|
||||
import "core:strings"
|
||||
import "../core"
|
||||
|
||||
format_suffix :: proc(f: core.Export_Format) -> string {
|
||||
switch f {
|
||||
case .PDF: return ".pdf"
|
||||
case .PNG: return ".zip"
|
||||
case .CBZ: return ".cbz"
|
||||
}
|
||||
return ".pdf"
|
||||
}
|
||||
|
||||
trim_known_export_suffix :: proc(path: string) -> string {
|
||||
if strings.has_suffix(path, ".pdf") { return path[:len(path)-4] }
|
||||
if strings.has_suffix(path, ".zip") { return path[:len(path)-4] }
|
||||
if strings.has_suffix(path, ".cbz") { return path[:len(path)-4] }
|
||||
if strings.has_suffix(path, ".png") { return path[:len(path)-4] }
|
||||
return path
|
||||
}
|
||||
|
||||
export_path_for_format :: proc(path: string, f: core.Export_Format) -> string {
|
||||
base := trim_known_export_suffix(path)
|
||||
return fmt.aprintf("%s%s", base, format_suffix(f))
|
||||
}
|
||||
|
||||
default_export_filename_for_format :: proc(f: core.Export_Format) -> string {
|
||||
switch f {
|
||||
case .PDF: return "comic.pdf"
|
||||
case .PNG: return "comic_png.zip"
|
||||
case .CBZ: return "comic.cbz"
|
||||
}
|
||||
return "comic.pdf"
|
||||
}
|
||||
|
||||
default_export_path_for_format :: proc(f: core.Export_Format) -> string {
|
||||
return fmt.aprintf("./%s", default_export_filename_for_format(f))
|
||||
}
|
||||
|
||||
export_path_in_project_dir :: proc(project_path: string, f: core.Export_Format) -> string {
|
||||
dir, _ := filepath.split(project_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "./"
|
||||
}
|
||||
parts := []string{dir, default_export_filename_for_format(f)}
|
||||
joined, err := filepath.join(parts)
|
||||
if err != nil {
|
||||
return default_export_path_for_format(f)
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
reset_helper_fields :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) {
|
||||
export_path^ = default_export_path_for_format(f)
|
||||
local_script_pages^ = "2"
|
||||
autosave_interval_text^ = "20"
|
||||
}
|
||||
|
||||
sync_export_path_to_project_dir :: proc(project_path: string, export_path: ^string, f: core.Export_Format) {
|
||||
export_path^ = export_path_in_project_dir(project_path, f)
|
||||
}
|
||||
|
||||
project_path_in_export_dir :: proc(export_path: string) -> string {
|
||||
dir, _ := filepath.split(export_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "./"
|
||||
}
|
||||
parts := []string{dir, "gui_project.comic.json"}
|
||||
joined, err := filepath.join(parts)
|
||||
if err != nil {
|
||||
return "./gui_project.comic.json"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
normalize_project_path :: proc(path: string) -> string {
|
||||
trimmed := strings.trim_space(path)
|
||||
if len(trimmed) == 0 {
|
||||
return "./gui_project.comic.json"
|
||||
}
|
||||
if strings.has_suffix(trimmed, ".comic.json") {
|
||||
return trimmed
|
||||
}
|
||||
if strings.has_suffix(trimmed, ".json") {
|
||||
return fmt.aprintf("%s.comic.json", trimmed[:len(trimmed)-5])
|
||||
}
|
||||
return fmt.aprintf("%s.comic.json", trimmed)
|
||||
}
|
||||
|
||||
normalize_project_path_field :: proc(path: ^string) {
|
||||
path^ = normalize_project_path(path^)
|
||||
}
|
||||
|
||||
normalize_export_path_field :: proc(path: ^string, f: core.Export_Format) {
|
||||
trimmed := strings.trim_space(path^)
|
||||
if len(trimmed) == 0 {
|
||||
path^ = default_export_path_for_format(f)
|
||||
return
|
||||
}
|
||||
path^ = export_path_for_format(trimmed, f)
|
||||
}
|
||||
|
||||
fix_all_paths :: proc(project_path, export_path: ^string, f: core.Export_Format) {
|
||||
normalize_project_path_field(project_path)
|
||||
normalize_export_path_field(export_path, f)
|
||||
}
|
||||
|
||||
set_export_preset_with_message :: proc(export_path: ^string, f: core.Export_Format) -> string {
|
||||
export_path^ = default_export_path_for_format(f)
|
||||
return fmt.aprintf("Preset export path: %s", export_path^)
|
||||
}
|
||||
|
||||
set_export_path_from_project_with_message :: proc(export_path: ^string, project_path: string, f: core.Export_Format) -> string {
|
||||
export_path^ = export_path_in_project_dir(project_path, f)
|
||||
return fmt.aprintf("Export path from project dir: %s", export_path^)
|
||||
}
|
||||
|
||||
set_project_path_from_export_with_message :: proc(project_path: ^string, export_path: string) -> string {
|
||||
project_path^ = project_path_in_export_dir(export_path)
|
||||
return fmt.aprintf("Project path from export dir: %s", project_path^)
|
||||
}
|
||||
|
||||
normalize_project_path_with_message :: proc(project_path: ^string) -> string {
|
||||
normalize_project_path_field(project_path)
|
||||
return fmt.aprintf("Normalized project path: %s", project_path^)
|
||||
}
|
||||
|
||||
normalize_export_path_with_message :: proc(export_path: ^string, f: core.Export_Format) -> string {
|
||||
normalize_export_path_field(export_path, f)
|
||||
return fmt.aprintf("Normalized export path: %s", export_path^)
|
||||
}
|
||||
|
||||
fix_all_paths_with_message :: proc(project_path, export_path: ^string, f: core.Export_Format) -> string {
|
||||
fix_all_paths(project_path, export_path, f)
|
||||
return fmt.aprintf("Normalized paths: P=%s E=%s", project_path^, export_path^)
|
||||
}
|
||||
|
||||
project_path_is_normalized :: proc(path: string) -> bool {
|
||||
trimmed := strings.trim_space(path)
|
||||
return len(trimmed) > 0 && strings.has_suffix(trimmed, ".comic.json")
|
||||
}
|
||||
|
||||
export_path_matches_format :: proc(path: string, f: core.Export_Format) -> bool {
|
||||
trimmed := strings.trim_space(path)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
return strings.has_suffix(trimmed, format_suffix(f))
|
||||
}
|
||||
|
||||
path_health_hint :: proc(project_ok, export_ok: bool) -> string {
|
||||
if project_ok && export_ok {
|
||||
return ""
|
||||
}
|
||||
if !project_ok && !export_ok {
|
||||
return "Fix paths: P/E/PE buttons or Ctrl+Shift+U"
|
||||
}
|
||||
if !project_ok {
|
||||
return "Fix project path: P button or Ctrl+Shift+K"
|
||||
}
|
||||
return "Fix export path: E button or Ctrl+Shift+M"
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import rl "vendor:raylib"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
import "../ui"
|
||||
|
||||
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)
|
||||
|
||||
selected_field := 0 // 0 idea, 1 genre, 2 audience, 3 export path, 4 local pages, 5 project path, 6 autosave interval
|
||||
export_path := "./gui_export.pdf"
|
||||
project_path := "./gui_project.comic.json"
|
||||
local_script_pages := "2"
|
||||
autosave_interval_text := "20"
|
||||
export_format: core.Export_Format = .PDF
|
||||
use_deepseek_script := false
|
||||
status_msg := fmt.aprintf("GUI ready")
|
||||
is_dirty := false
|
||||
autosave_enabled := true
|
||||
autosave_interval_s: f64 = 20
|
||||
last_autosave_at := rl.GetTime()
|
||||
last_save_at: f64 = -1
|
||||
last_export_at: f64 = -1
|
||||
action_log: Action_Log
|
||||
log_show_lines: i32 = 6
|
||||
log_oldest_first := false
|
||||
summary_opts := Summary_View_Options{}
|
||||
show_help_overlay := false
|
||||
show_confirm_overlay := false
|
||||
pending_confirm_action: Pending_Confirm_Action = .None
|
||||
push_status(&status_msg, &action_log, status_msg)
|
||||
defer action_log_dispose(&action_log)
|
||||
defer delete(status_msg)
|
||||
|
||||
for !rl.WindowShouldClose() {
|
||||
screen_w_loop := rl.GetScreenWidth()
|
||||
screen_h_loop := rl.GetScreenHeight()
|
||||
compact_mode := screen_h_loop < 860
|
||||
cfg_loop := shared.load_config()
|
||||
has_deepseek_key := len(cfg_loop.deepseek_api_key) > 0
|
||||
main_w_loop := screen_w_loop - 282 - 20
|
||||
if main_w_loop < 960 {
|
||||
main_w_loop = 960
|
||||
}
|
||||
status_w_loop := (main_w_loop - 2) / 2
|
||||
log_x_loop := 282 + status_w_loop + 2
|
||||
lower_y_loop := screen_h_loop - 252
|
||||
if lower_y_loop < 450 {
|
||||
lower_y_loop = 450
|
||||
}
|
||||
|
||||
idea_rec := rl.Rectangle{x = 420, y = 90, width = f32(main_w_loop-460), height = 36}
|
||||
genre_rec := rl.Rectangle{x = 420, y = 146, width = f32(main_w_loop-460), height = 36}
|
||||
audience_rec := rl.Rectangle{x = 420, y = 202, width = f32(main_w_loop-460), height = 36}
|
||||
export_rec := rl.Rectangle{x = 420, y = 258, width = f32(main_w_loop-460), height = 36}
|
||||
project_rec := rl.Rectangle{x = 420, y = 314, width = f32(main_w_loop-460), height = 36}
|
||||
pages_rec := rl.Rectangle{x = f32(screen_w_loop - 120), y = 22, width = 100, height = 28}
|
||||
|
||||
fmt_pdf_btn := rl.Rectangle{x = 420, y = 400, width = 80, height = 30}
|
||||
fmt_png_btn := rl.Rectangle{x = 510, y = 400, width = 80, height = 30}
|
||||
fmt_cbz_btn := rl.Rectangle{x = 600, y = 400, width = 80, height = 30}
|
||||
script_src_local_btn := rl.Rectangle{x = 800, y = 400, width = 92, height = 30}
|
||||
script_src_deepseek_btn := rl.Rectangle{x = 898, y = 400, width = 120, height = 30}
|
||||
|
||||
// Main Actions Row 1 (y=470)
|
||||
new_btn := rl.Rectangle{x = 290, y = 470, width = 140, height = 38}
|
||||
script_btn := rl.Rectangle{x = 440, y = 470, width = 230, height = 38}
|
||||
panels_btn := rl.Rectangle{x = 680, y = 470, width = 230, height = 38}
|
||||
layout_btn := rl.Rectangle{x = 920, y = 470, width = 140, height = 38}
|
||||
export_btn := rl.Rectangle{x = 1070, y = 470, width = 140, height = 38}
|
||||
|
||||
// Secondary Actions Row 2 (y=518)
|
||||
save_btn := rl.Rectangle{x = 290, y = 518, width = 160, height = 38}
|
||||
open_btn := rl.Rectangle{x = 460, y = 518, width = 160, height = 38}
|
||||
next_btn := rl.Rectangle{x = 630, y = 518, width = 160, height = 38}
|
||||
auto_btn := rl.Rectangle{x = 800, y = 518, width = 160, height = 38}
|
||||
auto_save_btn := rl.Rectangle{x = 970, y = 518, width = 240, height = 38}
|
||||
|
||||
// Utility Strip Row 3 (y=566)
|
||||
autosave_btn := rl.Rectangle{x = 290, y = 566, width = 160, height = 34}
|
||||
autosave_rec := rl.Rectangle{x = 600, y = 568, width = 70, height = 30}
|
||||
autosave_15_btn := rl.Rectangle{x = 680, y = 568, width = 44, height = 30}
|
||||
autosave_30_btn := rl.Rectangle{x = 730, y = 568, width = 44, height = 30}
|
||||
autosave_60_btn := rl.Rectangle{x = 780, y = 568, width = 44, height = 30}
|
||||
help_btn := rl.Rectangle{x = 850, y = 566, width = 110, height = 34}
|
||||
clear_field_btn := rl.Rectangle{x = 970, y = 566, width = 110, height = 34}
|
||||
reset_helpers_btn := rl.Rectangle{x = 1090, y = 566, width = 120, height = 34}
|
||||
|
||||
// Path Fixes (now below project inputs)
|
||||
export_copy_btn := rl.Rectangle{x = 420, y = 360, width = 110, height = 24}
|
||||
export_preset_btn := rl.Rectangle{x = 540, y = 360, width = 110, height = 24}
|
||||
path_fix_btn := rl.Rectangle{x = 660, y = 360, width = 110, height = 24}
|
||||
project_fix_btn := rl.Rectangle{x = 780, y = 360, width = 110, height = 24}
|
||||
project_from_export_btn := rl.Rectangle{x = 900, y = 360, width = 110, height = 24}
|
||||
export_project_btn := rl.Rectangle{x = 1020, y = 360, width = 110, height = 24}
|
||||
|
||||
log_reset_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
report_file_btn := rl.Rectangle{x = f32(log_x_loop + 92), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
log_copy_btn := rl.Rectangle{x = f32(log_x_loop + 166), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
script_copy_page_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 96, height = 26}
|
||||
script_copy_all_btn := rl.Rectangle{x = f32(log_x_loop + 120), y = f32(lower_y_loop + 2), width = 86, height = 26}
|
||||
diag_file_btn := rl.Rectangle{x = f32(log_x_loop + 240), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
status_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
log_clear_btn := rl.Rectangle{x = f32(log_x_loop + 388), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
diag_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 32), width = 68, height = 26}
|
||||
confirm_base_x := (screen_w_loop - 520) / 2
|
||||
confirm_base_y := (screen_h_loop - 230) / 2
|
||||
confirm_yes_btn := rl.Rectangle{x = f32(confirm_base_x + 180), y = f32(confirm_base_y + 154), width = 140, height = 34}
|
||||
confirm_no_btn := rl.Rectangle{x = f32(confirm_base_x + 330), y = f32(confirm_base_y + 154), width = 140, height = 34}
|
||||
path_fix_project_status_btn := rl.Rectangle{x = 638, y = 556, width = 32, height = 20}
|
||||
path_fix_export_status_btn := rl.Rectangle{x = 674, y = 556, width = 32, height = 20}
|
||||
path_fix_all_status_btn := rl.Rectangle{x = 710, y = 556, width = 34, height = 20}
|
||||
|
||||
summary_show_btn := rl.Rectangle{x = f32(282 + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24}
|
||||
summary_sort_btn := rl.Rectangle{x = f32(282 + status_w_loop - 94), y = f32(lower_y_loop + 18), width = 78, height = 24}
|
||||
summary_prev_btn := rl.Rectangle{x = f32(300), y = f32(lower_y_loop + 18), width = 52, height = 24}
|
||||
summary_next_btn := rl.Rectangle{x = f32(358), y = f32(lower_y_loop + 18), width = 52, height = 24}
|
||||
|
||||
if rl.IsKeyPressed(.SLASH) {
|
||||
toggle_help_overlay(&show_help_overlay)
|
||||
}
|
||||
if rl.IsKeyPressed(.ESCAPE) {
|
||||
close_help_overlay_if_open(&show_help_overlay)
|
||||
}
|
||||
interaction_locked := show_help_overlay || show_confirm_overlay
|
||||
|
||||
nav_story := rl.Rectangle{x = 16, y = 90, width = 228, height = 32}
|
||||
nav_script := rl.Rectangle{x = 16, y = 126, width = 228, height = 32}
|
||||
nav_chars := rl.Rectangle{x = 16, y = 162, width = 228, height = 32}
|
||||
nav_panels := rl.Rectangle{x = 16, y = 198, width = 228, height = 32}
|
||||
nav_layout := rl.Rectangle{x = 16, y = 234, width = 228, height = 32}
|
||||
nav_bubbles := rl.Rectangle{x = 16, y = 270, width = 228, height = 32}
|
||||
nav_export := rl.Rectangle{x = 16, y = 306, width = 228, height = 32}
|
||||
nav_community := rl.Rectangle{x = 16, y = 342, width = 228, height = 32}
|
||||
|
||||
if !interaction_locked {
|
||||
if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&controller, .Story) }
|
||||
if rl.IsKeyPressed(.TWO) { _ = ui.navigate_to_screen(&controller, .Script) }
|
||||
if rl.IsKeyPressed(.THREE) { _ = ui.navigate_to_screen(&controller, .Characters) }
|
||||
if rl.IsKeyPressed(.FOUR) { _ = ui.navigate_to_screen(&controller, .Panels) }
|
||||
if rl.IsKeyPressed(.FIVE) { _ = ui.navigate_to_screen(&controller, .Layout) }
|
||||
if rl.IsKeyPressed(.SIX) { _ = ui.navigate_to_screen(&controller, .Bubbles) }
|
||||
if rl.IsKeyPressed(.SEVEN) { _ = ui.navigate_to_screen(&controller, .Export) }
|
||||
if rl.IsKeyPressed(.EIGHT) { _ = ui.navigate_to_screen(&controller, .Community) }
|
||||
|
||||
if button_clicked(nav_story) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Story))
|
||||
}
|
||||
if button_clicked(nav_script) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Script))
|
||||
}
|
||||
if button_clicked(nav_chars) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Characters))
|
||||
}
|
||||
if button_clicked(nav_panels) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Panels))
|
||||
}
|
||||
if button_clicked(nav_layout) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Layout))
|
||||
}
|
||||
if button_clicked(nav_bubbles) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Bubbles))
|
||||
}
|
||||
if button_clicked(nav_export) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Export))
|
||||
}
|
||||
if button_clicked(nav_community) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Community))
|
||||
}
|
||||
|
||||
if rl.IsKeyPressed(.TAB) { selected_field = (selected_field + 1) % 7 }
|
||||
if rl.IsKeyPressed(.F1) { selected_field = 0 }
|
||||
if rl.IsKeyPressed(.F2) { selected_field = 1 }
|
||||
if rl.IsKeyPressed(.F3) { selected_field = 2 }
|
||||
if rl.IsKeyPressed(.F4) { selected_field = 3 }
|
||||
if rl.IsKeyPressed(.F11) { selected_field = 4 }
|
||||
if rl.IsKeyPressed(.F12) { selected_field = 5 }
|
||||
if button_clicked(idea_rec) { selected_field = 0 }
|
||||
if button_clicked(genre_rec) { selected_field = 1 }
|
||||
if button_clicked(audience_rec) { selected_field = 2 }
|
||||
if button_clicked(export_rec) { selected_field = 3 }
|
||||
if button_clicked(pages_rec) { selected_field = 4 }
|
||||
if button_clicked(project_rec) { selected_field = 5 }
|
||||
if button_clicked(autosave_rec) { selected_field = 6 }
|
||||
if button_clicked(autosave_15_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15))
|
||||
}
|
||||
if button_clicked(autosave_30_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30))
|
||||
}
|
||||
if button_clicked(autosave_60_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60))
|
||||
}
|
||||
}
|
||||
|
||||
pages_count := parse_pages_or_default(local_script_pages, 2)
|
||||
autosave_secs := parse_autosave_interval(autosave_interval_text, 20)
|
||||
autosave_interval_s = f64(autosave_secs)
|
||||
if selected_field != 6 && len(strings.trim_space(autosave_interval_text)) == 0 {
|
||||
autosave_interval_text = fmt.aprintf("%d", autosave_secs)
|
||||
}
|
||||
shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT)
|
||||
can_generate_panels := len(controller.state.script.pages) > 0
|
||||
can_layout := len(controller.state.panel_images) > 0
|
||||
can_export := len(controller.state.page_layouts) > 0 && len(controller.state.panel_images) > 0
|
||||
project_path_ok := project_path_is_normalized(project_path)
|
||||
export_path_ok := export_path_matches_format(export_path, export_format)
|
||||
|
||||
if show_confirm_overlay {
|
||||
confirm_yes := button_clicked(confirm_yes_btn) || rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y)
|
||||
confirm_no := button_clicked(confirm_no_btn) || rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N)
|
||||
if confirm_no {
|
||||
show_confirm_overlay = false
|
||||
pending_confirm_action = .None
|
||||
push_status(&status_msg, &action_log, "Cancelled destructive action")
|
||||
} else if confirm_yes {
|
||||
action := pending_confirm_action
|
||||
show_confirm_overlay = false
|
||||
pending_confirm_action = .None
|
||||
push_status(&status_msg, &action_log, resolve_confirm_action_with_message(action, &controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
|
||||
}
|
||||
}
|
||||
|
||||
if !interaction_locked {
|
||||
if button_clicked(export_copy_btn) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard"))
|
||||
}
|
||||
if button_clicked(export_preset_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format))
|
||||
}
|
||||
if button_clicked(path_fix_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if button_clicked(project_fix_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if button_clicked(project_from_export_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path))
|
||||
}
|
||||
if button_clicked(export_project_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format))
|
||||
}
|
||||
if button_clicked(path_fix_project_status_btn) && !project_path_ok {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if button_clicked(path_fix_export_status_btn) && !export_path_ok {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if button_clicked(path_fix_all_status_btn) && (!project_path_ok || !export_path_ok) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format))
|
||||
}
|
||||
if button_clicked(fmt_pdf_btn) {
|
||||
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PDF, &is_dirty))
|
||||
}
|
||||
if button_clicked(fmt_png_btn) {
|
||||
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PNG, &is_dirty))
|
||||
}
|
||||
if button_clicked(fmt_cbz_btn) {
|
||||
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .CBZ, &is_dirty))
|
||||
}
|
||||
if button_clicked(script_src_local_btn) {
|
||||
use_deepseek_script = false
|
||||
push_status(&status_msg, &action_log, "Script source: Local")
|
||||
}
|
||||
if button_clicked(script_src_deepseek_btn) {
|
||||
if !has_deepseek_key {
|
||||
push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)")
|
||||
} else {
|
||||
use_deepseek_script = true
|
||||
push_status(&status_msg, &action_log, "Script source: DeepSeek")
|
||||
}
|
||||
}
|
||||
|
||||
if button_clicked(summary_show_btn) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if button_clicked(summary_sort_btn) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(summary_prev_btn) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor -= 1
|
||||
if summary_opts.script_page_cursor < 0 {
|
||||
summary_opts.script_page_cursor = page_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(summary_next_btn) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor += 1
|
||||
if summary_opts.script_page_cursor >= page_count {
|
||||
summary_opts.script_page_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && button_clicked(summary_prev_btn) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor -= 1
|
||||
if summary_opts.panel_cursor < 0 {
|
||||
summary_opts.panel_cursor = panel_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && button_clicked(summary_next_btn) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor += 1
|
||||
if summary_opts.panel_cursor >= panel_count {
|
||||
summary_opts.panel_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
|
||||
if button_clicked(new_btn) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, false))
|
||||
}
|
||||
}
|
||||
if button_clicked(save_btn) {
|
||||
push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project"))
|
||||
}
|
||||
if button_clicked(open_btn) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
|
||||
}
|
||||
}
|
||||
if button_clicked(auto_save_btn) {
|
||||
push_status(&status_msg, &action_log, run_auto_all_save_action(&controller, &project_path, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at, &last_autosave_at, &last_save_at))
|
||||
}
|
||||
if button_clicked(autosave_btn) {
|
||||
push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled))
|
||||
}
|
||||
if button_clicked(help_btn) {
|
||||
toggle_help_overlay(&show_help_overlay)
|
||||
}
|
||||
if button_clicked(clear_field_btn) {
|
||||
push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
|
||||
}
|
||||
if button_clicked(reset_helpers_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format))
|
||||
}
|
||||
if controller.active_screen != .Script {
|
||||
if button_clicked(log_reset_btn) {
|
||||
push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first))
|
||||
}
|
||||
if button_clicked(report_file_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx))
|
||||
}
|
||||
if button_clicked(log_copy_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx))
|
||||
}
|
||||
if button_clicked(diag_file_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
if button_clicked(log_clear_btn) {
|
||||
set_status(&status_msg, clear_action_log_with_message(&action_log))
|
||||
}
|
||||
if button_clicked(status_copy_btn) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard"))
|
||||
}
|
||||
if button_clicked(diag_copy_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(script_copy_page_btn) {
|
||||
page_text := build_script_page_detail_text(controller.state, summary_opts.script_page_cursor)
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(page_text, "Copied script page detail"))
|
||||
delete(page_text)
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(script_copy_all_btn) {
|
||||
full_text := build_full_script_text(controller.state)
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(full_text, "Copied full script"))
|
||||
delete(full_text)
|
||||
}
|
||||
if button_clicked(script_btn) {
|
||||
push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty))
|
||||
}
|
||||
if button_clicked(panels_btn) {
|
||||
push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty))
|
||||
}
|
||||
if button_clicked(layout_btn) {
|
||||
push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty))
|
||||
}
|
||||
if button_clicked(export_btn) {
|
||||
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
|
||||
}
|
||||
if button_clicked(next_btn) {
|
||||
push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
if button_clicked(auto_btn) {
|
||||
push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
|
||||
if rl.IsKeyPressed(.F5) {
|
||||
push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty))
|
||||
}
|
||||
if rl.IsKeyPressed(.F6) {
|
||||
push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty))
|
||||
}
|
||||
if rl.IsKeyPressed(.F7) {
|
||||
push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty))
|
||||
}
|
||||
if rl.IsKeyPressed(.F8) {
|
||||
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
|
||||
}
|
||||
if rl.IsKeyPressed(.F9) {
|
||||
push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
if rl.IsKeyPressed(.F10) {
|
||||
push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
|
||||
ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)
|
||||
if ctrl_down && rl.IsKeyPressed(.S) {
|
||||
push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project"))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.N) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, true))
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.O) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.E) {
|
||||
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.G) {
|
||||
if use_deepseek_script {
|
||||
use_deepseek_script = false
|
||||
push_status(&status_msg, &action_log, "Script source: Local")
|
||||
} else {
|
||||
if !has_deepseek_key {
|
||||
push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)")
|
||||
} else {
|
||||
use_deepseek_script = true
|
||||
push_status(&status_msg, &action_log, "Script source: DeepSeek")
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.H) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.J) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor -= 1
|
||||
if summary_opts.script_page_cursor < 0 {
|
||||
summary_opts.script_page_cursor = page_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor += 1
|
||||
if summary_opts.script_page_cursor >= page_count {
|
||||
summary_opts.script_page_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor -= 1
|
||||
if summary_opts.panel_cursor < 0 {
|
||||
summary_opts.panel_cursor = panel_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor += 1
|
||||
if summary_opts.panel_cursor >= panel_count {
|
||||
summary_opts.panel_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.MINUS) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs-5))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.EQUAL) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs+5))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.SEVEN) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.EIGHT) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.NINE) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.ZERO) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format))
|
||||
}
|
||||
if ctrl_down && !shift_down && rl.IsKeyPressed(.L) {
|
||||
set_status(&status_msg, clear_action_log_with_message(&action_log))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.L) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.Z) {
|
||||
push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.W) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.V) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, paste_clipboard_into_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.BACKSPACE) {
|
||||
push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.I) {
|
||||
field_text := selected_field_value(selected_field, controller.state, export_path, local_script_pages, project_path, autosave_interval_text)
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(field_text, "Copied selected field to clipboard"))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.C) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard"))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.Y) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.T) {
|
||||
push_status(&status_msg, &action_log, toggle_log_lines_with_message(&log_show_lines))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.B) {
|
||||
push_status(&status_msg, &action_log, toggle_log_order_with_message(&log_oldest_first))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.R) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.X) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard"))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.P) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.D) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.G) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.J) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.K) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.M) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.U) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.F) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.A) {
|
||||
push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled))
|
||||
}
|
||||
|
||||
if !interaction_locked {
|
||||
for {
|
||||
ch := rl.GetCharPressed()
|
||||
if ch == 0 { break }
|
||||
if ch < 32 || ch > 126 { continue }
|
||||
if selected_field == 6 && (ch < '0' || ch > '9') {
|
||||
continue
|
||||
}
|
||||
switch selected_field {
|
||||
case 0: append_char(&controller.state.story_idea, ch)
|
||||
case 1: append_char(&controller.state.story_genre, ch)
|
||||
case 2: append_char(&controller.state.target_audience, ch)
|
||||
case 3: append_char(&export_path, ch)
|
||||
case 4: append_char(&local_script_pages, ch)
|
||||
case 5: append_char(&project_path, ch)
|
||||
case 6: append_char(&autosave_interval_text, ch)
|
||||
}
|
||||
is_dirty = true
|
||||
}
|
||||
if rl.IsKeyPressed(.BACKSPACE) {
|
||||
switch selected_field {
|
||||
case 0: pop_char(&controller.state.story_idea)
|
||||
case 1: pop_char(&controller.state.story_genre)
|
||||
case 2: pop_char(&controller.state.target_audience)
|
||||
case 3: pop_char(&export_path)
|
||||
case 4: pop_char(&local_script_pages)
|
||||
case 5: pop_char(&project_path)
|
||||
case 6: pop_char(&autosave_interval_text)
|
||||
}
|
||||
is_dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
push_status_if_nonempty(&status_msg, &action_log, autosave_tick_with_message(&project_path, controller.state, autosave_enabled, &is_dirty, &last_autosave_at, &last_save_at, autosave_interval_s))
|
||||
|
||||
rl.BeginDrawing()
|
||||
rl.ClearBackground(BG_BASE)
|
||||
screen_w := rl.GetScreenWidth()
|
||||
screen_h := rl.GetScreenHeight()
|
||||
main_w := screen_w - 282 - 20
|
||||
if main_w < 960 {
|
||||
main_w = 960
|
||||
}
|
||||
|
||||
rl.DrawRectangle(0, 0, 260, screen_h, BG_SIDEBAR)
|
||||
rl.DrawRectangle(260, 0, screen_w-260, 72, BG_TOPBAR)
|
||||
rl.DrawLine(260, 72, screen_w, 72, BORDER_DIVIDER)
|
||||
rl.DrawLine(260, 0, 260, screen_h, BORDER_DIVIDER)
|
||||
|
||||
draw_card(rl.Rectangle{x = 14, y = 10, width = 236, height = 64})
|
||||
rl.DrawText("comic-odin", 24, 22, 23, BRAND_TITLE)
|
||||
rl.DrawText("Native GUI", 24, 46, 14, BRAND_SUBTITLE)
|
||||
draw_section_title(24, 68, "Screens")
|
||||
draw_card(rl.Rectangle{x = 14, y = 86, width = 236, height = 296})
|
||||
draw_nav_item(nav_story, "1 Story", controller.active_screen == .Story)
|
||||
draw_nav_item(nav_script, "2 Script", controller.active_screen == .Script)
|
||||
draw_nav_item(nav_chars, "3 Characters", controller.active_screen == .Characters)
|
||||
draw_nav_item(nav_panels, "4 Panels", controller.active_screen == .Panels)
|
||||
draw_nav_item(nav_layout, "5 Layout", controller.active_screen == .Layout)
|
||||
draw_nav_item(nav_bubbles, "6 Bubbles", controller.active_screen == .Bubbles)
|
||||
draw_nav_item(nav_export, "7 Export", controller.active_screen == .Export)
|
||||
draw_nav_item(nav_community, "8 Community", controller.active_screen == .Community)
|
||||
draw_sidebar_shortcuts(screen_h_loop)
|
||||
|
||||
// --- Pipeline Stepper in Topbar ---
|
||||
draw_card(rl.Rectangle{x = 282, y = 12, width = f32(main_w), height = 48})
|
||||
rl.DrawText(fmt.ctprintf("%s", ui.screen_name(controller.active_screen)), 300, 26, 20, BRAND_TITLE)
|
||||
|
||||
script_ok := len(controller.state.script.pages) > 0
|
||||
panels_ok := len(controller.state.panel_images) > 0
|
||||
layout_ok := len(controller.state.page_layouts) > 0
|
||||
export_ok := panels_ok && layout_ok
|
||||
|
||||
step_x := i32(460)
|
||||
step_y := i32(36)
|
||||
step_spacing := i32(140)
|
||||
|
||||
// Draw connecting lines first
|
||||
for i in 0..<3 {
|
||||
x1 := step_x + i32(i)*step_spacing + 24
|
||||
x2 := step_x + i32(i+1)*step_spacing - 24
|
||||
c := STEP_LINE_TODO
|
||||
if (i == 0 && panels_ok) || (i == 1 && layout_ok) || (i == 2 && export_ok) {
|
||||
c = STEP_LINE_DONE
|
||||
}
|
||||
rl.DrawLineEx(rl.Vector2{f32(x1), f32(step_y)}, rl.Vector2{f32(x2), f32(step_y)}, 2.0, c)
|
||||
}
|
||||
|
||||
// Helper to draw a single pipeline step
|
||||
draw_step := proc(x, y: i32, label: string, done: bool) {
|
||||
fill := STEP_TODO_FILL
|
||||
border := STEP_TODO_BORDER
|
||||
text_col := STEP_LABEL_TODO
|
||||
if done {
|
||||
fill = STEP_DONE_FILL
|
||||
border = STEP_DONE_BORDER
|
||||
text_col = STEP_LABEL_DONE
|
||||
}
|
||||
rl.DrawCircle(x, y, 10, fill)
|
||||
rl.DrawCircleLines(x, y, 10, border)
|
||||
// Draw checkmark if done
|
||||
if done {
|
||||
rl.DrawLineEx(rl.Vector2{f32(x-3), f32(y+1)}, rl.Vector2{f32(x-1), f32(y+4)}, 2.0, BG_BASE)
|
||||
rl.DrawLineEx(rl.Vector2{f32(x-1), f32(y+4)}, rl.Vector2{f32(x+4), f32(y-3)}, 2.0, BG_BASE)
|
||||
}
|
||||
draw_text_fitted(label, x - 24, y + 16, 14, 48, 7, text_col)
|
||||
}
|
||||
|
||||
draw_step(step_x, step_y, "Script", script_ok)
|
||||
draw_step(step_x + step_spacing, step_y, "Panels", panels_ok)
|
||||
draw_step(step_x + step_spacing*2, step_y, "Layout", layout_ok)
|
||||
draw_step(step_x + step_spacing*3, step_y, "Export", export_ok)
|
||||
|
||||
// Pages input on far right of topbar
|
||||
rl.DrawText("Local script pages", screen_w_loop - 250, 26, 16, TEXT_SECONDARY)
|
||||
draw_input_field(pages_rec, local_script_pages, selected_field == 4)
|
||||
|
||||
draw_card(rl.Rectangle{x = 282, y = 82, width = f32(main_w), height = 356})
|
||||
draw_card(rl.Rectangle{x = 282, y = 460, width = f32(main_w), height = 110})
|
||||
draw_section_title(300, 92, "Project Setup")
|
||||
draw_section_title(300, 470, "Actions")
|
||||
|
||||
rl.DrawText("Story Idea", 290, 96, 16, TEXT_SECONDARY)
|
||||
draw_input_field(idea_rec, controller.state.story_idea, selected_field == 0)
|
||||
|
||||
rl.DrawText("Genre", 290, 152, 16, TEXT_SECONDARY)
|
||||
draw_input_field(genre_rec, controller.state.story_genre, selected_field == 1)
|
||||
|
||||
rl.DrawText("Audience", 290, 208, 16, TEXT_SECONDARY)
|
||||
draw_input_field(audience_rec, controller.state.target_audience, selected_field == 2)
|
||||
|
||||
rl.DrawText("Export Path", 290, 264, 16, TEXT_SECONDARY)
|
||||
draw_input_field(export_rec, export_path, selected_field == 3)
|
||||
|
||||
rl.DrawText("Project Path", 290, 320, 16, TEXT_SECONDARY)
|
||||
draw_input_field(project_rec, project_path, selected_field == 5)
|
||||
|
||||
// Compact utility row below inputs
|
||||
draw_small_button(export_copy_btn, "Copy Export")
|
||||
draw_small_button(export_preset_btn, "Preset Ext")
|
||||
draw_small_button(path_fix_btn, "Fix Exp Ext")
|
||||
draw_small_button(project_fix_btn, "Fix Proj Ext")
|
||||
draw_small_button(project_from_export_btn, "Proj From Exp")
|
||||
draw_small_button(export_project_btn, "Exp From Proj")
|
||||
|
||||
rl.DrawText("Format", 290, 404, 16, TEXT_SECONDARY)
|
||||
draw_nav_item(fmt_pdf_btn, "PDF", export_format == .PDF)
|
||||
draw_nav_item(fmt_png_btn, "PNG", export_format == .PNG)
|
||||
draw_nav_item(fmt_cbz_btn, "CBZ", export_format == .CBZ)
|
||||
|
||||
rl.DrawText("Script Source", 700, 404, 16, TEXT_SECONDARY)
|
||||
draw_nav_item(script_src_local_btn, "Local", !use_deepseek_script)
|
||||
draw_nav_item(script_src_deepseek_btn, "DeepSeek", use_deepseek_script)
|
||||
if !has_deepseek_key {
|
||||
draw_summary_subline(1024, 408, "set DEEPSEEK_API_KEY", KEY_MISSING_COLOR)
|
||||
}
|
||||
|
||||
next_hint := gui_next_hint_with_source(controller, use_deepseek_script)
|
||||
recommended_label := recommended_label_from_hint(next_hint)
|
||||
script_btn_label := "Generate Script Local"
|
||||
if use_deepseek_script {
|
||||
script_btn_label = "Generate Script"
|
||||
}
|
||||
|
||||
draw_button_warning(new_btn, "New Project")
|
||||
if recommended_label == "Generate Script" || recommended_label == "Generate Script Local" {
|
||||
draw_button_recommended(script_btn, script_btn_label)
|
||||
} else {
|
||||
draw_button(script_btn, script_btn_label)
|
||||
}
|
||||
if recommended_label == "Generate Panels Local" {
|
||||
draw_button_recommended(panels_btn, "Generate Panels Local")
|
||||
} else {
|
||||
draw_button_state(panels_btn, "Generate Panels Local", can_generate_panels)
|
||||
}
|
||||
if recommended_label == "Layout" {
|
||||
draw_button_recommended(layout_btn, "Layout Pages")
|
||||
} else {
|
||||
draw_button_state(layout_btn, "Layout Pages", can_layout)
|
||||
}
|
||||
if recommended_label == "Export" {
|
||||
draw_button_recommended(export_btn, "Export")
|
||||
} else {
|
||||
draw_button_state(export_btn, "Export", can_export)
|
||||
}
|
||||
|
||||
draw_button_soft_accent(save_btn, "Save")
|
||||
draw_button_soft_accent(open_btn, "Open")
|
||||
draw_button_primary(next_btn, "Next Step")
|
||||
draw_button_primary(auto_btn, "Auto-All")
|
||||
draw_button_primary(auto_save_btn, "Auto-All + Save")
|
||||
|
||||
draw_button_soft_accent(autosave_btn, autosave_enabled ? "Autosave: yes" : "Autosave: no")
|
||||
rl.DrawText("Interval(s)", 500, 574, 16, TEXT_SECONDARY)
|
||||
draw_input_field(autosave_rec, autosave_interval_text, selected_field == 6)
|
||||
draw_small_button(autosave_15_btn, "15")
|
||||
draw_small_button(autosave_30_btn, "30")
|
||||
draw_small_button(autosave_60_btn, "60")
|
||||
draw_button_soft_accent(help_btn, "Help (/)")
|
||||
draw_button(clear_field_btn, "Clear Field")
|
||||
draw_button_soft_accent(reset_helpers_btn, "Reset Helpers")
|
||||
|
||||
label := "idea"
|
||||
if selected_field == 1 { label = "genre" }
|
||||
if selected_field == 2 { label = "audience" }
|
||||
if selected_field == 3 { label = "export path" }
|
||||
if selected_field == 4 { label = "local pages" }
|
||||
if selected_field == 5 { label = "project path" }
|
||||
if selected_field == 6 { label = "autosave interval" }
|
||||
if !compact_mode {
|
||||
hint_msg := button_readiness_hint(rl.GetMousePosition(), panels_btn, layout_btn, export_btn, can_generate_panels, can_layout, can_export)
|
||||
if len(hint_msg) > 0 {
|
||||
draw_hint_pill(rl.Rectangle{x = 1000, y = 580, width = 210, height = 22}, fmt.tprintf("%s", hint_msg), true)
|
||||
}
|
||||
}
|
||||
|
||||
draw_card(rl.Rectangle{x = 282, y = f32(lower_y_loop-140), width = f32(status_w_loop), height = 160})
|
||||
now_draw := rl.GetTime()
|
||||
status_y := lower_y_loop - 126
|
||||
rl.DrawText("Status", 300, status_y, 19, BRAND_TITLE)
|
||||
rl.DrawLine(370, status_y+10, i32(282+status_w_loop)-14, status_y+10, BORDER_DIVIDER)
|
||||
draw_text_fitted(status_msg, 300, status_y+26, 18, int(status_w_loop-36), 8, status_text_color(status_msg))
|
||||
draw_readiness_row(controller, 300, status_y+50)
|
||||
ready_count, total_count := ready_stage_count(controller)
|
||||
progress := f32(0)
|
||||
if total_count > 0 {
|
||||
progress = f32(ready_count) / f32(total_count)
|
||||
}
|
||||
draw_progress_bar(300, status_y+84, status_w_loop-26, progress)
|
||||
draw_text_fitted(fmt.tprintf("Pipeline: %d/%d", ready_count, total_count), 300, status_y+102, 14, 122, 7, TEXT_TERTIARY)
|
||||
draw_text_fitted(fmt.tprintf("Next: %s", gui_next_hint_with_source(controller, use_deepseek_script)), 430, status_y+102, 14, int(status_w_loop-166), 7, TEXT_SECONDARY)
|
||||
draw_status_badge(rl.Rectangle{x = 300, y = f32(status_y+120), width = 118, height = 22}, fmt.tprintf("Dirty: %s", yn(is_dirty)), !is_dirty)
|
||||
draw_status_badge(rl.Rectangle{x = 430, y = f32(status_y+120), width = 188, height = 22}, fmt.tprintf("Autosave: %s (%ds)", yn(autosave_enabled), autosave_secs), autosave_enabled)
|
||||
save_meta := "save: never"
|
||||
if last_save_at >= 0 {
|
||||
save_meta = fmt.tprintf("save: %.0fs", now_draw-last_save_at)
|
||||
}
|
||||
export_meta := "export: never"
|
||||
if last_export_at >= 0 {
|
||||
export_meta = fmt.tprintf("export: %.0fs", now_draw-last_export_at)
|
||||
}
|
||||
draw_text_fitted(fmt.tprintf("%s | %s", save_meta, export_meta), 630, status_y+124, 13, int(status_w_loop-26), 7, TEXT_TERTIARY)
|
||||
path_fix_project_status_btn.y = f32(status_y + 114)
|
||||
path_fix_export_status_btn.y = f32(status_y + 114)
|
||||
path_fix_all_status_btn.y = f32(status_y + 114)
|
||||
draw_small_button_state(path_fix_project_status_btn, "P", !project_path_ok)
|
||||
draw_small_button_state(path_fix_export_status_btn, "E", !export_path_ok)
|
||||
draw_small_button_state(path_fix_all_status_btn, "PE", !project_path_ok || !export_path_ok)
|
||||
|
||||
draw_screen_summary(controller, export_path, 300, lower_y_loop+12, status_w_loop-8, summary_opts)
|
||||
if controller.active_screen == .Script || controller.active_screen == .Layout || controller.active_screen == .Panels {
|
||||
if controller.active_screen == .Script || controller.active_screen == .Layout {
|
||||
show_txt := "Top"
|
||||
sort_txt := "Asc"
|
||||
if controller.active_screen == .Script {
|
||||
if summary_opts.script_show_all { show_txt = "All" }
|
||||
if summary_opts.script_desc { sort_txt = "Desc" }
|
||||
} else {
|
||||
if summary_opts.layout_show_all { show_txt = "All" }
|
||||
if summary_opts.layout_desc { sort_txt = "Desc" }
|
||||
}
|
||||
show_btn_label := "Show:Top"
|
||||
if show_txt == "All" {
|
||||
show_btn_label = "Show:All"
|
||||
}
|
||||
sort_btn_label := "Sort:Asc"
|
||||
if sort_txt == "Desc" {
|
||||
sort_btn_label = "Sort:Desc"
|
||||
}
|
||||
draw_small_button(summary_show_btn, show_btn_label)
|
||||
draw_small_button(summary_sort_btn, sort_btn_label)
|
||||
}
|
||||
if controller.active_screen == .Script {
|
||||
draw_small_button(summary_prev_btn, "< Pg")
|
||||
draw_small_button(summary_next_btn, "Pg >")
|
||||
} else if controller.active_screen == .Panels {
|
||||
draw_small_button(summary_prev_btn, "< Pn")
|
||||
draw_small_button(summary_next_btn, "Pn >")
|
||||
}
|
||||
if !compact_mode {
|
||||
hint_label := "Ctrl+[ / Ctrl+]"
|
||||
if controller.active_screen == .Script || controller.active_screen == .Layout {
|
||||
hint_label = "Ctrl+H / Ctrl+J"
|
||||
}
|
||||
draw_hint_pill(rl.Rectangle{x = f32(282 + status_w_loop - 186), y = f32(lower_y_loop + 46), width = 172, height = 20}, hint_label, false)
|
||||
}
|
||||
}
|
||||
if !compact_mode {
|
||||
draw_hint_pill(rl.Rectangle{x = 300, y = f32(lower_y_loop + 206), width = f32(status_w_loop-18), height = 24}, "Tip: New/Open show confirm modal when dirty (Shift still quick-confirms)", true)
|
||||
}
|
||||
|
||||
if controller.active_screen == .Script {
|
||||
draw_script_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.script_page_cursor)
|
||||
draw_small_button(script_copy_page_btn, "Copy Page")
|
||||
draw_small_button(script_copy_all_btn, "Copy All")
|
||||
draw_text_fitted("Ctrl+[ / Ctrl+] page nav", log_x_loop+216, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-228), 7, TEXT_TERTIARY)
|
||||
} else if controller.active_screen == .Panels {
|
||||
retry, new_cursor := draw_panels_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.panel_cursor)
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if summary_opts.panel_cursor != new_cursor {
|
||||
summary_opts.panel_cursor = new_cursor
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
if retry {
|
||||
panel, _, ok := panel_by_flat_index(controller.state.script, summary_opts.panel_cursor)
|
||||
if ok {
|
||||
msg := action_regenerate_panel(&controller, panel.panel_id)
|
||||
push_status(&status_msg, &action_log, msg)
|
||||
}
|
||||
}
|
||||
|
||||
wheel := rl.GetMouseWheelMove()
|
||||
if wheel != 0 && panel_count > 0 {
|
||||
summary_opts.panel_cursor -= int(wheel)
|
||||
if summary_opts.panel_cursor < 0 {
|
||||
summary_opts.panel_cursor = 0
|
||||
}
|
||||
if summary_opts.panel_cursor >= panel_count {
|
||||
summary_opts.panel_cursor = panel_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
|
||||
draw_text_fitted("Ctrl+[ / Ctrl+] panel nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY)
|
||||
} else {
|
||||
draw_card(rl.Rectangle{x = f32(log_x_loop), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-2), height = 200})
|
||||
draw_section_title(log_x_loop+18, lower_y_loop+6, "Action Log")
|
||||
draw_subtle_strip(rl.Rectangle{x = f32(log_x_loop+12), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-26), height = 40})
|
||||
draw_small_button(log_reset_btn, "Default")
|
||||
draw_small_button(report_file_btn, "Report")
|
||||
draw_small_button(log_copy_btn, "LogCopy")
|
||||
draw_small_button(diag_file_btn, "DiagFile")
|
||||
draw_small_button(status_copy_btn, "Copy")
|
||||
draw_small_button(log_clear_btn, "Clear")
|
||||
draw_small_button(diag_copy_btn, "Diag")
|
||||
order_label := "newest"
|
||||
if log_oldest_first {
|
||||
order_label = "oldest"
|
||||
}
|
||||
draw_text_fitted(fmt.tprintf("View: %d lines, %s first", log_show_lines, order_label), log_x_loop+18, lower_y_loop+36, 13, 216, 7, TEXT_TERTIARY)
|
||||
draw_action_log(action_log, log_x_loop+18, lower_y_loop+52, log_show_lines, log_oldest_first)
|
||||
}
|
||||
draw_toast(action_log, log_x_loop+8, 70, main_w_loop-status_w_loop-18)
|
||||
if show_help_overlay {
|
||||
draw_help_overlay()
|
||||
}
|
||||
if show_confirm_overlay {
|
||||
draw_confirm_overlay(pending_confirm_action)
|
||||
draw_button_danger(confirm_yes_btn, "Confirm")
|
||||
draw_button_soft_accent(confirm_no_btn, "Cancel")
|
||||
}
|
||||
|
||||
rl.EndDrawing()
|
||||
}
|
||||
|
||||
core.dispose_state(state)
|
||||
state^ = controller.state
|
||||
controller.state = core.Comic_State{}
|
||||
return shared.ok()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
import "../ui"
|
||||
|
||||
set_status :: proc(status: ^string, msg: string) {
|
||||
delete(status^)
|
||||
status^ = fmt.aprintf("%s", msg)
|
||||
}
|
||||
|
||||
push_status :: proc(status: ^string, log: ^Action_Log, msg: string) {
|
||||
set_status(status, msg)
|
||||
action_log_push(log, status^)
|
||||
}
|
||||
|
||||
push_status_if_nonempty :: proc(status: ^string, log: ^Action_Log, msg: string) {
|
||||
if len(msg) > 0 {
|
||||
push_status(status, log, msg)
|
||||
}
|
||||
}
|
||||
|
||||
push_dirty_status :: proc(is_dirty: ^bool, status: ^string, log: ^Action_Log, msg: string) {
|
||||
is_dirty^ = true
|
||||
push_status(status, log, msg)
|
||||
}
|
||||
|
||||
screen_status_label :: proc(screen: ui.App_Screen) -> string {
|
||||
switch screen {
|
||||
case .Story: return "Story"
|
||||
case .Script: return "Script"
|
||||
case .Characters: return "Characters"
|
||||
case .Panels: return "Panels"
|
||||
case .Layout: return "Layout"
|
||||
case .Bubbles: return "Bubbles"
|
||||
case .Export: return "Export"
|
||||
case .Community: return "Community"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
navigate_screen_with_status :: proc(controller: ^ui.App_Controller, screen: ui.App_Screen) -> string {
|
||||
err := ui.navigate_to_screen(controller, screen)
|
||||
if !shared.is_ok(err) {
|
||||
return err.message
|
||||
}
|
||||
return fmt.aprintf("Screen: %s", screen_status_label(screen))
|
||||
}
|
||||
|
||||
request_confirmation :: proc(show_confirm_overlay, show_help_overlay: ^bool, pending_confirm_action: ^Pending_Confirm_Action, action: Pending_Confirm_Action, prompt: string) -> string {
|
||||
show_confirm_overlay^ = true
|
||||
show_help_overlay^ = false
|
||||
pending_confirm_action^ = action
|
||||
return prompt
|
||||
}
|
||||
|
||||
toggle_autosave_with_message :: proc(autosave_enabled: ^bool) -> string {
|
||||
autosave_enabled^ = !autosave_enabled^
|
||||
return fmt.aprintf("Autosave: %s", yn(autosave_enabled^))
|
||||
}
|
||||
|
||||
reset_helper_fields_with_message :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) -> string {
|
||||
reset_helper_fields(export_path, local_script_pages, autosave_interval_text, f)
|
||||
return "Reset helper fields to defaults"
|
||||
}
|
||||
|
||||
toggle_help_overlay :: proc(show_help_overlay: ^bool) {
|
||||
show_help_overlay^ = !show_help_overlay^
|
||||
}
|
||||
|
||||
close_help_overlay_if_open :: proc(show_help_overlay: ^bool) {
|
||||
if show_help_overlay^ {
|
||||
show_help_overlay^ = false
|
||||
}
|
||||
}
|
||||
|
||||
clear_action_log_with_message :: proc(log: ^Action_Log) -> string {
|
||||
action_log_dispose(log)
|
||||
return "Action log cleared"
|
||||
}
|
||||
|
||||
reset_log_view :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) {
|
||||
log_show_lines^ = 6
|
||||
log_oldest_first^ = false
|
||||
}
|
||||
|
||||
reset_log_view_with_message :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) -> string {
|
||||
reset_log_view(log_show_lines, log_oldest_first)
|
||||
return "Reset log view"
|
||||
}
|
||||
|
||||
toggle_log_lines_with_message :: proc(log_show_lines: ^i32) -> string {
|
||||
if log_show_lines^ == 6 {
|
||||
log_show_lines^ = 4
|
||||
} else {
|
||||
log_show_lines^ = 6
|
||||
}
|
||||
return fmt.aprintf("Log lines: %d", log_show_lines^)
|
||||
}
|
||||
|
||||
toggle_log_order_with_message :: proc(log_oldest_first: ^bool) -> string {
|
||||
log_oldest_first^ = !log_oldest_first^
|
||||
order := "newest"
|
||||
if log_oldest_first^ {
|
||||
order = "oldest"
|
||||
}
|
||||
return fmt.aprintf("Log order: %s first", order)
|
||||
}
|
||||
|
||||
selected_field_value :: proc(selected_field: int, state: core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: string) -> string {
|
||||
switch selected_field {
|
||||
case 0: return state.story_idea
|
||||
case 1: return state.story_genre
|
||||
case 2: return state.target_audience
|
||||
case 3: return export_path
|
||||
case 4: return local_script_pages
|
||||
case 5: return project_path
|
||||
case 6: return autosave_interval_text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
clear_selected_field :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string) -> bool {
|
||||
switch selected_field {
|
||||
case 0:
|
||||
if len(state.story_idea) == 0 { return false }
|
||||
state.story_idea = ""
|
||||
case 1:
|
||||
if len(state.story_genre) == 0 { return false }
|
||||
state.story_genre = ""
|
||||
case 2:
|
||||
if len(state.target_audience) == 0 { return false }
|
||||
state.target_audience = ""
|
||||
case 3:
|
||||
if len(export_path^) == 0 { return false }
|
||||
export_path^ = ""
|
||||
case 4:
|
||||
if len(local_script_pages^) == 0 { return false }
|
||||
local_script_pages^ = ""
|
||||
case 5:
|
||||
if len(project_path^) == 0 { return false }
|
||||
project_path^ = ""
|
||||
case 6:
|
||||
if len(autosave_interval_text^) == 0 { return false }
|
||||
autosave_interval_text^ = ""
|
||||
case:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
clear_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string {
|
||||
if clear_selected_field(selected_field, state, export_path, local_script_pages, project_path, autosave_interval_text) {
|
||||
is_dirty^ = true
|
||||
return "Cleared selected field"
|
||||
}
|
||||
return "Selected field already empty"
|
||||
}
|
||||
|
||||
paste_clipboard_into_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string {
|
||||
clip_raw := rl.GetClipboardText()
|
||||
if clip_raw == nil {
|
||||
return ""
|
||||
}
|
||||
clip_text := fmt.aprintf("%s", clip_raw)
|
||||
switch selected_field {
|
||||
case 0: state.story_idea = clip_text
|
||||
case 1: state.story_genre = clip_text
|
||||
case 2: state.target_audience = clip_text
|
||||
case 3: export_path^ = clip_text
|
||||
case 4: local_script_pages^ = clip_text
|
||||
case 5: project_path^ = clip_text
|
||||
case 6: autosave_interval_text^ = clip_text
|
||||
}
|
||||
is_dirty^ = true
|
||||
return "Pasted clipboard into selected field"
|
||||
}
|
||||
|
||||
Action_Log :: struct {
|
||||
entries: [8]string,
|
||||
owned: [8]bool,
|
||||
entry_times: [8]f64,
|
||||
count: int,
|
||||
last_push_at: f64,
|
||||
}
|
||||
|
||||
action_log_push :: proc(log: ^Action_Log, msg: string) {
|
||||
now := rl.GetTime()
|
||||
idx := log.count % len(log.entries)
|
||||
if log.owned[idx] {
|
||||
delete(log.entries[idx])
|
||||
}
|
||||
log.entries[idx] = fmt.aprintf("%s", msg)
|
||||
log.owned[idx] = true
|
||||
log.entry_times[idx] = now
|
||||
log.count += 1
|
||||
log.last_push_at = now
|
||||
}
|
||||
|
||||
action_log_dispose :: proc(log: ^Action_Log) {
|
||||
for i in 0..<len(log.entries) {
|
||||
if log.owned[i] {
|
||||
delete(log.entries[i])
|
||||
log.owned[i] = false
|
||||
}
|
||||
log.entry_times[i] = 0
|
||||
}
|
||||
log.count = 0
|
||||
log.last_push_at = 0
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
import "../core"
|
||||
import "../ui"
|
||||
|
||||
draw_stat_chip :: proc(x, y: i32, label: string, value: int) {
|
||||
rec := rl.Rectangle{x = f32(x), y = f32(y), width = 80, height = 28}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, CHIP_BG)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, CHIP_BORDER)
|
||||
draw_text_fitted(label, x+10, y+8, 12, 40, 6, CHIP_TEXT)
|
||||
draw_text_fitted(fmt.tprintf("%d", value), x+56, y+6, 14, 20, 7, TEXT_PRIMARY)
|
||||
}
|
||||
|
||||
draw_readiness_chip :: proc(x, y, w: i32, label: string, ok: bool) {
|
||||
bg := UNREADY_BG
|
||||
border := UNREADY_BORDER
|
||||
fg := UNREADY_TEXT
|
||||
if ok {
|
||||
bg = READY_BG
|
||||
border = READY_BORDER
|
||||
fg = READY_TEXT
|
||||
}
|
||||
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 26}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, border)
|
||||
prefix := "○"
|
||||
if ok {
|
||||
prefix = "●"
|
||||
}
|
||||
draw_text_fitted(fmt.tprintf("%s %s", prefix, label), x+10, y+7, 12, int(w-20), 6, fg)
|
||||
}
|
||||
|
||||
ready_stage_count :: proc(controller: ui.App_Controller) -> (ready: int, total: int) {
|
||||
script_ok := len(controller.state.script.pages) > 0
|
||||
panels_ok := len(controller.state.panel_images) > 0
|
||||
layout_ok := len(controller.state.page_layouts) > 0
|
||||
export_ok := panels_ok && layout_ok
|
||||
ready = 0
|
||||
if script_ok { ready += 1 }
|
||||
if panels_ok { ready += 1 }
|
||||
if layout_ok { ready += 1 }
|
||||
if export_ok { ready += 1 }
|
||||
return ready, 4
|
||||
}
|
||||
|
||||
draw_progress_bar :: proc(x, y, w: i32, progress: f32) {
|
||||
track := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 12}
|
||||
rl.DrawRectangleRounded(track, RADIUS_BAR, 8, PROGRESS_TRACK)
|
||||
fill_w := i32(f32(w) * progress)
|
||||
if fill_w > 0 {
|
||||
fill := rl.Rectangle{x = f32(x), y = f32(y), width = f32(fill_w), height = 12}
|
||||
rl.DrawRectangleRounded(fill, RADIUS_BAR, 8, PROGRESS_FILL)
|
||||
}
|
||||
}
|
||||
|
||||
draw_readiness_row :: proc(controller: ui.App_Controller, x, y: i32) {
|
||||
script_ok := len(controller.state.script.pages) > 0
|
||||
panels_ok := len(controller.state.panel_images) > 0
|
||||
layout_ok := len(controller.state.page_layouts) > 0
|
||||
export_ok := panels_ok && layout_ok
|
||||
draw_readiness_chip(x, y, 120, "Script", script_ok)
|
||||
draw_readiness_chip(x+126, y, 120, "Panels", panels_ok)
|
||||
draw_readiness_chip(x+252, y, 120, "Layout", layout_ok)
|
||||
draw_readiness_chip(x+378, y, 120, "Export", export_ok)
|
||||
}
|
||||
|
||||
export_block_reason :: proc(state: core.Comic_State) -> string {
|
||||
if len(state.panel_images) == 0 && len(state.page_layouts) == 0 {
|
||||
return "need panels + layout"
|
||||
}
|
||||
if len(state.panel_images) == 0 {
|
||||
return "need panels"
|
||||
}
|
||||
if len(state.page_layouts) == 0 {
|
||||
return "need layout"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
draw_screen_summary :: proc(controller: ui.App_Controller, export_path: string, x, y, w: i32, opts: Summary_View_Options) {
|
||||
draw_card(rl.Rectangle{x = f32(x-18), y = f32(y-12), width = f32(w), height = 200})
|
||||
rl.DrawText("Screen Summary", x, y, 22, SUMMARY_TITLE)
|
||||
chip_base := x + w - 258
|
||||
if chip_base < x+210 {
|
||||
chip_base = x + 210
|
||||
}
|
||||
draw_stat_chip(chip_base, y-4, "Pages", len(controller.state.script.pages))
|
||||
draw_stat_chip(chip_base+86, y-4, "Panels", len(controller.state.panel_images))
|
||||
draw_stat_chip(chip_base+172, y-4, "Layout", len(controller.state.page_layouts))
|
||||
|
||||
switch controller.active_screen {
|
||||
case .Story:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Idea length: %d chars", len(controller.state.story_idea)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Genre: %s", controller.state.story_genre), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Audience: %s", controller.state.target_audience), rl.DARKGRAY)
|
||||
rl.DrawText("Use Generate Script Local to begin", x, y+112, 18, SUMMARY_HINT)
|
||||
case .Script:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Title: %s", controller.state.script.title), rl.DARKGRAY)
|
||||
page_count := len(controller.state.script.pages)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Pages: %d | Characters: %d", page_count, len(controller.state.script.characters)), rl.DARKGRAY)
|
||||
if page_count == 0 {
|
||||
rl.DrawText("No script pages yet. Generate Script Local to continue.", x, y+86, 18, SUMMARY_HINT)
|
||||
} else {
|
||||
cursor := opts.script_page_cursor
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
if cursor >= page_count {
|
||||
cursor = page_count - 1
|
||||
}
|
||||
page := controller.state.script.pages[cursor]
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Viewing page %d/%d (script page #%d)", cursor+1, page_count, page.page_number), SUMMARY_ACCENT)
|
||||
draw_summary_line(x, y+100, fmt.tprintf("Panels on page: %d", len(page.panels)), rl.DARKGRAY)
|
||||
line_y := y + 124
|
||||
show_panels := len(page.panels)
|
||||
if show_panels > 2 {
|
||||
show_panels = 2
|
||||
}
|
||||
for i in 0..<show_panels {
|
||||
pn := page.panels[i]
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
draw_summary_subline(x, line_y+i32(i*28), fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-36), 7), SUMMARY_SUBLINE)
|
||||
if len(pn.dialogue) > 0 {
|
||||
draw_summary_subline(x+12, line_y+i32(i*28)+14, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-54), 7), SUMMARY_DIM)
|
||||
}
|
||||
}
|
||||
if len(page.panels) > show_panels {
|
||||
draw_summary_subline(x+w-158, y+166, fmt.tprintf("+%d more panels", len(page.panels)-show_panels), SUMMARY_ACCENT)
|
||||
}
|
||||
}
|
||||
case .Characters:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Character count: %d", len(controller.state.characters)), rl.DARKGRAY)
|
||||
rl.DrawText("Character editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
|
||||
rl.DrawText("Use script generation to populate", x, y+78, 18, rl.DARKGRAY)
|
||||
case .Panels:
|
||||
script_panel_count := count_script_panels(controller.state.script)
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Panel images: %d", len(controller.state.panel_images)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Script panels: %d", script_panel_count), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Script pages: %d", len(controller.state.script.pages)), rl.DARKGRAY)
|
||||
if script_panel_count == 0 {
|
||||
rl.DrawText("No script panels yet. Generate Script first.", x, y+112, 18, SUMMARY_HINT)
|
||||
} else {
|
||||
pidx := clamp_panel_cursor(script_panel_count, opts.panel_cursor)
|
||||
panel, page_num, _ := panel_by_flat_index(controller.state.script, pidx)
|
||||
status := "missing"
|
||||
if _, has_img := controller.state.panel_images[panel.panel_id]; has_img {
|
||||
status = "ready"
|
||||
}
|
||||
draw_summary_line(x, y+102, fmt.tprintf("Viewing panel %d/%d • page %d # %d", pidx+1, script_panel_count, page_num, panel.panel_number), SUMMARY_ACCENT)
|
||||
draw_summary_subline(x, y+124, fmt.tprintf("%s • %s", panel.panel_id, status), SUMMARY_SUBLINE)
|
||||
}
|
||||
case .Layout:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Layout pages: %d", len(controller.state.page_layouts)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Page size: %v", controller.state.page_size), rl.DARKGRAY)
|
||||
layout_show := len(controller.state.page_layouts)
|
||||
if !opts.layout_show_all && layout_show > 3 { layout_show = 3 }
|
||||
if layout_show == 0 {
|
||||
rl.DrawText("No layouts yet. Use Layout Auto after panels are ready.", x, y+86, 18, SUMMARY_HINT)
|
||||
} else {
|
||||
if opts.layout_desc {
|
||||
for i in 0..<layout_show {
|
||||
idx := len(controller.state.page_layouts)-1-i
|
||||
l := controller.state.page_layouts[idx]
|
||||
draw_summary_line(x, y+78+i32(i*22), fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels)), rl.DARKGRAY)
|
||||
}
|
||||
} else {
|
||||
for i in 0..<layout_show {
|
||||
l := controller.state.page_layouts[i]
|
||||
draw_summary_line(x, y+78+i32(i*22), fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels)), rl.DARKGRAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .Bubbles:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Bubble maps: %d", len(controller.state.speech_bubbles)), rl.DARKGRAY)
|
||||
rl.DrawText("Bubble editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
|
||||
case .Export:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Format: %v", controller.state.export_format), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Layouts: %d | Panels: %d", len(controller.state.page_layouts), len(controller.state.panel_images)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Target: %s", export_path), rl.DARKGRAY)
|
||||
if len(controller.state.page_layouts) > 0 {
|
||||
last := controller.state.page_layouts[len(controller.state.page_layouts)-1]
|
||||
draw_summary_subline(x, y+102, fmt.tprintf("Last layout pattern: %s", last.pattern_id), SUMMARY_DIM)
|
||||
}
|
||||
reason := export_block_reason(controller.state)
|
||||
if len(reason) > 0 {
|
||||
draw_summary_line(x, y+124, fmt.tprintf("Export blocked: %s", reason), ERROR)
|
||||
} else {
|
||||
rl.DrawText("Use Export button or Ctrl+E", x, y+124, 18, SUMMARY_HINT)
|
||||
}
|
||||
case .Community:
|
||||
rl.DrawText("Community features coming soon", x, y+30, 18, rl.DARKGRAY)
|
||||
rl.DrawText("Current focus: local GUI workflows", x, y+54, 18, rl.DARKGRAY)
|
||||
}
|
||||
}
|
||||
|
||||
clamp_script_cursor :: proc(page_count, cursor: int) -> int {
|
||||
if page_count <= 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor < 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor >= page_count {
|
||||
return page_count - 1
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
clamp_panel_cursor :: proc(panel_count, cursor: int) -> int {
|
||||
if panel_count <= 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor < 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor >= panel_count {
|
||||
return panel_count - 1
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
panel_by_flat_index :: proc(script: core.Comic_Script, panel_idx: int) -> (core.Panel, int, bool) {
|
||||
if panel_idx < 0 {
|
||||
return core.Panel{}, 0, false
|
||||
}
|
||||
flat := 0
|
||||
for page in script.pages {
|
||||
for panel in page.panels {
|
||||
if flat == panel_idx {
|
||||
return panel, page.page_number, true
|
||||
}
|
||||
flat += 1
|
||||
}
|
||||
}
|
||||
return core.Panel{}, 0, false
|
||||
}
|
||||
|
||||
build_script_page_detail_text :: proc(state: core.Comic_State, cursor: int) -> string {
|
||||
page_count := len(state.script.pages)
|
||||
if page_count == 0 {
|
||||
return fmt.aprintf("No script pages available.")
|
||||
}
|
||||
idx := clamp_script_cursor(page_count, cursor)
|
||||
page := state.script.pages[idx]
|
||||
out := fmt.aprintf("Title: %s\nPage %d/%d (script page #%d)\nPanels: %d", state.script.title, idx+1, page_count, page.page_number, len(page.panels))
|
||||
for pn in page.panels {
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
next := fmt.aprintf("%s\n\nPanel %d [%v]\n%s", out, pn.panel_number, pn.shot_type, desc)
|
||||
delete(out)
|
||||
out = next
|
||||
for d in pn.dialogue {
|
||||
line := fmt.aprintf("%s\n- %s: %s", out, d.speaker_id, d.text)
|
||||
delete(out)
|
||||
out = line
|
||||
}
|
||||
if len(pn.caption) > 0 {
|
||||
line := fmt.aprintf("%s\n caption: %s", out, pn.caption)
|
||||
delete(out)
|
||||
out = line
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
build_full_script_text :: proc(state: core.Comic_State) -> string {
|
||||
page_count := len(state.script.pages)
|
||||
if page_count == 0 {
|
||||
return fmt.aprintf("No script pages available.")
|
||||
}
|
||||
out := fmt.aprintf("Title: %s\nSynopsis: %s\nCharacters: %d\nPages: %d", state.script.title, state.script.synopsis, len(state.script.characters), page_count)
|
||||
for page in state.script.pages {
|
||||
head := fmt.aprintf("%s\n\n=== Page %d (%d panels) ===", out, page.page_number, len(page.panels))
|
||||
delete(out)
|
||||
out = head
|
||||
for pn in page.panels {
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
row := fmt.aprintf("%s\nPanel %d: %s", out, pn.panel_number, desc)
|
||||
delete(out)
|
||||
out = row
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
draw_script_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) {
|
||||
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
|
||||
draw_section_title(x+18, y+6, "Script Detail")
|
||||
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count == 0 {
|
||||
draw_summary_line(x+18, y+46, "No script pages yet. Run Generate Script Local.", SUMMARY_HINT)
|
||||
return
|
||||
}
|
||||
idx := clamp_script_cursor(page_count, cursor)
|
||||
page := controller.state.script.pages[idx]
|
||||
draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d (#%d) • panels %d", idx+1, page_count, page.page_number, len(page.panels)), SUMMARY_ACCENT)
|
||||
line_y := y + 70
|
||||
line_step: i32 = 20
|
||||
line_max: i32 = (h - 84) / line_step
|
||||
lines_used: i32 = 0
|
||||
for pn in page.panels {
|
||||
if lines_used >= line_max {
|
||||
break
|
||||
}
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
draw_summary_subline(x+18, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-40), 7), SUMMARY_SUBLINE)
|
||||
lines_used += 1
|
||||
if len(pn.dialogue) > 0 && lines_used < line_max {
|
||||
draw_summary_subline(x+30, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-52), 7), SUMMARY_DIM)
|
||||
lines_used += 1
|
||||
}
|
||||
}
|
||||
if len(page.panels) > 0 && lines_used >= line_max {
|
||||
draw_summary_subline(x+w-140, y+h-18, "…more", SUMMARY_ACCENT)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
draw_panels_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) -> (retry_clicked: bool, new_cursor: int) {
|
||||
new_cursor = cursor
|
||||
retry_clicked = false
|
||||
|
||||
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
|
||||
draw_section_title(x+18, y+6, "Panel Results")
|
||||
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count == 0 {
|
||||
draw_summary_line(x+18, y+46, "No script panels yet. Generate Script first.", SUMMARY_HINT)
|
||||
return
|
||||
}
|
||||
idx := clamp_panel_cursor(panel_count, cursor)
|
||||
panel, page_num, ok := panel_by_flat_index(controller.state.script, idx)
|
||||
if !ok {
|
||||
draw_summary_line(x+18, y+46, "Panel index out of range.", ERROR)
|
||||
return
|
||||
}
|
||||
img, has_img := controller.state.panel_images[panel.panel_id]
|
||||
err_msg, has_err := controller.state.panel_errors[panel.panel_id]
|
||||
status := "missing"
|
||||
status_color := WARNING
|
||||
if has_err {
|
||||
status = "error"
|
||||
status_color = ERROR
|
||||
}
|
||||
if has_img {
|
||||
status = "ready"
|
||||
status_color = SUCCESS
|
||||
}
|
||||
draw_summary_line(x+18, y+46, fmt.tprintf("Panel %d/%d • page %d # %d • %s", idx+1, panel_count, page_num, panel.panel_number, status), status_color)
|
||||
|
||||
btn_label := "Regenerate"
|
||||
if status != "ready" {
|
||||
btn_label = "Generate"
|
||||
}
|
||||
btn_rec := rl.Rectangle{x = f32(x + w - 100), y = f32(y+42), width = 80, height = 24}
|
||||
draw_small_button_state(btn_rec, btn_label, true)
|
||||
if button_clicked(btn_rec) {
|
||||
retry_clicked = true
|
||||
}
|
||||
|
||||
draw_summary_subline(x+18, y+66, fit_text_for_width(fmt.tprintf("id: %s", panel.panel_id), int(w-120), 7), SUMMARY_SUBLINE)
|
||||
if has_err {
|
||||
draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("err: %s", err_msg), int(w-36), 7), ERROR)
|
||||
} else if has_img {
|
||||
draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("img: %dx%d seed:%d", img.width, img.height, img.seed), int(w-36), 7), SUMMARY_DIM)
|
||||
} else {
|
||||
draw_summary_subline(x+18, y+84, "img: not generated", SUMMARY_DIM)
|
||||
}
|
||||
desc := panel.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
draw_summary_subline(x+18, y+104, fit_text_for_width(fmt.tprintf("desc: %s", desc), int(w-36), 7), SUMMARY_SUBLINE)
|
||||
if has_img {
|
||||
draw_summary_subline(x+18, y+124, fit_text_for_width(fmt.tprintf("src: %s", img.url), int(w-36), 7), SUMMARY_DIM)
|
||||
}
|
||||
|
||||
list_y := y + 146
|
||||
row_h: i32 = 18
|
||||
rows: i32 = (h - 154) / row_h
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
start := idx - int(rows/2)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + int(rows)
|
||||
if end > panel_count {
|
||||
end = panel_count
|
||||
start = end - int(rows)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
line: i32 = 0
|
||||
for i in start..<end {
|
||||
row_panel, row_page, row_ok := panel_by_flat_index(controller.state.script, i)
|
||||
if !row_ok {
|
||||
continue
|
||||
}
|
||||
mark := " "
|
||||
row_color := TEXT_TERTIARY
|
||||
if i == idx {
|
||||
mark = ">"
|
||||
row_color = TEXT_PRIMARY
|
||||
}
|
||||
ready := "missing"
|
||||
if _, err_exists := controller.state.panel_errors[row_panel.panel_id]; err_exists {
|
||||
ready = "error"
|
||||
}
|
||||
if _, exists := controller.state.panel_images[row_panel.panel_id]; exists {
|
||||
ready = "ready"
|
||||
}
|
||||
|
||||
row_rec := rl.Rectangle{x = f32(x+18), y = f32(list_y+line*row_h), width = f32(w-36), height = f32(row_h)}
|
||||
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && rl.IsMouseButtonPressed(.LEFT) {
|
||||
new_cursor = i
|
||||
}
|
||||
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && row_color == TEXT_TERTIARY {
|
||||
row_color = TEXT_SECONDARY
|
||||
}
|
||||
|
||||
draw_summary_subline(x+18, list_y+line*row_h+2, fit_text_for_width(fmt.tprintf("%s %02d p%d#%d %s", mark, i+1, row_page, row_panel.panel_number, ready), int(w-36), 7), row_color)
|
||||
line += 1
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
|
||||
fit_text_for_width :: proc(text: string, width_px, px_per_char: int) -> string {
|
||||
if px_per_char <= 0 {
|
||||
return text
|
||||
}
|
||||
max_chars := width_px / px_per_char
|
||||
if max_chars < 4 {
|
||||
max_chars = 4
|
||||
}
|
||||
if len(text) <= max_chars {
|
||||
return text
|
||||
}
|
||||
return fmt.tprintf("%s…", text[:max_chars-1])
|
||||
}
|
||||
|
||||
draw_text_fitted :: proc(text: string, x, y, font_size: i32, width_px, px_per_char: int, color: rl.Color) {
|
||||
display := fit_text_for_width(text, width_px, px_per_char)
|
||||
rl.DrawText(fmt.ctprintf("%s", display), x, y, font_size, color)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package gui
|
||||
|
||||
import rl "vendor:raylib"
|
||||
|
||||
// ── Backgrounds ──────────────────────────────────────────────────────────
|
||||
BG_BASE :: rl.Color{13, 13, 18, 255}
|
||||
BG_SIDEBAR :: rl.Color{18, 18, 24, 255}
|
||||
BG_TOPBAR :: rl.Color{18, 18, 24, 255}
|
||||
BG_CARD :: rl.Color{24, 24, 32, 255}
|
||||
BG_CARD_ALT :: rl.Color{28, 28, 38, 255} // slightly elevated card
|
||||
BG_STRIP :: rl.Color{22, 22, 30, 255} // subtle strip background
|
||||
BG_OVERLAY :: rl.Color{8, 8, 12, 180} // modal backdrop
|
||||
|
||||
// ── Borders ──────────────────────────────────────────────────────────────
|
||||
BORDER_CARD :: rl.Color{40, 40, 52, 255}
|
||||
BORDER_SUBTLE :: rl.Color{36, 36, 48, 255}
|
||||
BORDER_DIVIDER :: rl.Color{36, 36, 48, 255}
|
||||
|
||||
// ── Accent (Indigo-Violet) ───────────────────────────────────────────────
|
||||
ACCENT :: rl.Color{99, 102, 241, 255}
|
||||
ACCENT_HOVER :: rl.Color{120, 122, 248, 255}
|
||||
ACCENT_MUTED :: rl.Color{68, 70, 180, 255}
|
||||
ACCENT_SURFACE :: rl.Color{30, 30, 56, 255}
|
||||
ACCENT_GLOW :: rl.Color{99, 102, 241, 80}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────
|
||||
TEXT_PRIMARY :: rl.Color{228, 228, 240, 255}
|
||||
TEXT_SECONDARY :: rl.Color{148, 148, 168, 255}
|
||||
TEXT_TERTIARY :: rl.Color{98, 98, 118, 255}
|
||||
TEXT_DISABLED :: rl.Color{68, 68, 88, 255}
|
||||
TEXT_BRIGHT :: rl.Color{245, 245, 255, 255}
|
||||
|
||||
// ── Semantic: Success ────────────────────────────────────────────────────
|
||||
SUCCESS :: rl.Color{52, 211, 153, 255}
|
||||
SUCCESS_BG :: rl.Color{16, 42, 32, 255}
|
||||
SUCCESS_BORDER :: rl.Color{40, 100, 74, 255}
|
||||
SUCCESS_TEXT :: rl.Color{110, 231, 183, 255}
|
||||
|
||||
// ── Semantic: Warning ────────────────────────────────────────────────────
|
||||
WARNING :: rl.Color{251, 191, 36, 255}
|
||||
WARNING_BG :: rl.Color{50, 38, 14, 255}
|
||||
WARNING_BORDER :: rl.Color{120, 90, 30, 255}
|
||||
WARNING_TEXT :: rl.Color{253, 224, 120, 255}
|
||||
|
||||
// ── Semantic: Error ──────────────────────────────────────────────────────
|
||||
ERROR :: rl.Color{248, 113, 113, 255}
|
||||
ERROR_BG :: rl.Color{50, 18, 18, 255}
|
||||
ERROR_BORDER :: rl.Color{120, 50, 50, 255}
|
||||
ERROR_TEXT :: rl.Color{254, 178, 178, 255}
|
||||
|
||||
// ── Semantic: Danger (destructive buttons) ───────────────────────────────
|
||||
DANGER_BG :: rl.Color{153, 50, 58, 255}
|
||||
DANGER_BG_HOVER :: rl.Color{172, 60, 68, 255}
|
||||
DANGER_BORDER :: rl.Color{130, 42, 48, 255}
|
||||
|
||||
// ── Semantic: Warning-style buttons ──────────────────────────────────────
|
||||
WARN_BTN_BG :: rl.Color{80, 64, 30, 255}
|
||||
WARN_BTN_BG_HOVER :: rl.Color{95, 76, 36, 255}
|
||||
WARN_BTN_BORDER :: rl.Color{110, 88, 40, 255}
|
||||
WARN_BTN_TEXT :: rl.Color{253, 224, 120, 255}
|
||||
|
||||
// ── Buttons ──────────────────────────────────────────────────────────────
|
||||
BTN_BG :: rl.Color{32, 32, 44, 255}
|
||||
BTN_BG_HOVER :: rl.Color{40, 40, 54, 255}
|
||||
BTN_BORDER :: rl.Color{52, 52, 68, 255}
|
||||
BTN_BORDER_HOVER :: rl.Color{80, 82, 140, 255}
|
||||
BTN_TEXT :: TEXT_PRIMARY
|
||||
|
||||
BTN_SOFT_BG :: rl.Color{28, 30, 48, 255}
|
||||
BTN_SOFT_BG_HOVER :: rl.Color{36, 38, 58, 255}
|
||||
BTN_SOFT_BORDER :: rl.Color{60, 62, 100, 255}
|
||||
BTN_SOFT_TEXT :: rl.Color{160, 162, 220, 255}
|
||||
|
||||
BTN_DISABLED_BG :: rl.Color{24, 24, 32, 255}
|
||||
BTN_DISABLED_BORDER :: rl.Color{36, 36, 48, 255}
|
||||
BTN_DISABLED_TEXT :: rl.Color{68, 68, 88, 255}
|
||||
|
||||
// ── Small Buttons ────────────────────────────────────────────────────────
|
||||
SBTN_BG :: rl.Color{30, 30, 42, 255}
|
||||
SBTN_BG_HOVER :: rl.Color{40, 40, 54, 255}
|
||||
SBTN_BORDER :: rl.Color{50, 50, 66, 255}
|
||||
SBTN_BORDER_HOVER :: rl.Color{80, 82, 140, 255}
|
||||
SBTN_TEXT :: rl.Color{188, 188, 210, 255}
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
NAV_BG :: rl.Color{24, 24, 32, 255}
|
||||
NAV_BG_HOVER :: rl.Color{32, 32, 44, 255}
|
||||
NAV_BORDER :: rl.Color{40, 40, 52, 255}
|
||||
NAV_BORDER_HOVER :: rl.Color{70, 72, 120, 255}
|
||||
NAV_TEXT :: rl.Color{168, 168, 188, 255}
|
||||
NAV_ACTIVE_BG :: ACCENT
|
||||
NAV_ACTIVE_TEXT :: TEXT_BRIGHT
|
||||
NAV_ACTIVE_BAR :: rl.Color{180, 182, 255, 255}
|
||||
|
||||
// ── Input Fields ─────────────────────────────────────────────────────────
|
||||
INPUT_BG :: rl.Color{16, 16, 24, 255}
|
||||
INPUT_BORDER :: rl.Color{40, 40, 54, 255}
|
||||
INPUT_FOCUS_BG :: rl.Color{20, 20, 30, 255}
|
||||
INPUT_FOCUS_BORDER :: ACCENT
|
||||
INPUT_FOCUS_RING :: ACCENT_GLOW
|
||||
INPUT_TEXT :: TEXT_PRIMARY
|
||||
INPUT_TEXT_FOCUS :: TEXT_BRIGHT
|
||||
|
||||
// ── Chips & Pills ────────────────────────────────────────────────────────
|
||||
PILL_BG :: rl.Color{28, 28, 38, 255}
|
||||
PILL_BORDER :: rl.Color{44, 44, 58, 255}
|
||||
PILL_TEXT :: TEXT_SECONDARY
|
||||
|
||||
PILL_ACCENT_BG :: ACCENT_SURFACE
|
||||
PILL_ACCENT_BORDER :: ACCENT_MUTED
|
||||
PILL_ACCENT_TEXT :: rl.Color{180, 182, 255, 255}
|
||||
|
||||
CHIP_BG :: rl.Color{28, 28, 38, 255}
|
||||
CHIP_BORDER :: rl.Color{44, 44, 58, 255}
|
||||
CHIP_TEXT :: TEXT_SECONDARY
|
||||
|
||||
CHIP_ACCENT_BG :: ACCENT_SURFACE
|
||||
CHIP_ACCENT_BORDER :: ACCENT_MUTED
|
||||
CHIP_ACCENT_TEXT :: PILL_ACCENT_TEXT
|
||||
|
||||
// ── Status Badges ────────────────────────────────────────────────────────
|
||||
BADGE_OK_BG :: SUCCESS_BG
|
||||
BADGE_OK_BORDER :: SUCCESS_BORDER
|
||||
BADGE_OK_TEXT :: SUCCESS_TEXT
|
||||
BADGE_BAD_BG :: ERROR_BG
|
||||
BADGE_BAD_BORDER :: ERROR_BORDER
|
||||
BADGE_BAD_TEXT :: ERROR_TEXT
|
||||
|
||||
// ── Readiness Chips ──────────────────────────────────────────────────────
|
||||
READY_BG :: SUCCESS_BG
|
||||
READY_BORDER :: SUCCESS_BORDER
|
||||
READY_TEXT :: SUCCESS_TEXT
|
||||
UNREADY_BG :: rl.Color{28, 28, 38, 255}
|
||||
UNREADY_BORDER :: rl.Color{44, 44, 58, 255}
|
||||
UNREADY_TEXT :: TEXT_TERTIARY
|
||||
|
||||
// ── Progress Bar ─────────────────────────────────────────────────────────
|
||||
PROGRESS_TRACK :: rl.Color{28, 28, 40, 255}
|
||||
PROGRESS_FILL :: ACCENT
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────────────────
|
||||
TOAST_SUCCESS :: rl.Color{28, 120, 80, 235}
|
||||
TOAST_WARNING :: rl.Color{140, 100, 30, 235}
|
||||
TOAST_ERROR :: rl.Color{150, 50, 50, 235}
|
||||
TOAST_BORDER :: rl.Color{255, 255, 255, 40}
|
||||
TOAST_SHADOW :: rl.Color{0, 0, 0, 60}
|
||||
|
||||
// ── Action Log ───────────────────────────────────────────────────────────
|
||||
LOG_ROW_ALT :: rl.Color{22, 22, 30, 255}
|
||||
LOG_TEXT :: rl.Color{158, 158, 178, 255}
|
||||
|
||||
// ── Section Titles ───────────────────────────────────────────────────────
|
||||
SECTION_TITLE_COLOR :: rl.Color{148, 150, 210, 255}
|
||||
SECTION_UNDERLINE :: rl.Color{44, 44, 60, 255}
|
||||
|
||||
// ── Screen Summary ───────────────────────────────────────────────────────
|
||||
SUMMARY_TITLE :: rl.Color{170, 172, 230, 255}
|
||||
SUMMARY_ACCENT :: rl.Color{99, 140, 220, 255}
|
||||
SUMMARY_HINT :: rl.Color{120, 90, 200, 255}
|
||||
SUMMARY_SUBLINE :: rl.Color{128, 128, 148, 255}
|
||||
SUMMARY_DIM :: rl.Color{98, 98, 118, 255}
|
||||
|
||||
// ── Pipeline Stepper ─────────────────────────────────────────────────────
|
||||
STEP_DONE_FILL :: SUCCESS
|
||||
STEP_DONE_BORDER :: SUCCESS_BORDER
|
||||
STEP_TODO_FILL :: rl.Color{36, 36, 48, 255}
|
||||
STEP_TODO_BORDER :: rl.Color{52, 52, 68, 255}
|
||||
STEP_LINE_DONE :: rl.Color{40, 100, 74, 255}
|
||||
STEP_LINE_TODO :: rl.Color{40, 40, 52, 255}
|
||||
STEP_LABEL_DONE :: SUCCESS_TEXT
|
||||
STEP_LABEL_TODO :: TEXT_TERTIARY
|
||||
|
||||
// ── Help Overlay ─────────────────────────────────────────────────────────
|
||||
HELP_TITLE :: TEXT_BRIGHT
|
||||
HELP_SECTION :: rl.Color{130, 132, 210, 255}
|
||||
HELP_LINE :: TEXT_SECONDARY
|
||||
HELP_CLOSE :: rl.Color{170, 148, 240, 255}
|
||||
|
||||
// ── Confirm Overlay ──────────────────────────────────────────────────────
|
||||
CONFIRM_ACCENT :: ACCENT
|
||||
CONFIRM_TITLE :: TEXT_BRIGHT
|
||||
CONFIRM_BODY :: TEXT_SECONDARY
|
||||
CONFIRM_HINT :: rl.Color{170, 148, 240, 255}
|
||||
|
||||
// ── Sidebar Shortcuts ────────────────────────────────────────────────────
|
||||
SIDEBAR_TITLE :: rl.Color{130, 132, 200, 255}
|
||||
SIDEBAR_TEXT :: TEXT_TERTIARY
|
||||
SIDEBAR_FOOTER :: rl.Color{80, 80, 100, 255}
|
||||
|
||||
// ── Brand ────────────────────────────────────────────────────────────────
|
||||
BRAND_TITLE :: TEXT_BRIGHT
|
||||
BRAND_SUBTITLE :: TEXT_TERTIARY
|
||||
|
||||
// ── Roundness Constants ──────────────────────────────────────────────────
|
||||
RADIUS_CARD :: f32(0.14)
|
||||
RADIUS_BUTTON :: f32(0.32)
|
||||
RADIUS_PILL :: f32(0.50)
|
||||
RADIUS_INPUT :: f32(0.24)
|
||||
RADIUS_NAV :: f32(0.28)
|
||||
RADIUS_CHIP :: f32(0.42)
|
||||
RADIUS_BADGE :: f32(0.42)
|
||||
RADIUS_TOAST :: f32(0.40)
|
||||
RADIUS_BAR :: f32(0.60)
|
||||
|
||||
// ── Recommended Halo ─────────────────────────────────────────────────────
|
||||
RECOMMEND_HALO_FILL :: rl.Color{50, 50, 100, 255}
|
||||
RECOMMEND_HALO_BORDER :: rl.Color{120, 122, 248, 255}
|
||||
|
||||
// ── DeepSeek key missing ─────────────────────────────────────────────────
|
||||
KEY_MISSING_COLOR :: ERROR
|
||||
@@ -0,0 +1,17 @@
|
||||
package gui
|
||||
|
||||
Summary_View_Options :: struct {
|
||||
script_show_all: bool,
|
||||
script_desc: bool,
|
||||
script_page_cursor: int,
|
||||
panel_cursor: int,
|
||||
layout_show_all: bool,
|
||||
layout_desc: bool,
|
||||
layout_page_cursor: int,
|
||||
}
|
||||
|
||||
Pending_Confirm_Action :: enum {
|
||||
None,
|
||||
Reset_Project,
|
||||
Open_Project,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gui
|
||||
|
||||
import rl "vendor:raylib"
|
||||
|
||||
draw_card :: proc(rec: rl.Rectangle) {
|
||||
rl.DrawRectangleRounded(rec, RADIUS_CARD, 8, BG_CARD)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CARD, 8, 1.0, BORDER_CARD)
|
||||
}
|
||||
|
||||
draw_subtle_strip :: proc(rec: rl.Rectangle) {
|
||||
rl.DrawRectangleRounded(rec, 0.20, 8, BG_STRIP)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 8, 1.0, BORDER_SUBTLE)
|
||||
}
|
||||
|
||||
draw_hint_pill :: proc(rec: rl.Rectangle, label: string, accent: bool) {
|
||||
bg := PILL_BG
|
||||
border := PILL_BORDER
|
||||
fg := PILL_TEXT
|
||||
if accent {
|
||||
bg = PILL_ACCENT_BG
|
||||
border = PILL_ACCENT_BORDER
|
||||
fg = PILL_ACCENT_TEXT
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_PILL, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_PILL, 8, 1.0, border)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 13, int(rec.width)-16, 7, fg)
|
||||
}
|
||||
|
||||
draw_topbar_chip :: proc(rec: rl.Rectangle, label: string, accent: bool) {
|
||||
bg := CHIP_BG
|
||||
border := CHIP_BORDER
|
||||
fg := CHIP_TEXT
|
||||
if accent {
|
||||
bg = CHIP_ACCENT_BG
|
||||
border = CHIP_ACCENT_BORDER
|
||||
fg = CHIP_ACCENT_TEXT
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, border)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-16, 8, fg)
|
||||
}
|
||||
|
||||
draw_status_badge :: proc(rec: rl.Rectangle, label: string, ok: bool) {
|
||||
bg := BADGE_BAD_BG
|
||||
border := BADGE_BAD_BORDER
|
||||
fg := BADGE_BAD_TEXT
|
||||
if ok {
|
||||
bg = BADGE_OK_BG
|
||||
border = BADGE_OK_BORDER
|
||||
fg = BADGE_OK_TEXT
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BADGE, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BADGE, 8, 1.0, border)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 13, int(rec.width)-16, 7, fg)
|
||||
}
|
||||
|
||||
draw_section_title :: proc(x, y: i32, label: string) {
|
||||
draw_text_fitted(label, x, y, 17, 180, 8, SECTION_TITLE_COLOR)
|
||||
rl.DrawLine(x, y+20, x+180, y+20, SECTION_UNDERLINE)
|
||||
}
|
||||
|
||||
draw_summary_line :: proc(x, y: i32, text: string, c: rl.Color) {
|
||||
fg := c
|
||||
if int(c.r)+int(c.g)+int(c.b) < 260 {
|
||||
fg = TEXT_PRIMARY
|
||||
}
|
||||
draw_text_fitted(text, x, y, 18, 438, 8, fg)
|
||||
}
|
||||
|
||||
draw_summary_subline :: proc(x, y: i32, text: string, c: rl.Color) {
|
||||
fg := c
|
||||
if int(c.r)+int(c.g)+int(c.b) < 260 {
|
||||
fg = TEXT_SECONDARY
|
||||
}
|
||||
draw_text_fitted(text, x, y, 16, 438, 7, fg)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package shared
|
||||
|
||||
import "core:os"
|
||||
|
||||
Config :: struct {
|
||||
deepseek_api_key: string,
|
||||
deepseek_base_url: string,
|
||||
fal_api_key: string,
|
||||
project_root: string,
|
||||
}
|
||||
|
||||
load_config :: proc() -> Config {
|
||||
cfg := Config{
|
||||
deepseek_api_key = os.get_env("DEEPSEEK_API_KEY", context.temp_allocator),
|
||||
deepseek_base_url = os.get_env("DEEPSEEK_BASE_URL", context.temp_allocator),
|
||||
fal_api_key = os.get_env("FAL_API_KEY", context.temp_allocator),
|
||||
project_root = ".",
|
||||
}
|
||||
|
||||
if len(cfg.deepseek_base_url) == 0 {
|
||||
cfg.deepseek_base_url = "https://api.deepseek.com"
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
load_config_stub :: proc() -> Config {
|
||||
return load_config()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package shared
|
||||
|
||||
App_Error_Code :: enum {
|
||||
None,
|
||||
Config,
|
||||
Network,
|
||||
Rate_Limit,
|
||||
Validation,
|
||||
Generation,
|
||||
Export,
|
||||
Storage,
|
||||
}
|
||||
|
||||
App_Error :: struct {
|
||||
code: App_Error_Code,
|
||||
message: string,
|
||||
recoverable: bool,
|
||||
}
|
||||
|
||||
ok :: proc() -> App_Error {
|
||||
return App_Error{code = .None, message = "", recoverable = false}
|
||||
}
|
||||
|
||||
is_ok :: proc(err: App_Error) -> bool {
|
||||
return err.code == .None
|
||||
}
|
||||
|
||||
new_error :: proc(code: App_Error_Code, message: string, recoverable: bool) -> App_Error {
|
||||
return App_Error{code = code, message = message, recoverable = recoverable}
|
||||
}
|
||||
|
||||
config_error :: proc(message: string) -> App_Error {
|
||||
return new_error(.Config, message, false)
|
||||
}
|
||||
|
||||
network_error :: proc(message: string) -> App_Error {
|
||||
return new_error(.Network, message, true)
|
||||
}
|
||||
|
||||
rate_limit_error :: proc(message: string) -> App_Error {
|
||||
return new_error(.Rate_Limit, message, true)
|
||||
}
|
||||
|
||||
validation_error :: proc(message: string) -> App_Error {
|
||||
return new_error(.Validation, message, false)
|
||||
}
|
||||
|
||||
generation_error :: proc(message: string) -> App_Error {
|
||||
return new_error(.Generation, message, true)
|
||||
}
|
||||
|
||||
should_retry :: proc(err: App_Error) -> bool {
|
||||
if err.code == .None {
|
||||
return false
|
||||
}
|
||||
if err.code == .Rate_Limit || err.code == .Network {
|
||||
return true
|
||||
}
|
||||
return err.recoverable
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ui
|
||||
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
App_Controller :: struct {
|
||||
state: core.Comic_State,
|
||||
active_screen: App_Screen,
|
||||
jobs: Job_Manager,
|
||||
}
|
||||
|
||||
new_controller :: proc(state: core.Comic_State) -> App_Controller {
|
||||
screen := screen_from_workflow(state.workflow.current_step)
|
||||
return App_Controller{
|
||||
state = state,
|
||||
active_screen = screen,
|
||||
jobs = new_job_manager(),
|
||||
}
|
||||
}
|
||||
|
||||
navigate_to_screen :: proc(c: ^App_Controller, target: App_Screen) -> shared.App_Error {
|
||||
if !can_open_screen(c.state, target) {
|
||||
return shared.new_error(.Validation, "screen blocked by workflow guards", false)
|
||||
}
|
||||
c.active_screen = target
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
set_workflow_step :: proc(c: ^App_Controller, next: core.Workflow_Step) -> shared.App_Error {
|
||||
curr := c.state.workflow.current_step
|
||||
if !core.can_transition(curr, next) && curr != next {
|
||||
return shared.new_error(.Validation, "invalid workflow transition", false)
|
||||
}
|
||||
core.set_workflow_step(&c.state, next)
|
||||
c.active_screen = screen_from_workflow(next)
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
start_background_job :: proc(c: ^App_Controller, t: Job_Type, message: string) -> int {
|
||||
id := submit_job(&c.jobs, t, message)
|
||||
c.state.workflow.is_generating = true
|
||||
c.state.workflow.generation_progress = 0
|
||||
c.state.workflow.error_message = ""
|
||||
return id
|
||||
}
|
||||
|
||||
set_generation_progress :: proc(c: ^App_Controller, progress: f32) {
|
||||
c.state.workflow.generation_progress = progress
|
||||
}
|
||||
|
||||
finish_background_job :: proc(c: ^App_Controller, id: int, failed_message: string) -> shared.App_Error {
|
||||
if len(failed_message) > 0 {
|
||||
_ = mark_job_failed(&c.jobs, id, failed_message)
|
||||
c.state.workflow.error_message = failed_message
|
||||
c.state.workflow.is_generating = false
|
||||
return shared.new_error(.Generation, failed_message, true)
|
||||
}
|
||||
|
||||
_ = mark_job_completed(&c.jobs, id)
|
||||
if active_jobs_count(c.jobs) == 0 {
|
||||
c.state.workflow.is_generating = false
|
||||
c.state.workflow.generation_progress = 100
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
cancel_background_job :: proc(c: ^App_Controller, id: int) -> shared.App_Error {
|
||||
err := request_job_cancel(&c.jobs, id)
|
||||
if !shared.is_ok(err) {
|
||||
return err
|
||||
}
|
||||
if active_jobs_count(c.jobs) == 0 {
|
||||
c.state.workflow.is_generating = false
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ui
|
||||
|
||||
import "../core"
|
||||
|
||||
dispose_job_manager :: proc(m: ^Job_Manager) {
|
||||
delete(m.jobs)
|
||||
m.jobs = nil
|
||||
m.next_id = 1
|
||||
}
|
||||
|
||||
dispose_controller :: proc(c: ^App_Controller) {
|
||||
dispose_job_manager(&c.jobs)
|
||||
core.dispose_state(&c.state)
|
||||
}
|
||||
|
||||
dispose_controller_owned :: proc(c: ^App_Controller) {
|
||||
dispose_job_manager(&c.jobs)
|
||||
core.dispose_state_owned(&c.state)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ui
|
||||
|
||||
import "../shared"
|
||||
|
||||
Job_Type :: enum {
|
||||
Generate_Script,
|
||||
Generate_Character,
|
||||
Generate_Panel,
|
||||
Export,
|
||||
}
|
||||
|
||||
Job_Status :: enum {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
Background_Job :: struct {
|
||||
id: int,
|
||||
type: Job_Type,
|
||||
status: Job_Status,
|
||||
message: string,
|
||||
cancel_requested: bool,
|
||||
}
|
||||
|
||||
Job_Manager :: struct {
|
||||
next_id: int,
|
||||
jobs: [dynamic]Background_Job,
|
||||
}
|
||||
|
||||
new_job_manager :: proc() -> Job_Manager {
|
||||
return Job_Manager{next_id = 1}
|
||||
}
|
||||
|
||||
submit_job :: proc(m: ^Job_Manager, t: Job_Type, message: string) -> int {
|
||||
id := m.next_id
|
||||
m.next_id += 1
|
||||
append(&m.jobs, Background_Job{id = id, type = t, status = .Queued, message = message})
|
||||
return id
|
||||
}
|
||||
|
||||
job_index_by_id :: proc(m: ^Job_Manager, id: int) -> int {
|
||||
for j, i in m.jobs {
|
||||
if j.id == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
mark_job_running :: proc(m: ^Job_Manager, id: int) -> shared.App_Error {
|
||||
idx := job_index_by_id(m, id)
|
||||
if idx < 0 {
|
||||
return shared.new_error(.Generation, "job not found", true)
|
||||
}
|
||||
m.jobs[idx].status = .Running
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
mark_job_completed :: proc(m: ^Job_Manager, id: int) -> shared.App_Error {
|
||||
idx := job_index_by_id(m, id)
|
||||
if idx < 0 {
|
||||
return shared.new_error(.Generation, "job not found", true)
|
||||
}
|
||||
if m.jobs[idx].cancel_requested {
|
||||
m.jobs[idx].status = .Cancelled
|
||||
} else {
|
||||
m.jobs[idx].status = .Completed
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
mark_job_failed :: proc(m: ^Job_Manager, id: int, message: string) -> shared.App_Error {
|
||||
idx := job_index_by_id(m, id)
|
||||
if idx < 0 {
|
||||
return shared.new_error(.Generation, "job not found", true)
|
||||
}
|
||||
m.jobs[idx].status = .Failed
|
||||
m.jobs[idx].message = message
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
request_job_cancel :: proc(m: ^Job_Manager, id: int) -> shared.App_Error {
|
||||
idx := job_index_by_id(m, id)
|
||||
if idx < 0 {
|
||||
return shared.new_error(.Generation, "job not found", true)
|
||||
}
|
||||
m.jobs[idx].cancel_requested = true
|
||||
if m.jobs[idx].status != .Completed && m.jobs[idx].status != .Failed {
|
||||
m.jobs[idx].status = .Cancelled
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
|
||||
active_jobs_count :: proc(m: Job_Manager) -> int {
|
||||
count := 0
|
||||
for j in m.jobs {
|
||||
if j.status == .Queued || j.status == .Running {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ui
|
||||
|
||||
import "../core"
|
||||
|
||||
can_open_screen :: proc(state: core.Comic_State, target: App_Screen) -> bool {
|
||||
switch target {
|
||||
case .Story:
|
||||
return true
|
||||
case .Script:
|
||||
return len(state.script.pages) > 0
|
||||
case .Characters:
|
||||
return len(state.script.characters) > 0
|
||||
case .Panels:
|
||||
return len(state.script.pages) > 0 && len(state.characters) > 0
|
||||
case .Layout:
|
||||
return len(state.panel_images) > 0
|
||||
case .Bubbles:
|
||||
return len(state.page_layouts) > 0
|
||||
case .Export:
|
||||
return len(state.page_layouts) > 0
|
||||
case .Community:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
next_step_for_screen :: proc(screen: App_Screen) -> core.Workflow_Step {
|
||||
switch screen {
|
||||
case .Story: return .Story_Input
|
||||
case .Script: return .Script_Review
|
||||
case .Characters: return .Character_Setup
|
||||
case .Panels: return .Generating_Panels
|
||||
case .Layout: return .Layout
|
||||
case .Bubbles: return .Speech_Bubbles
|
||||
case .Export: return .Complete
|
||||
case .Community: return .Complete
|
||||
}
|
||||
return .Story_Input
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ui
|
||||
|
||||
import "../core"
|
||||
import "../shared"
|
||||
|
||||
UI_Command_Kind :: enum {
|
||||
Navigate,
|
||||
Set_Workflow,
|
||||
Start_Generate,
|
||||
Set_Progress,
|
||||
Complete_Job,
|
||||
Fail_Job,
|
||||
Cancel_Job,
|
||||
}
|
||||
|
||||
UI_Command :: struct {
|
||||
kind: UI_Command_Kind,
|
||||
screen: App_Screen,
|
||||
workflow_step: core.Workflow_Step,
|
||||
job_type: Job_Type,
|
||||
job_id: int,
|
||||
progress: f32,
|
||||
message: string,
|
||||
}
|
||||
|
||||
UI_Runtime_Result :: struct {
|
||||
job_id: int,
|
||||
err: shared.App_Error,
|
||||
}
|
||||
|
||||
apply_command :: proc(c: ^App_Controller, cmd: UI_Command) -> UI_Runtime_Result {
|
||||
res := UI_Runtime_Result{job_id = 0, err = shared.ok()}
|
||||
switch cmd.kind {
|
||||
case .Navigate:
|
||||
res.err = navigate_to_screen(c, cmd.screen)
|
||||
case .Set_Workflow:
|
||||
res.err = set_workflow_step(c, cmd.workflow_step)
|
||||
case .Start_Generate:
|
||||
res.job_id = start_background_job(c, cmd.job_type, cmd.message)
|
||||
case .Set_Progress:
|
||||
set_generation_progress(c, cmd.progress)
|
||||
case .Complete_Job:
|
||||
res.err = finish_background_job(c, cmd.job_id, "")
|
||||
case .Fail_Job:
|
||||
res.err = finish_background_job(c, cmd.job_id, cmd.message)
|
||||
case .Cancel_Job:
|
||||
res.err = cancel_background_job(c, cmd.job_id)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
apply_commands :: proc(c: ^App_Controller, cmds: []UI_Command) -> shared.App_Error {
|
||||
for cmd in cmds {
|
||||
res := apply_command(c, cmd)
|
||||
if !shared.is_ok(res.err) {
|
||||
return res.err
|
||||
}
|
||||
}
|
||||
return shared.ok()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ui
|
||||
|
||||
import "../core"
|
||||
|
||||
App_Screen :: enum {
|
||||
Story,
|
||||
Script,
|
||||
Characters,
|
||||
Panels,
|
||||
Layout,
|
||||
Bubbles,
|
||||
Export,
|
||||
Community,
|
||||
}
|
||||
|
||||
screen_from_workflow :: proc(step: core.Workflow_Step) -> App_Screen {
|
||||
switch step {
|
||||
case .Story_Input, .Generating_Script:
|
||||
return .Story
|
||||
case .Script_Review:
|
||||
return .Script
|
||||
case .Character_Setup:
|
||||
return .Characters
|
||||
case .Generating_Panels:
|
||||
return .Panels
|
||||
case .Layout:
|
||||
return .Layout
|
||||
case .Speech_Bubbles:
|
||||
return .Bubbles
|
||||
case .Complete:
|
||||
return .Export
|
||||
}
|
||||
return .Story
|
||||
}
|
||||
|
||||
screen_name :: proc(s: App_Screen) -> string {
|
||||
switch s {
|
||||
case .Story: return "Story"
|
||||
case .Script: return "Script"
|
||||
case .Characters: return "Characters"
|
||||
case .Panels: return "Panels"
|
||||
case .Layout: return "Layout"
|
||||
case .Bubbles: return "Speech"
|
||||
case .Export: return "Export"
|
||||
case .Community: return "Community"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package ui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "../core"
|
||||
|
||||
render_header :: proc(c: App_Controller) -> string {
|
||||
mode := "Casual"
|
||||
if c.state.user_mode == .Professional {
|
||||
mode = "Professional"
|
||||
}
|
||||
return fmt.aprintf("[comic-odin] screen=%s step=%v mode=%s", screen_name(c.active_screen), c.state.workflow.current_step, mode)
|
||||
}
|
||||
|
||||
render_progress :: proc(state: core.Comic_State) -> string {
|
||||
if !state.workflow.is_generating {
|
||||
return "idle"
|
||||
}
|
||||
return fmt.aprintf("generating %.0f%%", state.workflow.generation_progress)
|
||||
}
|
||||
|
||||
render_story_view :: proc(state: core.Comic_State) -> string {
|
||||
idea := state.story_idea
|
||||
if len(idea) == 0 {
|
||||
idea = "(no story idea yet)"
|
||||
}
|
||||
return fmt.aprintf("Story\n- idea: %s\n- genre: %s\n- audience: %s", idea, state.story_genre, state.target_audience)
|
||||
}
|
||||
|
||||
render_script_view :: proc(state: core.Comic_State) -> string {
|
||||
return fmt.aprintf("Script\n- title: %s\n- pages: %d\n- characters: %d", state.script.title, len(state.script.pages), len(state.script.characters))
|
||||
}
|
||||
|
||||
render_characters_view :: proc(state: core.Comic_State) -> string {
|
||||
return fmt.aprintf("Characters\n- count: %d", len(state.characters))
|
||||
}
|
||||
|
||||
render_panels_view :: proc(state: core.Comic_State) -> string {
|
||||
return fmt.aprintf("Panels\n- generated images: %d", len(state.panel_images))
|
||||
}
|
||||
|
||||
render_layout_view :: proc(state: core.Comic_State) -> string {
|
||||
return fmt.aprintf("Layout\n- pages: %d", len(state.page_layouts))
|
||||
}
|
||||
|
||||
render_bubbles_view :: proc(state: core.Comic_State) -> string {
|
||||
return fmt.aprintf("Speech\n- panels with bubbles: %d", len(state.speech_bubbles))
|
||||
}
|
||||
|
||||
render_export_view :: proc(state: core.Comic_State) -> string {
|
||||
return fmt.aprintf("Export\n- page layouts: %d\n- format: %v", len(state.page_layouts), state.export_format)
|
||||
}
|
||||
|
||||
render_screen_body :: proc(c: App_Controller) -> string {
|
||||
switch c.active_screen {
|
||||
case .Story:
|
||||
return render_story_view(c.state)
|
||||
case .Script:
|
||||
return render_script_view(c.state)
|
||||
case .Characters:
|
||||
return render_characters_view(c.state)
|
||||
case .Panels:
|
||||
return render_panels_view(c.state)
|
||||
case .Layout:
|
||||
return render_layout_view(c.state)
|
||||
case .Bubbles:
|
||||
return render_bubbles_view(c.state)
|
||||
case .Export:
|
||||
return render_export_view(c.state)
|
||||
case .Community:
|
||||
return "Community\n- coming soon"
|
||||
}
|
||||
return "Unknown screen"
|
||||
}
|
||||
|
||||
render_status_bar :: proc(c: App_Controller) -> string {
|
||||
status := render_progress(c.state)
|
||||
if len(c.state.workflow.error_message) > 0 {
|
||||
status = fmt.aprintf("error: %s", c.state.workflow.error_message)
|
||||
}
|
||||
return fmt.aprintf("jobs=%d | %s", active_jobs_count(c.jobs), status)
|
||||
}
|
||||
|
||||
render_app_to_string :: proc(c: App_Controller) -> string {
|
||||
parts := [3]string{render_header(c), render_screen_body(c), render_status_bar(c)}
|
||||
out := strings.join(parts[:], "\n\n", context.allocator)
|
||||
for p in parts {
|
||||
delete(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user