check point

This commit is contained in:
dimitar committed 2026-05-21 06:10:32 +02:00
1 parent ae1cae967b
commit 1e85df5193
72 files changed
+11452

No files matched your search

+429
View File
@@ -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)
}
+177
View File
@@ -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)
}
+159
View File
@@ -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
}
+124
View File
@@ -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"
}
+151
View File
@@ -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)
}
+165
View File
@@ -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"
}
+973
View File
@@ -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()
}
+212
View File
@@ -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
}
+443
View File
@@ -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
}
+23
View File
@@ -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)
}
+210
View File
@@ -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
+17
View File
@@ -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,
}
+76
View File
@@ -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)
}