diff --git a/.github/workflows/odin-ci.yml b/.github/workflows/odin-ci.yml index 2882e03..19c0792 100644 --- a/.github/workflows/odin-ci.yml +++ b/.github/workflows/odin-ci.yml @@ -20,6 +20,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: true - name: Setup Odin uses: laytan/setup-odin@v2 @@ -30,7 +32,9 @@ jobs: run: ./build.sh - name: Test - run: odin test tests + run: | + CLAY_DIR="$(pwd)/vendor/clay/bindings/odin/clay-odin" + odin test tests -collection:clay="$CLAY_DIR" - name: Package run: ./scripts/package.sh diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b6458d1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "odin/vendor/clay"] + path = odin/vendor/clay + url = https://github.com/nicbarker/clay.git diff --git a/odin/build.sh b/odin/build.sh index 815eb3a..a336634 100755 --- a/odin/build.sh +++ b/odin/build.sh @@ -1,5 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLAY_DIR="$SCRIPT_DIR/vendor/clay/bindings/odin/clay-odin" + mkdir -p bin -odin build src/app -out:bin/comic_odin -debug +odin build src/app \ + -out:bin/comic_odin \ + -debug \ + -collection:clay="$CLAY_DIR" \ No newline at end of file diff --git a/odin/gui_project.comic.json b/odin/gui_project.comic.json index 0fc42ac..794f190 100644 --- a/odin/gui_project.comic.json +++ b/odin/gui_project.comic.json @@ -9,528 +9,31 @@ "last_modified_iso": "" }, "user_mode": 0, - "story_idea": "car race in night time tokyo", + "story_idea": "2 cars racing in down town newyork", "story_genre": "action", "target_audience": "general", "art_style": "manga", "script": { - "title": "Midnight Run", - "synopsis": "Generated comic synopsis", + "title": "", + "synopsis": "", "characters": [ ], "pages": [ - { - "page_number": 1, - "layout_type": 0, - "panels": [ - { - "panel_id": "panel_001_001", - "panel_number": 1, - "shot_type": 2, - "description": "Wide shot of Tokyo skyline at night, neon lights reflecting on wet streets. A sleek black Nissan GT-R and a red Mazda RX-7 are at a traffic light, engines revving.", - "characters_present": [ - ], - "dialogue": [ - - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_001_002", - "panel_number": 2, - "shot_type": 2, - "description": "Close-up of the drivers gripping their steering wheels. The black GT-R driver (Kenji) has a focused, intense expression. The red RX-7 driver (Ryo) smirks confidently.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "Ready to lose, Kenji?", - "bubble_type": 0, - "emotion": 4 - }, - { - "speaker_id": "", - "text": "You wish.", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_001_003", - "panel_number": 3, - "shot_type": 2, - "description": "The traffic light turns green. Both cars launch forward, tires screeching and leaving rubber marks. Speed lines emphasize acceleration.", - "characters_present": [ - - ], - "dialogue": [ - - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_001_004", - "panel_number": 4, - "shot_type": 2, - "description": "Shot from behind the cars as they speed through a tunnel, neon lights blurring. The GT-R is slightly ahead.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "Not bad, but I'm just warming up.", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_001_005", - "panel_number": 5, - "shot_type": 2, - "description": "The RX-7 drifts around a sharp corner, sparks flying from the exhaust. The GT-R follows closely.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "He's good...", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - } - ] - }, - { - "page_number": 2, - "layout_type": 0, - "panels": [ - { - "panel_id": "panel_002_001", - "panel_number": 1, - "shot_type": 2, - "description": "Both cars race side by side on a straight stretch of elevated highway. Tokyo tower is visible in the background.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "Time to end this!", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_002_002", - "panel_number": 2, - "shot_type": 2, - "description": "Ryo hits a nitrous boost. The RX-7 surges ahead, engine glowing red. Kenji's eyes widen.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "What?! Nitrous?", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_002_003", - "panel_number": 3, - "shot_type": 2, - "description": "Kenji shifts gears and his GT-R also boosts, catching up. Their front bumpers are almost touching.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "You're crazy!", - "bubble_type": 0, - "emotion": 4 - }, - { - "speaker_id": "", - "text": "Let's see who blinks first!", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_002_004", - "panel_number": 4, - "shot_type": 2, - "description": "An oncoming truck appears in the distance, its headlights blinding. Both cars are in the same lane.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "Truck!", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_002_005", - "panel_number": 5, - "shot_type": 2, - "description": "At the last second, Kenji swerves left, Ryo swerves right. They split around the truck, inches away. The truck honks loudly.", - "characters_present": [ - - ], - "dialogue": [ - - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_002_006", - "panel_number": 6, - "shot_type": 2, - "description": "Both cars cross the finish line (a banner on the road) simultaneously. They slow down, pulling over. Ryo and Kenji step out, panting.", - "characters_present": [ - - ], - "dialogue": [ - { - "speaker_id": "", - "text": "Tie.", - "bubble_type": 0, - "emotion": 4 - }, - { - "speaker_id": "", - "text": "Yeah. Next time, I'll win.", - "bubble_type": 0, - "emotion": 4 - }, - { - "speaker_id": "", - "text": "Keep dreaming.", - "bubble_type": 0, - "emotion": 4 - } - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - }, - { - "panel_id": "panel_002_007", - "panel_number": 7, - "shot_type": 2, - "description": "They share a grin. The city lights glow behind them. Final panel: their cars parked side by side under a streetlight.", - "characters_present": [ - - ], - "dialogue": [ - - ], - "caption": "", - "sound_effects": [ - - ], - "transition_from_previous": 0 - } - ] - } ] }, "characters": [ ], "panel_images": { - "panel_001_001": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_001_panel_001_001.png", - "width": 1024, - "height": 1024, - "seed": 1, - "prompt": "local panel 1" - }, - "panel_002_006": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_011_panel_002_006.png", - "width": 1024, - "height": 1024, - "seed": 11, - "prompt": "local panel 11" - }, - "panel_002_007": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_012_panel_002_007.png", - "width": 1024, - "height": 1024, - "seed": 12, - "prompt": "local panel 12" - }, - "panel_002_001": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_006_panel_002_001.png", - "width": 1024, - "height": 1024, - "seed": 6, - "prompt": "local panel 6" - }, - "panel_001_003": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_003_panel_001_003.png", - "width": 1024, - "height": 1024, - "seed": 3, - "prompt": "local panel 3" - }, - "panel_002_004": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_009_panel_002_004.png", - "width": 1024, - "height": 1024, - "seed": 9, - "prompt": "local panel 9" - }, - "panel_001_005": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_005_panel_001_005.png", - "width": 1024, - "height": 1024, - "seed": 5, - "prompt": "local panel 5" - }, - "panel_002_002": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_007_panel_002_002.png", - "width": 1024, - "height": 1024, - "seed": 7, - "prompt": "local panel 7" - }, - "panel_001_002": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_002_panel_001_002.png", - "width": 1024, - "height": 1024, - "seed": 2, - "prompt": "local panel 2" - }, - "panel_002_005": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_010_panel_002_005.png", - "width": 1024, - "height": 1024, - "seed": 10, - "prompt": "local panel 10" - }, - "panel_002_003": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_008_panel_002_003.png", - "width": 1024, - "height": 1024, - "seed": 8, - "prompt": "local panel 8" - }, - "panel_001_004": { - "url": "file:///tmp/comic-gui-local-panels-5031376420/panel_004_panel_001_004.png", - "width": 1024, - "height": 1024, - "seed": 4, - "prompt": "local panel 4" - } + }, "panel_errors": { }, "page_layouts": [ - { - "page_number": 1, - "pattern_id": "grid-2x2", - "panels": [ - { - "panel_id": "panel_001_001", - "panel_number": 1, - "layout_cell": { - "x": 0.02000000, - "y": 0.02000000, - "w": 0.47000000, - "h": 0.47000000 - } - }, - { - "panel_id": "panel_001_002", - "panel_number": 2, - "layout_cell": { - "x": 0.50999999, - "y": 0.02000000, - "w": 0.47000000, - "h": 0.47000000 - } - }, - { - "panel_id": "panel_001_003", - "panel_number": 3, - "layout_cell": { - "x": 0.02000000, - "y": 0.50999999, - "w": 0.47000000, - "h": 0.47000000 - } - }, - { - "panel_id": "panel_001_004", - "panel_number": 4, - "layout_cell": { - "x": 0.50999999, - "y": 0.50999999, - "w": 0.47000000, - "h": 0.47000000 - } - } - ], - "width": 2480, - "height": 3508 - }, - { - "page_number": 2, - "pattern_id": "dialogue-heavy", - "panels": [ - { - "panel_id": "panel_001_005", - "panel_number": 5, - "layout_cell": { - "x": 0.02000000, - "y": 0.02000000, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_001", - "panel_number": 1, - "layout_cell": { - "x": 0.50999999, - "y": 0.02000000, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_002", - "panel_number": 2, - "layout_cell": { - "x": 0.02000000, - "y": 0.25999999, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_003", - "panel_number": 3, - "layout_cell": { - "x": 0.50999999, - "y": 0.25999999, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_004", - "panel_number": 4, - "layout_cell": { - "x": 0.02000000, - "y": 0.50000000, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_005", - "panel_number": 5, - "layout_cell": { - "x": 0.50999999, - "y": 0.50000000, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_006", - "panel_number": 6, - "layout_cell": { - "x": 0.02000000, - "y": 0.74000001, - "w": 0.47000000, - "h": 0.22000000 - } - }, - { - "panel_id": "panel_002_007", - "panel_number": 7, - "layout_cell": { - "x": 0.50999999, - "y": 0.74000001, - "w": 0.47000000, - "h": 0.22000000 - } - } - ], - "width": 2480, - "height": 3508 - } + ], "speech_bubbles": { @@ -539,7 +42,7 @@ "page_size": 0, "color_profile": 0, "workflow": { - "current_step": 5, + "current_step": 0, "completed_steps": [ ], diff --git a/odin/ols.json b/odin/ols.json new file mode 100644 index 0000000..61acd19 --- /dev/null +++ b/odin/ols.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json", + "enable_document_symbols": true, + "enable_hover": true, + "enable_snippets": true, + "collections": [ + { + "name": "clay", + "path": "vendor/clay/bindings/odin/clay-odin" + } + ] +} \ No newline at end of file diff --git a/odin/scripts/package.sh b/odin/scripts/package.sh index cecb551..54a068a 100755 --- a/odin/scripts/package.sh +++ b/odin/scripts/package.sh @@ -14,7 +14,8 @@ echo "=> Building comic-odin v${VERSION} (${OS}-${ARCH})" ./build.sh echo "=> Running test suite" -odin test tests +CLAY_DIR="$ROOT_DIR/vendor/clay/bindings/odin/clay-odin" +odin test tests -collection:clay="$CLAY_DIR" mkdir -p dist PKG_NAME="comic-odin-${VERSION}-${OS}-${ARCH}" diff --git a/odin/src/gui/bubbles_views.odin b/odin/src/gui/bubbles_views.odin index c0081c5..965c3ef 100644 --- a/odin/src/gui/bubbles_views.odin +++ b/odin/src/gui/bubbles_views.odin @@ -1,9 +1,6 @@ package gui -import "core:fmt" -import rl "vendor:raylib" import "../core" -import "../ui" bubble_type_name :: proc(t: core.Bubble_Type) -> string { switch t { @@ -42,13 +39,6 @@ clamp_bubble_cursor :: proc(count, cursor: int) -> int { return cursor } -collect_layout_panels_for_page :: proc(layouts: []core.Page_Layout, page_cursor: int) -> []core.Page_Layout_Panel { - if len(layouts) == 0 || page_cursor < 0 || page_cursor >= len(layouts) { - return nil - } - return layouts[page_cursor].panels -} - count_bubbles_for_panel :: proc(bubbles: map[string][]core.Speech_Bubble, panel_id: string) -> int { if bubbles == nil { return 0 @@ -59,197 +49,9 @@ count_bubbles_for_panel :: proc(bubbles: map[string][]core.Speech_Bubble, panel_ return 0 } -draw_bubbles_detail_panel :: proc( - controller: ui.App_Controller, - x, y, w, h: i32, - page_cursor: int, - panel_cursor: int, - bubble_cursor: int, -) -> ( - add_clicked: bool, - delete_clicked: bool, - auto_place_clicked: bool, - new_page_cursor: int, - new_panel_cursor: int, - new_bubble_cursor: int, - edited_text: string, - edited_type: core.Bubble_Type, - type_changed: bool, - text_changed: bool, -) { - add_clicked = false - delete_clicked = false - auto_place_clicked = false - new_page_cursor = page_cursor - new_panel_cursor = panel_cursor - new_bubble_cursor = bubble_cursor - type_changed = false - text_changed = false - edited_text = "" - edited_type = .Normal - - draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)}) - draw_section_title(x+18, y+6, "Bubble Editor") - draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34}) - - layout_count := len(controller.state.page_layouts) - if layout_count == 0 { - draw_summary_line(x+18, y+46, "No layouts yet. Run Layout Auto first.", SUMMARY_HINT) - return +collect_layout_panels_for_page :: proc(layouts: []core.Page_Layout, page_cursor: int) -> []core.Page_Layout_Panel { + if len(layouts) == 0 || page_cursor < 0 || page_cursor >= len(layouts) { + return nil } - - page_idx := clamp_layout_cursor(layout_count, page_cursor) - if page_idx != page_cursor { - new_page_cursor = page_idx - } - layout := controller.state.page_layouts[page_idx] - panel_list := layout.panels - panel_count := len(panel_list) - - if panel_count == 0 { - draw_summary_line(x+18, y+46, fmt.tprintf("Page %d has no panels", layout.page_number), SUMMARY_HINT) - return - } - - // Page navigation - draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d • %d panels", page_idx+1, layout_count, panel_count), SUMMARY_ACCENT) - - panel_idx := clamp_bubble_cursor(panel_count, panel_cursor) - if panel_idx != panel_cursor { - new_panel_cursor = panel_idx - } - panel := panel_list[panel_idx] - - // Panel info + auto-place button - draw_summary_subline(x+18, y+66, fmt.tprintf("Panel %d (id: %s)", panel.panel_number, panel.panel_id), SUMMARY_SUBLINE) - - auto_btn_rec := rl.Rectangle{x = f32(x + w - 110), y = f32(y+62), width = 90, height = 22} - draw_small_button_state(auto_btn_rec, "Auto Place", true) - if button_clicked(auto_btn_rec) { - auto_place_clicked = true - } - - // Bubble list - bubble_map := controller.state.speech_bubbles - bubbles_for_panel: []core.Speech_Bubble = nil - if bubble_map != nil { - if slice, ok := bubble_map[panel.panel_id]; ok { - bubbles_for_panel = slice - } - } - bubble_count := len(bubbles_for_panel) - - bubble_idx := clamp_bubble_cursor(bubble_count, bubble_cursor) - if bubble_count > 0 && bubble_idx != bubble_cursor { - new_bubble_cursor = bubble_idx - } - - // Bubble list header - list_y := y + 90 - draw_summary_line(x+18, list_y, fmt.tprintf("Bubbles: %d", bubble_count), SUMMARY_ACCENT) - - // Add button - add_btn_rec := rl.Rectangle{x = f32(x + w - 70), y = f32(list_y-4), width = 50, height = 20} - draw_small_button_state(add_btn_rec, "Add", true) - if button_clicked(add_btn_rec) { - add_clicked = true - } - - // Bubble rows - row_start_y := list_y + 22 - row_h: i32 = 20 - max_rows: int = int(h - 120) / int(row_h) - if max_rows < 1 { - max_rows = 1 - } - - // Scroll window for bubble list - scroll_start := 0 - if bubble_idx >= max_rows { - scroll_start = bubble_idx - max_rows + 1 - } - scroll_end := scroll_start + max_rows - if scroll_end > bubble_count { - scroll_end = bubble_count - scroll_start = scroll_end - max_rows - if scroll_start < 0 { - scroll_start = 0 - } - } - - row: i32 = 0 - for i in scroll_start.. 30 { - preview = preview[:30] - preview = fmt.tprintf("%s...", preview) - } - if len(preview) == 0 { - preview = "(empty)" - } - draw_summary_subline(x+18, row_start_y+row*row_h+2, - fit_text_for_width(fmt.tprintf("%s [%s] %s", mark, bubble_type_name(b.type), preview), int(w-100), 7), row_color) - row += 1 - } - - // Selected bubble editor - if bubble_count > 0 && bubble_idx < bubble_count { - editor_y := row_start_y + row*row_h + 8 - if editor_y < y+h-80 { - selected := bubbles_for_panel[bubble_idx] - draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(editor_y-6), width = f32(w-24), height = 70}) - draw_summary_line(x+18, editor_y, fmt.tprintf("Editing: %s", bubble_type_name(selected.type)), SUMMARY_ACCENT) - - // Type selector buttons - type_y := editor_y + 20 - type_idx: i32 = 0 - types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect} - type_w: i32 = 60 - type_spacing: i32 = 4 - for t in types { - t_rec := rl.Rectangle{x = f32(x)+18+f32(int(type_idx)*int(type_w+type_spacing)), y = f32(type_y), width = f32(type_w), height = 18} - draw_nav_item(t_rec, bubble_type_name(t), selected.type == t) - if button_clicked(t_rec) && selected.type != t { - edited_type = t - type_changed = true - } - type_idx += 1 - } - - // Text preview - text_y := type_y + 22 - draw_summary_subline(x+18, text_y, fit_text_for_width(fmt.tprintf("text: %s", selected.text), int(w-36), 7), SUMMARY_SUBLINE) - } - } - - if bubble_count == 0 { - draw_summary_line(x+18, row_start_y, "No bubbles for this panel. Click Add or Auto Place.", SUMMARY_HINT) - } - - return -} + return layouts[page_cursor].panels[:] +} \ No newline at end of file diff --git a/odin/src/gui/clay_layout.odin b/odin/src/gui/clay_layout.odin new file mode 100644 index 0000000..222ad57 --- /dev/null +++ b/odin/src/gui/clay_layout.odin @@ -0,0 +1,409 @@ +package gui + +import clay "clay:." +import "base:runtime" +import "core:fmt" +import "core:math" +import "core:strings" +import rl "vendor:raylib" +import "../shared" + +// --- Font IDs (indices into raylib_fonts) --- +CLAY_FONT_BODY :: u16(0) +CLAY_FONT_TITLE :: u16(1) +CLAY_FONT_MONO :: u16(2) + +// --- Font registry (shared between layout and renderer) --- +Raylib_Font :: struct { + fontId: u16, + font: rl.Font, +} + +raylib_fonts: [dynamic]Raylib_Font + +// --- Clay Color Palette (modernized dark theme) --- +// Named with CLAY_ prefix to avoid collision with existing theme.odin + +CLAY_BG_BASE :: clay.Color{13, 13, 18, 255} +CLAY_BG_SIDEBAR :: clay.Color{18, 18, 26, 255} +CLAY_BG_TOPBAR :: clay.Color{22, 22, 32, 255} +CLAY_BG_CARD :: clay.Color{28, 28, 40, 255} +CLAY_BG_CARD_ALT :: clay.Color{24, 24, 36, 255} +CLAY_BG_STRIP :: clay.Color{32, 32, 46, 255} +CLAY_BG_OVERLAY :: clay.Color{0, 0, 0, 180} +CLAY_BG_INPUT :: clay.Color{20, 20, 30, 255} + +CLAY_BORDER_CARD :: clay.Color{50, 50, 70, 255} +CLAY_BORDER_SUBTLE :: clay.Color{40, 40, 58, 255} +CLAY_BORDER_DIVIDER :: clay.Color{36, 36, 52, 255} + +CLAY_ACCENT :: clay.Color{99, 102, 241, 255} +CLAY_ACCENT_HOVER :: clay.Color{124, 127, 255, 255} +CLAY_ACCENT_MUTED :: clay.Color{79, 70, 229, 200} +CLAY_ACCENT_SURFACE:: clay.Color{67, 56, 202, 40} +CLAY_ACCENT_GLOW :: clay.Color{99, 102, 241, 60} + +CLAY_TEXT_PRIMARY :: clay.Color{240, 240, 245, 255} +CLAY_TEXT_SECONDARY :: clay.Color{170, 170, 190, 255} +CLAY_TEXT_TERTIARY :: clay.Color{110, 110, 135, 255} +CLAY_TEXT_DISABLED :: clay.Color{70, 70, 90, 255} +CLAY_TEXT_BRIGHT := clay.Color{255, 255, 255, 255} + +CLAY_SUCCESS :: clay.Color{34, 197, 94, 255} +CLAY_SUCCESS_DIM :: clay.Color{34, 197, 94, 100} +CLAY_WARNING :: clay.Color{234, 179, 8, 255} +CLAY_WARNING_DIM :: clay.Color{234, 179, 8, 100} +CLAY_ERROR :: clay.Color{239, 68, 68, 255} +CLAY_ERROR_DIM :: clay.Color{239, 68, 68, 100} + +CLAY_BTN_DEFAULT :: clay.Color{42, 42, 58, 255} +CLAY_BTN_DEFAULT_HOVER :: clay.Color{55, 55, 72, 255} +CLAY_BTN_SOFT :: clay.Color{55, 50, 120, 255} +CLAY_BTN_SOFT_HOVER :: clay.Color{70, 65, 140, 255} +CLAY_BTN_DANGER :: clay.Color{185, 28, 28, 255} +CLAY_BTN_DANGER_HOVER :: clay.Color{210, 40, 40, 255} +CLAY_BTN_DISABLED :: clay.Color{30, 30, 42, 255} + +CLAY_NAV_ACTIVE :: clay.Color{99, 102, 241, 255} +CLAY_NAV_ACTIVE_BG :: clay.Color{67, 56, 202, 25} +CLAY_NAV_HOVER_BG :: clay.Color{40, 40, 58, 255} + +CLAY_INPUT_BORDER :: clay.Color{55, 55, 75, 255} +CLAY_INPUT_FOCUS :: clay.Color{99, 102, 241, 255} + +CLAY_PROGRESS_TRACK :: clay.Color{30, 30, 42, 255} +CLAY_PROGRESS_FILL := clay.Color{99, 102, 241, 255} + +// --- Spacing (4px grid) --- +CLAY_SPACE_4 :: u16(4) +CLAY_SPACE_8 :: u16(8) +CLAY_SPACE_12 :: u16(12) +CLAY_SPACE_16 :: u16(16) +CLAY_SPACE_20 :: u16(20) +CLAY_SPACE_24 :: u16(24) +CLAY_SPACE_32 :: u16(32) +CLAY_SPACE_40 :: u16(40) +CLAY_SPACE_48 :: u16(48) + +// --- Font Sizes --- +CLAY_FONT_SIZE_SM :: u16(13) +CLAY_FONT_SIZE_MD :: u16(15) +CLAY_FONT_SIZE_LG :: u16(18) +CLAY_FONT_SIZE_XL :: u16(22) +CLAY_FONT_SIZE_2XL:: u16(28) + +// --- Corner Radius (px for Clay) --- +CLAY_RADIUS_SM :: f32(4) +CLAY_RADIUS_MD :: f32(8) +CLAY_RADIUS_LG :: f32(12) +CLAY_RADIUS_XL :: f32(16) + +// --- Fractional radius (for DrawRectangleRounded compat) --- +CLAY_RADIUS_FRAC_BTN :: f32(0.32) +CLAY_RADIUS_FRAC_PILL :: f32(0.50) +CLAY_RADIUS_FRAC_CARD :: f32(0.14) + +// --- Color conversion --- +clay_color_to_rl :: proc(color: clay.Color) -> rl.Color { + return {u8(color[0]), u8(color[1]), u8(color[2]), u8(color[3])} +} + +// --- Clay Error Handler --- +clay_error_handler :: proc "c" (errorData: clay.ErrorData) { + context = runtime.default_context() + fmt.eprintf("CLAY ERROR: %v\n", errorData) +} + +// --- Clay State --- +Clay_State :: struct { + arena_memory: [dynamic]u8, + font_default: rl.Font, + font_title: rl.Font, + font_mono: rl.Font, +} + +clay_state: Clay_State + +// --- Init / Shutdown --- +clay_init :: proc(screen_w: i32, screen_h: i32) { + min_memory_size := clay.MinMemorySize() + clay_state.arena_memory = make([dynamic]u8, min_memory_size) + arena: clay.Arena = clay.CreateArenaWithCapacityAndMemory(uint(min_memory_size), raw_data(clay_state.arena_memory)) + + clay.Initialize(arena, {f32(screen_w), f32(screen_h)}, {handler = clay_error_handler}) + clay.SetMeasureTextFunction(clay_measure_text, nil) + + clay_state.font_default = rl.GetFontDefault() + clay_state.font_title = rl.GetFontDefault() + clay_state.font_mono = rl.GetFontDefault() + + raylib_fonts = make([dynamic]Raylib_Font, 0, 3) + append(&raylib_fonts, Raylib_Font{fontId = CLAY_FONT_BODY, font = clay_state.font_default}) + append(&raylib_fonts, Raylib_Font{fontId = CLAY_FONT_TITLE, font = clay_state.font_title}) + append(&raylib_fonts, Raylib_Font{fontId = CLAY_FONT_MONO, font = clay_state.font_mono}) +} + +clay_shutdown :: proc() { + delete(raylib_fonts) + delete(clay_state.arena_memory) +} + +clay_update_dimensions :: proc(screen_w: i32, screen_h: i32) { + clay.SetLayoutDimensions({f32(screen_w), f32(screen_h)}) +} + +clay_update_input :: proc() { + mouse_pos := rl.GetMousePosition() + is_down := rl.IsMouseButtonDown(.LEFT) + clay.SetPointerState({mouse_pos.x, mouse_pos.y}, is_down) + + scroll_delta := rl.GetMouseWheelMove() + clay.UpdateScrollContainers(true, {f32(scroll_delta) * 40, 0}, rl.GetFrameTime()) +} + +// --- Text Measurement --- +clay_measure_text :: proc "c" (text: clay.StringSlice, config: ^clay.TextElementConfig, userData: rawptr) -> clay.Dimensions { + context = runtime.default_context() + + if config.fontId >= u16(len(raylib_fonts)) { + return {width = f32(text.length) * f32(config.fontSize) * 0.6, height = f32(config.fontSize)} + } + + line_width: f32 = 0 + font := raylib_fonts[config.fontId].font + text_str := string(text.chars[:text.length]) + + for i in 0 ..< len(text_str) { + ch := text_str[i] + glyph_index := rl.GetGlyphIndex(font, rune(ch)) + + if glyph_index < 0 || glyph_index >= i32(font.glyphCount) { + line_width += f32(config.fontSize) * 0.6 + continue + } + + glyph := font.glyphs[glyph_index] + if glyph.advanceX != 0 { + line_width += f32(glyph.advanceX) + } else if glyph_index < i32(font.glyphCount) { + line_width += font.recs[glyph_index].width + f32(font.glyphs[glyph_index].offsetX) + } + } + + scaleFactor := f32(config.fontSize) / f32(font.baseSize) + total_spacing := f32(len(text_str)) * f32(config.letterSpacing) + + return {width = line_width * scaleFactor + total_spacing, height = f32(config.fontSize)} +} + +// --- Rendering --- +clay_raylib_render :: proc(render_commands: ^clay.ClayArray(clay.RenderCommand)) { + for i in 0 ..< render_commands.length { + render_command := clay.RenderCommandArray_Get(render_commands, i) + bounds := render_command.boundingBox + + switch render_command.commandType { + case .None: + case .Text: + config := render_command.renderData.text + text := string(config.stringContents.chars[:config.stringContents.length]) + cstr_text := strings.clone_to_cstring(text) + if config.fontId < u16(len(raylib_fonts)) { + font := raylib_fonts[config.fontId].font + rl.DrawTextEx( + font, + cstr_text, + {bounds.x, bounds.y}, + f32(config.fontSize), + f32(config.letterSpacing), + clay_color_to_rl(config.textColor), + ) + } + case .Image: + config := render_command.renderData.image + texture := (^rl.Texture2D)(config.imageData) + rl.DrawTextureEx(texture^, {bounds.x, bounds.y}, 0, bounds.width / f32(texture.width), rl.WHITE) + case .ScissorStart: + rl.BeginScissorMode( + i32(math.round(bounds.x)), + i32(math.round(bounds.y)), + i32(math.round(bounds.width)), + i32(math.round(bounds.height)), + ) + case .ScissorEnd: + rl.EndScissorMode() + case .Rectangle: + config := render_command.renderData.rectangle + if config.cornerRadius.topLeft > 0 { + radius := (config.cornerRadius.topLeft * 2) / min(bounds.width, bounds.height) + rl.DrawRectangleRounded( + {bounds.x, bounds.y, bounds.width, bounds.height}, + radius, 8, + clay_color_to_rl(config.backgroundColor), + ) + } else { + rl.DrawRectangle( + i32(math.round(bounds.x)), + i32(math.round(bounds.y)), + i32(math.round(bounds.width)), + i32(math.round(bounds.height)), + clay_color_to_rl(config.backgroundColor), + ) + } + case .Border: + config := render_command.renderData.border + if config.width.left > 0 { + rl.DrawRectangle( + i32(math.round(bounds.x)), + i32(math.round(bounds.y + config.cornerRadius.topLeft)), + i32(config.width.left), + i32(math.round(bounds.height - config.cornerRadius.topLeft - config.cornerRadius.bottomLeft)), + clay_color_to_rl(config.color), + ) + } + if config.width.right > 0 { + rl.DrawRectangle( + i32(math.round(bounds.x + bounds.width - f32(config.width.right))), + i32(math.round(bounds.y + config.cornerRadius.topRight)), + i32(config.width.right), + i32(math.round(bounds.height - config.cornerRadius.topRight - config.cornerRadius.bottomRight)), + clay_color_to_rl(config.color), + ) + } + if config.width.top > 0 { + rl.DrawRectangle( + i32(math.round(bounds.x + config.cornerRadius.topLeft)), + i32(math.round(bounds.y)), + i32(math.round(bounds.width - config.cornerRadius.topLeft - config.cornerRadius.topRight)), + i32(config.width.top), + clay_color_to_rl(config.color), + ) + } + if config.width.bottom > 0 { + rl.DrawRectangle( + i32(math.round(bounds.x + config.cornerRadius.bottomLeft)), + i32(math.round(bounds.y + bounds.height - f32(config.width.bottom))), + i32(math.round(bounds.width - config.cornerRadius.bottomLeft - config.cornerRadius.bottomRight)), + i32(config.width.bottom), + clay_color_to_rl(config.color), + ) + } + if config.cornerRadius.topLeft > 0 { + rl.DrawRing( + {math.round(bounds.x + config.cornerRadius.topLeft), math.round(bounds.y + config.cornerRadius.topLeft)}, + math.round(config.cornerRadius.topLeft - f32(config.width.top)), + config.cornerRadius.topLeft, + 180, 270, 10, clay_color_to_rl(config.color), + ) + } + if config.cornerRadius.topRight > 0 { + rl.DrawRing( + {math.round(bounds.x + bounds.width - config.cornerRadius.topRight), math.round(bounds.y + config.cornerRadius.topRight)}, + math.round(config.cornerRadius.topRight - f32(config.width.top)), + config.cornerRadius.topRight, + 270, 360, 10, clay_color_to_rl(config.color), + ) + } + if config.cornerRadius.bottomLeft > 0 { + rl.DrawRing( + {math.round(bounds.x + config.cornerRadius.bottomLeft), math.round(bounds.y + bounds.height - config.cornerRadius.bottomLeft)}, + math.round(config.cornerRadius.bottomLeft - f32(config.width.top)), + config.cornerRadius.bottomLeft, + 90, 180, 10, clay_color_to_rl(config.color), + ) + } + if config.cornerRadius.bottomRight > 0 { + rl.DrawRing( + {math.round(bounds.x + bounds.width - config.cornerRadius.bottomRight), math.round(bounds.y + bounds.height - config.cornerRadius.bottomRight)}, + math.round(config.cornerRadius.bottomRight - f32(config.width.bottom)), + config.cornerRadius.bottomRight, + 0.1, 90, 10, clay_color_to_rl(config.color), + ) + } + case .OverlayColorStart: + config := render_command.renderData.overlayColor + rl.DrawRectangle( + i32(math.round(bounds.x)), + i32(math.round(bounds.y)), + i32(math.round(bounds.width)), + i32(math.round(bounds.height)), + clay_color_to_rl(config.color), + ) + case .OverlayColorEnd: + case .Custom: + } + } +} + +// --- Layout helpers (procs because Clay helpers require runtime evaluation) --- + +clay_card_layout :: proc() -> clay.LayoutConfig { + return clay.LayoutConfig { + layoutDirection = .TopToBottom, + sizing = { + width = clay.SizingGrow({}), + height = clay.SizingFit({}), + }, + padding = clay.PaddingAll(16), + childGap = CLAY_SPACE_8, + } +} + +clay_row_layout :: proc() -> clay.LayoutConfig { + return clay.LayoutConfig { + layoutDirection = .LeftToRight, + sizing = { + width = clay.SizingGrow({}), + height = clay.SizingFit({}), + }, + childGap = CLAY_SPACE_8, + } +} + +clay_input_layout :: proc() -> clay.LayoutConfig { + return clay.LayoutConfig { + layoutDirection = .LeftToRight, + sizing = { + width = clay.SizingGrow({}), + height = clay.SizingFixed(36), + }, + padding = {top = 8, right = 12, bottom = 8, left = 12}, + } +} + +clay_button_layout :: proc() -> clay.LayoutConfig { + return clay.LayoutConfig { + layoutDirection = .LeftToRight, + sizing = { + width = clay.SizingFit({}), + height = clay.SizingFixed(36), + }, + padding = {top = 8, right = 16, bottom = 8, left = 16}, + childAlignment = {x = .Center, y = .Center}, + } +} + +// --- Style configs --- + +clay_card_style :: proc() -> clay.ElementDeclaration { + return { + layout = clay_card_layout(), + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(1)}, + } +} + +// --- Text helpers --- + +clay_body_text :: proc(text: string, color: clay.Color = CLAY_TEXT_PRIMARY, size: u16 = CLAY_FONT_SIZE_MD) { + clay.Text(text, {fontId = CLAY_FONT_BODY, fontSize = size, textColor = color}) +} + +clay_title_text :: proc(text: string, color: clay.Color = CLAY_TEXT_BRIGHT, size: u16 = CLAY_FONT_SIZE_LG) { + clay.Text(text, {fontId = CLAY_FONT_TITLE, fontSize = size, textColor = color}) +} + +clay_muted_text :: proc(text: string) { + clay.Text(text, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = CLAY_TEXT_TERTIARY}) +} \ No newline at end of file diff --git a/odin/src/gui/controls.odin b/odin/src/gui/controls.odin deleted file mode 100644 index 96d6f0f..0000000 --- a/odin/src/gui/controls.odin +++ /dev/null @@ -1,177 +0,0 @@ -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) -} diff --git a/odin/src/gui/local_helpers.odin b/odin/src/gui/local_helpers.odin index 8621b10..f7f6e16 100644 --- a/odin/src/gui/local_helpers.odin +++ b/odin/src/gui/local_helpers.odin @@ -113,28 +113,3 @@ pop_char :: proc(dst: ^string) { } 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" -} diff --git a/odin/src/gui/overlays.odin b/odin/src/gui/overlays.odin index b3d5442..ef73842 100644 --- a/odin/src/gui/overlays.odin +++ b/odin/src/gui/overlays.odin @@ -1,38 +1,6 @@ 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.. bool { return strings.contains(msg, "failed") || strings.contains(msg, "blocked") || strings.contains(msg, "No script") @@ -40,112 +8,4 @@ is_error_message :: proc(msg: string) -> bool { 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) -} +} \ No newline at end of file diff --git a/odin/src/gui/runtime.odin b/odin/src/gui/runtime.odin index 61da742..9d479db 100644 --- a/odin/src/gui/runtime.odin +++ b/odin/src/gui/runtime.odin @@ -1,5 +1,6 @@ package gui +import clay "clay:." import "core:fmt" import "core:strings" import rl "vendor:raylib" @@ -7,6 +8,36 @@ import "../core" import "../shared" import "../ui" +// GUI_App_State holds all mutable GUI-local state for the main loop. +GUI_App_State :: struct { + controller: ui.App_Controller, + selected_field: int, // 0 idea, 1 genre, 2 audience, 3 export_path, 4 pages, 5 project_path, 6 autosave_interval + export_path: string, + project_path: string, + local_script_pages: string, + autosave_interval_text: string, + export_format: core.Export_Format, + use_deepseek_script: bool, + status_msg: string, + is_dirty: bool, + autosave_enabled: bool, + autosave_interval_s: f64, + last_autosave_at: f64, + last_save_at: f64, + last_export_at: f64, + action_log: Action_Log, + log_show_lines: i32, + log_oldest_first: bool, + summary_opts: Summary_View_Options, + show_help_overlay: bool, + show_confirm_overlay: bool, + pending_confirm: Pending_Confirm_Action, +} + +clicked :: proc(id: clay.ElementId) -> bool { + return clay.PointerOver(id) && rl.IsMouseButtonPressed(.LEFT) +} + run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { controller := ui.new_controller(state^) defer ui.dispose_job_manager(&controller.jobs) @@ -21,1167 +52,1659 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { 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) + clay_init(1240, 820) + defer clay_shutdown() + + app: GUI_App_State + app.controller = controller + app.selected_field = 0 + app.export_path = "./gui_export.pdf" + app.project_path = "./gui_project.comic.json" + app.local_script_pages = "2" + app.autosave_interval_text = "20" + app.export_format = .PDF + app.use_deepseek_script = false + app.status_msg = fmt.aprintf("GUI ready") + app.autosave_enabled = true + app.autosave_interval_s = 20 + app.last_autosave_at = rl.GetTime() + app.last_save_at = -1 + app.last_export_at = -1 + app.log_oldest_first = false + app.summary_opts = Summary_View_Options{} + push_status(&app.status_msg, &app.action_log, app.status_msg) + defer action_log_dispose(&app.action_log) + defer delete(app.status_msg) for !rl.WindowShouldClose() { - screen_w_loop := rl.GetScreenWidth() - screen_h_loop := rl.GetScreenHeight() - compact_mode := shared.is_compact(screen_h_loop) - cfg_loop := shared.load_config() - has_deepseek_key := len(cfg_loop.deepseek_api_key) > 0 - main_w_loop := shared.compute_main_width(screen_w_loop) - status_w_loop := (main_w_loop - 2) / 2 - log_x_loop := shared.LAYOUT.sidebar_width + status_w_loop + 2 - lower_y_loop := shared.compute_lower_y(screen_h_loop) + screen_w := rl.GetScreenWidth() + screen_h := rl.GetScreenHeight() + compact_mode := shared.is_compact(screen_h) + cfg := shared.load_config() + has_deepseek_key := len(cfg.deepseek_api_key) > 0 - 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} + clay_update_dimensions(screen_w, screen_h) + clay_update_input() - 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} + // ─── Keyboard Input ────────────────────────────────────── + interaction_locked := app.show_help_overlay || app.show_confirm_overlay - // 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(shared.LAYOUT.sidebar_width + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24} - summary_sort_btn := rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width + 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 rl.IsKeyPressed(.SLASH) { toggle_help_overlay(&app.show_help_overlay) } + if rl.IsKeyPressed(.ESCAPE) { close_help_overlay_if_open(&app.show_help_overlay) } if !interaction_locked { - if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&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)) - } + if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&app.controller, .Story) } + if rl.IsKeyPressed(.TWO) { _ = ui.navigate_to_screen(&app.controller, .Script) } + if rl.IsKeyPressed(.THREE) { _ = ui.navigate_to_screen(&app.controller, .Characters) } + if rl.IsKeyPressed(.FOUR) { _ = ui.navigate_to_screen(&app.controller, .Panels) } + if rl.IsKeyPressed(.FIVE) { _ = ui.navigate_to_screen(&app.controller, .Layout) } + if rl.IsKeyPressed(.SIX) { _ = ui.navigate_to_screen(&app.controller, .Bubbles) } + if rl.IsKeyPressed(.SEVEN) { _ = ui.navigate_to_screen(&app.controller, .Export) } + if rl.IsKeyPressed(.EIGHT) { _ = ui.navigate_to_screen(&app.controller, .Community) } + if rl.IsKeyPressed(.TAB) { app.selected_field = (app.selected_field + 1) % 7 } + if rl.IsKeyPressed(.F1) { app.selected_field = 0 } + if rl.IsKeyPressed(.F2) { app.selected_field = 1 } + if rl.IsKeyPressed(.F3) { app.selected_field = 2 } + if rl.IsKeyPressed(.F4) { app.selected_field = 3 } + if rl.IsKeyPressed(.F11) { app.selected_field = 4 } + if rl.IsKeyPressed(.F12) { app.selected_field = 5 } } - pages_count := parse_pages_or_default(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) + pages_count := parse_pages_or_default(app.local_script_pages, 2) + autosave_secs := parse_autosave_interval(app.autosave_interval_text, 20) + app.autosave_interval_s = f64(autosave_secs) + if app.selected_field != 6 && len(strings.trim_space(app.autosave_interval_text)) == 0 { + app.autosave_interval_text = fmt.aprintf("%d", autosave_secs) } shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT) - can_generate_panels := len(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) + can_generate_panels := len(app.controller.state.script.pages) > 0 + can_layout := len(app.controller.state.panel_images) > 0 + can_export := len(app.controller.state.page_layouts) > 0 && len(app.controller.state.panel_images) > 0 + project_path_ok := project_path_is_normalized(app.project_path) + export_path_ok := export_path_matches_format(app.export_path, app.export_format) - 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) + // ─── Confirm Overlay Logic ──────────────────────────────── + if app.show_confirm_overlay { + confirm_yes := rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y) + confirm_no := rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N) + if clicked(clay.ID("confirm_yes")) { confirm_yes = true } + if clicked(clay.ID("confirm_no")) { confirm_no = true } if confirm_no { - show_confirm_overlay = false - pending_confirm_action = .None - push_status(&status_msg, &action_log, "Cancelled destructive action") + app.show_confirm_overlay = false + app.pending_confirm = .None + push_status(&app.status_msg, &app.action_log, "Cancelled destructive action") } else if confirm_yes { - action := 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)) + action := app.pending_confirm + app.show_confirm_overlay = false + app.pending_confirm = .None + push_status(&app.status_msg, &app.action_log, resolve_confirm_action_with_message(action, &app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) } } - 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 controller.active_screen == .Layout && button_clicked(summary_prev_btn) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - summary_opts.layout_page_cursor -= 1 - if summary_opts.layout_page_cursor < 0 { - summary_opts.layout_page_cursor = layout_page_count - 1 - } - push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count)) - } - } - if controller.active_screen == .Layout && button_clicked(summary_next_btn) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - summary_opts.layout_page_cursor += 1 - if summary_opts.layout_page_cursor >= layout_page_count { - summary_opts.layout_page_cursor = 0 - } - push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count)) - } - } - if controller.active_screen == .Bubbles && button_clicked(summary_prev_btn) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - panel_count := len(controller.state.page_layouts[page_idx].panels) - if panel_count > 0 { - summary_opts.bubble_panel_cursor -= 1 - if summary_opts.bubble_panel_cursor < 0 { - summary_opts.bubble_panel_cursor = panel_count - 1 - } - summary_opts.bubble_edit_cursor = 0 - push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_panel_cursor+1, panel_count)) - } - } - } - if controller.active_screen == .Bubbles && button_clicked(summary_next_btn) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - panel_count := len(controller.state.page_layouts[page_idx].panels) - if panel_count > 0 { - summary_opts.bubble_panel_cursor += 1 - if summary_opts.bubble_panel_cursor >= panel_count { - summary_opts.bubble_panel_cursor = 0 - } - summary_opts.bubble_edit_cursor = 0 - push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_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 controller.active_screen == .Layout && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - summary_opts.layout_page_cursor -= 1 - if summary_opts.layout_page_cursor < 0 { - summary_opts.layout_page_cursor = layout_page_count - 1 - } - push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count)) - } - } - if controller.active_screen == .Layout && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - summary_opts.layout_page_cursor += 1 - if summary_opts.layout_page_cursor >= layout_page_count { - summary_opts.layout_page_cursor = 0 - } - push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count)) - } - } - if controller.active_screen == .Bubbles && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - panel_count := len(controller.state.page_layouts[page_idx].panels) - if panel_count > 0 { - summary_opts.bubble_panel_cursor -= 1 - if summary_opts.bubble_panel_cursor < 0 { - summary_opts.bubble_panel_cursor = panel_count - 1 - } - summary_opts.bubble_edit_cursor = 0 - push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_panel_cursor+1, panel_count)) - } - } - } - if controller.active_screen == .Bubbles && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - panel_count := len(controller.state.page_layouts[page_idx].panels) - if panel_count > 0 { - summary_opts.bubble_panel_cursor += 1 - if summary_opts.bubble_panel_cursor >= panel_count { - summary_opts.bubble_panel_cursor = 0 - } - summary_opts.bubble_edit_cursor = 0 - push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.bubble_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)) - } - + // ─── Text Input ──────────────────────────────────────────── 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 + if app.selected_field == 6 && (ch < '0' || ch > '9') { continue } + switch app.selected_field { + case 0: append_char(&app.controller.state.story_idea, ch) + case 1: append_char(&app.controller.state.story_genre, ch) + case 2: append_char(&app.controller.state.target_audience, ch) + case 3: append_char(&app.export_path, ch) + case 4: append_char(&app.local_script_pages, ch) + case 5: append_char(&app.project_path, ch) + case 6: append_char(&app.autosave_interval_text, ch) } - 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 + app.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) + switch app.selected_field { + case 0: pop_char(&app.controller.state.story_idea) + case 1: pop_char(&app.controller.state.story_genre) + case 2: pop_char(&app.controller.state.target_audience) + case 3: pop_char(&app.export_path) + case 4: pop_char(&app.local_script_pages) + case 5: pop_char(&app.project_path) + case 6: pop_char(&app.autosave_interval_text) } - is_dirty = true + app.is_dirty = true } } + // ─── Keyboard Shortcuts ───────────────────────────────────── + ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL) + if !interaction_locked { + if ctrl_down && rl.IsKeyPressed(.S) { + push_status(&app.status_msg, &app.action_log, save_project_session_with_message(&app.project_path, app.controller.state, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, "Saved project")) + } + if ctrl_down && rl.IsKeyPressed(.N) { + if app.is_dirty && !shift_down { + push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Reset_Project, "Confirm reset?")) + } else { + push_status(&app.status_msg, &app.action_log, reset_project_session(&app.controller, &app.is_dirty, &app.last_autosave_at, true)) + } + } + if ctrl_down && rl.IsKeyPressed(.O) { + if app.is_dirty && !shift_down { + push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Open_Project, "Confirm open?")) + } else { + push_status(&app.status_msg, &app.action_log, open_project_session(&app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) + } + } + if ctrl_down && rl.IsKeyPressed(.E) { + push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) + } + if ctrl_down && rl.IsKeyPressed(.G) { + if app.use_deepseek_script { + app.use_deepseek_script = false + push_status(&app.status_msg, &app.action_log, "Script source: Local") + } else { + if !has_deepseek_key { + push_status(&app.status_msg, &app.action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)") + } else { + app.use_deepseek_script = true + push_status(&app.status_msg, &app.action_log, "Script source: DeepSeek") + } + } + } + if ctrl_down && rl.IsKeyPressed(.H) { + push_status_if_nonempty(&app.status_msg, &app.action_log, toggle_summary_show_if_supported(app.controller.active_screen, &app.summary_opts)) + } + if ctrl_down && rl.IsKeyPressed(.J) { + push_status_if_nonempty(&app.status_msg, &app.action_log, toggle_summary_sort_if_supported(app.controller.active_screen, &app.summary_opts)) + } + if ctrl_down && rl.IsKeyPressed(.MINUS) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, autosave_secs-5)) + } + if ctrl_down && rl.IsKeyPressed(.EQUAL) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, autosave_secs+5)) + } + if ctrl_down && rl.IsKeyPressed(.SEVEN) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 15)) + } + if ctrl_down && rl.IsKeyPressed(.EIGHT) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 30)) + } + if ctrl_down && rl.IsKeyPressed(.NINE) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 60)) + } + if ctrl_down && rl.IsKeyPressed(.ZERO) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, reset_helper_fields_with_message(&app.export_path, &app.local_script_pages, &app.autosave_interval_text, app.export_format)) + } + if ctrl_down && !shift_down && rl.IsKeyPressed(.L) { + set_status(&app.status_msg, clear_action_log_with_message(&app.action_log)) + } + if ctrl_down && rl.IsKeyPressed(.V) { + push_status_if_nonempty(&app.status_msg, &app.action_log, paste_clipboard_into_selected_field_with_message(app.selected_field, &app.controller.state, &app.export_path, &app.local_script_pages, &app.project_path, &app.autosave_interval_text, &app.is_dirty)) + } + if ctrl_down && rl.IsKeyPressed(.BACKSPACE) { + push_status(&app.status_msg, &app.action_log, clear_selected_field_with_message(app.selected_field, &app.controller.state, &app.export_path, &app.local_script_pages, &app.project_path, &app.autosave_interval_text, &app.is_dirty)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.C) { + push_status(&app.status_msg, &app.action_log, copy_text_with_status(app.status_msg, "Copied status to clipboard")) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.A) { + push_status(&app.status_msg, &app.action_log, toggle_autosave_with_message(&app.autosave_enabled)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.X) { + push_status(&app.status_msg, &app.action_log, copy_text_with_status(app.export_path, "Copied export path to clipboard")) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.P) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_export_preset_with_message(&app.export_path, app.export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.D) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_export_path_from_project_with_message(&app.export_path, app.project_path, app.export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.G) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_project_path_from_export_with_message(&app.project_path, app.export_path)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.J) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_project_path_with_message(&app.project_path)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.K) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_project_path_with_message(&app.project_path)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.M) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_export_path_with_message(&app.export_path, app.export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.U) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, fix_all_paths_with_message(&app.project_path, &app.export_path, app.export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.F) { + push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, normalize_export_path_with_message(&app.export_path, app.export_format)) + } + if rl.IsKeyPressed(.F5) { + push_status(&app.status_msg, &app.action_log, run_script_action(&app.controller, pages_count, app.use_deepseek_script, &app.is_dirty)) + } + if rl.IsKeyPressed(.F6) { + push_status(&app.status_msg, &app.action_log, run_panels_action(&app.controller, can_generate_panels, &app.is_dirty)) + } + if rl.IsKeyPressed(.F7) { + push_status(&app.status_msg, &app.action_log, run_layout_action(&app.controller, can_layout, &app.is_dirty)) + } + if rl.IsKeyPressed(.F8) { + push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) + } + if rl.IsKeyPressed(.F9) { + push_status(&app.status_msg, &app.action_log, run_next_action(&app.controller, &app.export_path, app.export_format, pages_count, app.use_deepseek_script, &app.is_dirty, &app.last_export_at)) + } + if rl.IsKeyPressed(.F10) { + push_status(&app.status_msg, &app.action_log, run_auto_all_action(&app.controller, &app.export_path, app.export_format, pages_count, app.use_deepseek_script, &app.is_dirty, &app.last_export_at)) + } } - 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)) + // ─── Autosave ────────────────────────────────────────────── + push_status_if_nonempty(&app.status_msg, &app.action_log, autosave_tick_with_message(&app.project_path, app.controller.state, app.autosave_enabled, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, app.autosave_interval_s)) + // ─── Clay Layout Declaration ────────────────────────────── + main_w := shared.compute_main_width(screen_w) + status_w := (main_w - 2) / 2 + lower_y := shared.compute_lower_y(screen_h) + + clay.BeginLayout() + + // Root: horizontal layout (sidebar + main) + if clay.UI(clay.ID("Root"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .LeftToRight}, + backgroundColor = CLAY_BG_BASE, + }) { + // ─── Sidebar ─────────────────────────────────────── + declare_sidebar(&app, screen_h) + + // ─── Main Content ──────────────────────────────── + if clay.UI(clay.ID("MainArea"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom}, + }) { + declare_topbar(&app, main_w, screen_w) + declare_project_setup(&app, main_w) + declare_actions_row(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key) + declare_status_card(&app, status_w, lower_y) + declare_detail_area(&app, main_w, status_w, screen_w, lower_y, lower_y - 140, compact_mode) + } + } + + // ─── Floating Overlays (Clay) ────────────────────────────── + if app.show_help_overlay { + declare_help_overlay() + } + if app.show_confirm_overlay { + declare_confirm_overlay(app.pending_confirm) + } + declare_toast(&app.action_log) + + render_commands := clay.EndLayout(rl.GetFrameTime()) + + // ─── Click Detection (after layout, before render) ──────── + process_clicks(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, pages_count, shift_down, autosave_secs, compact_mode) + + // ─── Render ──────────────────────────────────────────────────── rl.BeginDrawing() rl.ClearBackground(BG_BASE) - screen_w := rl.GetScreenWidth() - screen_h := rl.GetScreenHeight() - main_w := shared.compute_main_width(screen_w) - - 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 = f32(shared.LAYOUT.sidebar_width), 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 = f32(shared.LAYOUT.sidebar_width), y = 82, width = f32(main_w), height = 356}) - draw_card(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width), 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 = f32(shared.LAYOUT.sidebar_width), 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(shared.LAYOUT.sidebar_width+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 || controller.active_screen == .Bubbles { - 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 >") - } else if controller.active_screen == .Layout { - draw_small_button(summary_prev_btn, "< Ly") - draw_small_button(summary_next_btn, "Ly >") - } else if controller.active_screen == .Bubbles { - draw_small_button(summary_prev_btn, "< Pn") - draw_small_button(summary_next_btn, "Pn >") - } - if !compact_mode { - hint_label := "Ctrl+[ / Ctrl+]" - draw_hint_pill(rl.Rectangle{x = f32(shared.LAYOUT.sidebar_width + 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 if controller.active_screen == .Layout { - regen, new_layout_cursor := draw_layout_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.layout_page_cursor) - layout_page_count := len(controller.state.page_layouts) - if summary_opts.layout_page_cursor != new_layout_cursor { - summary_opts.layout_page_cursor = new_layout_cursor - push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count)) - } - if regen { - msg := action_regenerate_page_layout(&controller, summary_opts.layout_page_cursor) - push_status(&status_msg, &action_log, msg) - is_dirty = true - } - - wheel := rl.GetMouseWheelMove() - if wheel != 0 && layout_page_count > 0 { - summary_opts.layout_page_cursor -= int(wheel) - if summary_opts.layout_page_cursor < 0 { - summary_opts.layout_page_cursor = 0 - } - if summary_opts.layout_page_cursor >= layout_page_count { - summary_opts.layout_page_cursor = layout_page_count - 1 - } - push_status(&status_msg, &action_log, fmt.tprintf("Viewing layout page %d/%d", summary_opts.layout_page_cursor+1, layout_page_count)) - } - - draw_text_fitted("Ctrl+[ / Ctrl+] layout nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY) - } else if controller.active_screen == .Bubbles { - add_clicked, delete_clicked, auto_place_clicked, new_page_cursor, new_panel_cursor, new_bubble_cursor, edited_text, edited_type, type_changed, text_changed := draw_bubbles_detail_panel( - controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, - summary_opts.bubble_page_cursor, summary_opts.bubble_panel_cursor, summary_opts.bubble_edit_cursor, - ) - if summary_opts.bubble_page_cursor != new_page_cursor { - summary_opts.bubble_page_cursor = new_page_cursor - summary_opts.bubble_panel_cursor = 0 - summary_opts.bubble_edit_cursor = 0 - } - if summary_opts.bubble_panel_cursor != new_panel_cursor { - summary_opts.bubble_panel_cursor = new_panel_cursor - summary_opts.bubble_edit_cursor = 0 - } - if summary_opts.bubble_edit_cursor != new_bubble_cursor { - summary_opts.bubble_edit_cursor = new_bubble_cursor - } - if add_clicked { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - layout := controller.state.page_layouts[page_idx] - if len(layout.panels) > 0 { - panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor) - panel_id := layout.panels[panel_idx].panel_id - msg := action_add_bubble(&controller, panel_id) - push_status(&status_msg, &action_log, msg) - is_dirty = true - } - } - } - if delete_clicked { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - layout := controller.state.page_layouts[page_idx] - if len(layout.panels) > 0 { - panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor) - panel_id := layout.panels[panel_idx].panel_id - msg := action_delete_bubble(&controller, panel_id, summary_opts.bubble_edit_cursor) - push_status(&status_msg, &action_log, msg) - is_dirty = true - if summary_opts.bubble_edit_cursor > 0 { - summary_opts.bubble_edit_cursor -= 1 - } - } - } - } - if auto_place_clicked { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - layout := controller.state.page_layouts[page_idx] - if len(layout.panels) > 0 { - panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor) - panel_id := layout.panels[panel_idx].panel_id - msg := action_auto_place_bubbles_for_panel(&controller, panel_id, layout) - push_status(&status_msg, &action_log, msg) - is_dirty = true - } - } - } - if type_changed { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - page_idx := clamp_layout_cursor(layout_page_count, summary_opts.bubble_page_cursor) - layout := controller.state.page_layouts[page_idx] - if len(layout.panels) > 0 { - panel_idx := clamp_bubble_cursor(len(layout.panels), summary_opts.bubble_panel_cursor) - panel_id := layout.panels[panel_idx].panel_id - msg := action_update_bubble(&controller, panel_id, summary_opts.bubble_edit_cursor, edited_type, "") - push_status(&status_msg, &action_log, msg) - is_dirty = true - } - } - } - - wheel := rl.GetMouseWheelMove() - if wheel != 0 { - layout_page_count := len(controller.state.page_layouts) - if layout_page_count > 0 { - summary_opts.bubble_panel_cursor -= int(wheel) - if summary_opts.bubble_panel_cursor < 0 { - summary_opts.bubble_panel_cursor = 0 - } - panel_count := len(controller.state.page_layouts[summary_opts.bubble_page_cursor].panels) - if summary_opts.bubble_panel_cursor >= panel_count { - summary_opts.bubble_panel_cursor = panel_count - 1 - } - summary_opts.bubble_edit_cursor = 0 - } - } - - 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") - } + clay_raylib_render(&render_commands) rl.EndDrawing() } core.dispose_state(state) - state^ = controller.state - controller.state = core.Comic_State{} + state^ = app.controller.state + app.controller.state = core.Comic_State{} return shared.ok() } + +// ─── Sidebar Declaration ───────────────────────────────────────── +declare_sidebar :: proc(app: ^GUI_App_State, screen_h: i32) { + screens := []ui.App_Screen{.Story, .Script, .Characters, .Panels, .Layout, .Bubbles, .Export, .Community} + screen_names := []string{"1 Story", "2 Script", "3 Characters", "4 Panels", "5 Layout", "6 Bubbles", "7 Export", "8 Community"} + + if clay.UI(clay.ID("Sidebar"))({ + layout = {sizing = {width = clay.SizingFixed(282), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 16, right = 16, bottom = 16, left = 16}, childGap = 2}, + backgroundColor = CLAY_BG_SIDEBAR, + }) { + // Brand + if clay.UI(clay.ID("BrandCard"))(clay_card_style()) { + clay_title_text("comic-odin") + clay_muted_text("Native GUI") + } + + // Navigation + for i in 0 ..< len(screens) { + is_active := app.controller.active_screen == screens[i] + bg := CLAY_NAV_HOVER_BG + if is_active { bg = CLAY_NAV_ACTIVE_BG } + accent_w: u16 = 0 + accent_c: clay.Color = {0, 0, 0, 0} + if is_active { accent_w = 3; accent_c = CLAY_NAV_ACTIVE } + + if clay.UI(clay.ID("Nav", u32(i)))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(32)}, padding = {top = 6, right = 12, bottom = 6, left = 12}, childAlignment = {y = .Center}}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = accent_c, width = {accent_w, 0, 0, 0, 0}}, + }) { + text_color: clay.Color = CLAY_TEXT_SECONDARY + if is_active { text_color = CLAY_TEXT_BRIGHT } + clay_body_text(screen_names[i], color = text_color) + } + } + + // Shortcuts card (compact) + if clay.UI(clay.ID("ShortcutsCard"))(clay_card_style()) { + clay_muted_text("F5-F10: Pipeline") + clay_muted_text("Ctrl+S/N/O/E") + clay_muted_text("/: Help Esc: Close") + } + } +} + +// ─── Topbar Declaration ────────────────────────────────────────── +declare_topbar :: proc(app: ^GUI_App_State, main_w: i32, screen_w: i32) { + if clay.UI(clay.ID("Topbar"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 72, max = 72})}, padding = {top = 12, right = 20, bottom = 12, left = 20}, childGap = 16, childAlignment = {y = .Center}}, + backgroundColor = CLAY_BG_TOPBAR, + border = {color = CLAY_BORDER_DIVIDER, width = clay.BorderOutside(1)}, + }) { + screen_name := ui.screen_name(app.controller.active_screen) + if clay.UI(clay.ID("ScreenTitle"))({ + layout = {sizing = {width = clay.SizingFixed(200)}}, + }) { + clay_title_text(screen_name) + } + + // Pipeline stepper + script_ok := len(app.controller.state.script.pages) > 0 + panels_ok := len(app.controller.state.panel_images) > 0 + layout_ok := len(app.controller.state.page_layouts) > 0 + export_ok := panels_ok && layout_ok + + steps := []struct{name: string, done: bool}{{"Script", script_ok}, {"Panels", panels_ok}, {"Layout", layout_ok}, {"Export", export_ok}} + for i in 0 ..< len(steps) { + circle_color := CLAY_BTN_DISABLED + text_color := CLAY_TEXT_TERTIARY + if steps[i].done { + circle_color = CLAY_ACCENT + text_color = CLAY_TEXT_PRIMARY + } + if clay.UI(clay.ID("Step", u32(i)))({ + layout = {sizing = {width = clay.SizingFixed(120), height = clay.SizingFixed(36)}, layoutDirection = .TopToBottom, childAlignment = {x = .Center}, padding = {top = 0, right = 0, bottom = 0, left = 0}, childGap = 2}, + }) { + if clay.UI(clay.ID("StepCircle", u32(i)))({ + layout = {sizing = {width = clay.SizingFixed(16), height = clay.SizingFixed(16)}}, + backgroundColor = circle_color, + cornerRadius = clay.CornerRadiusAll(8), + }) { + if steps[i].done { + // checkmark drawn as text + clay.Text("v", {fontId = CLAY_FONT_BODY, fontSize = 10, textColor = CLAY_BG_BASE}) + } + } + clay.Text(steps[i].name, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = text_color}) + } + if i < len(steps) - 1 { + line_color: clay.Color = clay.Color{255, 255, 255, 30} + if steps[i].done { line_color = CLAY_ACCENT } + if clay.UI(clay.ID("StepLine", u32(i)))({ + layout = {sizing = {width = clay.SizingFixed(20), height = clay.SizingFixed(2)}}, + backgroundColor = line_color, + }) {} + } + } + + // Pages input + if clay.UI(clay.ID("PagesInput"))({ + layout = {sizing = {width = clay.SizingFixed(200)}, childAlignment = {y = .Center}, childGap = 8, layoutDirection = .LeftToRight}, + }) { + clay_body_text("Pages:", color = CLAY_TEXT_SECONDARY) + if clay.UI(clay.ID("field_pages"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.local_script_pages) + } + } + } +} + +// ─── Project Setup Declaration ───────────────────────────────────── +declare_project_setup :: proc(app: ^GUI_App_State, main_w: i32) { + if clay.UI(clay.ID("ProjectSetup"))(clay_card_style()) { + clay_title_text("Project Setup") + // Story Idea + clay_muted_text("Story Idea") + if clay.UI(clay.ID("field_idea"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.controller.state.story_idea if len(app.controller.state.story_idea) > 0 else "Enter story idea...") + } + // Genre + clay_muted_text("Genre") + if clay.UI(clay.ID("field_genre"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.controller.state.story_genre if len(app.controller.state.story_genre) > 0 else "Enter genre...") + } + // Audience + clay_muted_text("Audience") + if clay.UI(clay.ID("field_audience"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.controller.state.target_audience if len(app.controller.state.target_audience) > 0 else "Enter audience...") + } + // Export Path + clay_muted_text("Export Path") + if clay.UI(clay.ID("field_export"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.export_path) + } + // Project Path + clay_muted_text("Project Path") + if clay.UI(clay.ID("field_project"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.project_path) + } + // Format row + if clay.UI(clay.ID("FormatRow"))({layout = clay_row_layout()}) { + clay_muted_text("Format") + declare_nav_chip("btn_pdf", "PDF", app.export_format == .PDF) + declare_nav_chip("btn_png", "PNG", app.export_format == .PNG) + declare_nav_chip("btn_cbz", "CBZ", app.export_format == .CBZ) + clay_muted_text("Script Source") + declare_nav_chip("btn_local", "Local", !app.use_deepseek_script) + declare_nav_chip("btn_deepseek", "DeepSeek", app.use_deepseek_script) + } + } +} + +// ─── Actions Row ────────────────────────────────────────────────── +declare_actions_row :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool) { + next_hint := gui_next_hint_with_source(app.controller, app.use_deepseek_script) + + if clay.UI(clay.ID("ActionsRow"))(clay_card_style()) { + clay_title_text("Actions") + // Row 1: Pipeline actions + if clay.UI(clay.ID("PipelineRow"))({layout = clay_row_layout()}) { + declare_button_danger("btn_new", "New Project") + script_label: string = "Generate Script Local" + if app.use_deepseek_script { script_label = "Generate Script" } + declare_button_state("btn_script", script_label, can_gen_panels, recommended = (next_hint == "Generate Script" || next_hint == "Generate Script Local")) + declare_button_state("btn_panels", "Generate Panels", can_gen_panels, next_hint == "Generate Panels Local") + declare_button_state("btn_layout", "Layout Pages", can_layout, recommended = (next_hint == "Layout")) + declare_button_state("btn_export", "Export", can_export, recommended = (next_hint == "Export")) + } + // Row 2: Session actions + if clay.UI(clay.ID("SessionRow"))({layout = clay_row_layout()}) { + declare_button_soft("btn_save", "Save") + declare_button_soft("btn_open", "Open") + declare_button_primary("btn_next", "Next Step") + declare_button_primary("btn_auto", "Auto-All") + declare_button_primary("btn_autosave", "Auto-All + Save") + } + // Row 3: Utility + if clay.UI(clay.ID("UtilityRow"))({layout = clay_row_layout()}) { + autosave_label: string = "Autosave: no" + if app.autosave_enabled { autosave_label = "Autosave: yes" } + declare_button_soft("btn_autosave_toggle", autosave_label) + clay_muted_text("Interval(s)") + if clay.UI(clay.ID("field_autosave"))({ + layout = {sizing = {width = clay.SizingFixed(60), height = clay.SizingFixed(30)}}, + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(app.autosave_interval_text) + } + declare_button_small("btn_as15", "15") + declare_button_small("btn_as30", "30") + declare_button_small("btn_as60", "60") + declare_button_soft("btn_help", "Help (/)") + declare_button_default("btn_clear", "Clear Field") + declare_button_soft("btn_reset", "Reset Helpers") + } + } +} + +// ─── Status Card ────────────────────────────────────────────────── +declare_status_card :: proc(app: ^GUI_App_State, status_w: i32, lower_y: i32) { + ready_count, total_count := ready_stage_count(app.controller) + progress := f32(0) + if total_count > 0 { progress = f32(ready_count) / f32(total_count) } + + if clay.UI(clay.ID("StatusCard"))(clay_card_style()) { + clay_title_text("Status") + clay_body_text(app.status_msg) + + // Readiness row + script_ok := len(app.controller.state.script.pages) > 0 + panels_ok := len(app.controller.state.panel_images) > 0 + layout_ok := len(app.controller.state.page_layouts) > 0 + export_ok := panels_ok && layout_ok + + if clay.UI(clay.ID("ReadinessRow"))({layout = clay_row_layout()}) { + declare_status_badge("rdy_script", "Script", script_ok) + declare_status_badge("rdy_panels", "Panels", panels_ok) + declare_status_badge("rdy_layout", "Layout", layout_ok) + declare_status_badge("rdy_export", "Export", export_ok) + } + + // Progress bar + if clay.UI(clay.ID("ProgressTrack"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(8)}}, + backgroundColor = CLAY_PROGRESS_TRACK, + cornerRadius = clay.CornerRadiusAll(4), + }) { + if progress > 0 { + if clay.UI(clay.ID("ProgressFill"))({ + layout = {sizing = {width = clay.SizingPercent(progress), height = clay.SizingGrow({})}}, + backgroundColor = CLAY_PROGRESS_FILL, + cornerRadius = clay.CornerRadiusAll(4), + }) {} + } + } + clay_muted_text(fmt.tprintf("Pipeline: %d/%d", ready_count, total_count)) + clay_body_text(fmt.tprintf("Next: %s", gui_next_hint_with_source(app.controller, app.use_deepseek_script)), color = CLAY_TEXT_SECONDARY) + + if clay.UI(clay.ID("StatusMetaRow"))({layout = clay_row_layout()}) { + declare_status_badge("badge_dirty", fmt.tprintf("Dirty: %s", yn(app.is_dirty)), !app.is_dirty) + declare_status_badge("badge_autosave", fmt.tprintf("Autosave: %s (%ds)", yn(app.autosave_enabled), parse_autosave_interval(app.autosave_interval_text, 20)), app.autosave_enabled) + } + } +} + +// ─── Detail Area ────────────────────────────────────────────────── +declare_detail_area :: proc(app: ^GUI_App_State, main_w, status_w, screen_w, lower_y, summary_y: i32, compact_mode: bool) { + if clay.UI(clay.ID("DetailArea"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .LeftToRight, childGap = 2}, + }) { + // Summary panel (left) — summary content rendered via Clay + if clay.UI(clay.ID("SummaryPanel"))({ + layout = {sizing = {width = clay.SizingFixed(f32(status_w)), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = clay.PaddingAll(8)}, + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + }) { + declare_screen_summary(app) + screen := app.controller.active_screen + if screen == .Script || screen == .Layout || screen == .Panels || screen == .Bubbles { + show_txt := "Show:Top" + sort_txt := "Sort:Asc" + if screen == .Script { + if app.summary_opts.script_show_all { show_txt = "Show:All" } + if app.summary_opts.script_desc { sort_txt = "Sort:Desc" } + } else { + if app.summary_opts.layout_show_all { show_txt = "Show:All" } + if app.summary_opts.layout_desc { sort_txt = "Sort:Desc" } + } + if clay.UI(clay.ID("SummaryBtnRow"))({layout = clay_row_layout()}) { + declare_button_small("btn_summary_show", show_txt) + declare_button_small("btn_summary_sort", sort_txt) + if screen == .Script { + declare_button_small("btn_summary_prev", "< Pg") + declare_button_small("btn_summary_next", "Pg >") + } else if screen == .Panels { + declare_button_small("btn_summary_prev", "< Pn") + declare_button_small("btn_summary_next", "Pn >") + } else if screen == .Layout { + declare_button_small("btn_summary_prev", "< Ly") + declare_button_small("btn_summary_next", "Ly >") + } else if screen == .Bubbles { + declare_button_small("btn_summary_prev", "< Pn") + declare_button_small("btn_summary_next", "Pn >") + } + } + } + } + + // Detail/log panel (right) — detail panels or action log + if clay.UI(clay.ID("DetailLogPanel"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = clay.PaddingAll(8)}, + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + }) { + screen := app.controller.active_screen + if screen == .Script { + declare_script_detail(app) + } else if screen == .Panels { + declare_panels_detail(app) + } else if screen == .Layout { + declare_layout_detail(app) + } else if screen == .Bubbles { + declare_bubbles_detail(app) + } else { + declare_action_log(app) + } + } + } +} + +// ─── Click Processing ────────────────────────────────────────────── +process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool, pages_count: int, shift_down: bool, autosave_secs: int, compact_mode: bool) { + // Navigation + nav_screen := []ui.App_Screen{.Story, .Script, .Characters, .Panels, .Layout, .Bubbles, .Export, .Community} + for i in 0 ..< len(nav_screen) { + if clicked(clay.ID("Nav", u32(i))) { + push_status(&app.status_msg, &app.action_log, navigate_screen_with_status(&app.controller, nav_screen[i])) + } + } + + // Input field focus + input_ids := []string{"field_idea", "field_genre", "field_audience", "field_export", "field_pages", "field_project", "field_autosave"} + for i in 0 ..< len(input_ids) { + if clicked(clay.ID(input_ids[i])) { + app.selected_field = i + } + } + + // Format buttons + if clicked(clay.ID("btn_pdf")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .PDF, &app.is_dirty)) } + if clicked(clay.ID("btn_png")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .PNG, &app.is_dirty)) } + if clicked(clay.ID("btn_cbz")) { push_status(&app.status_msg, &app.action_log, set_export_format_with_message(&app.export_format, &app.export_path, .CBZ, &app.is_dirty)) } + if clicked(clay.ID("btn_local")) { app.use_deepseek_script = false; push_status(&app.status_msg, &app.action_log, "Script source: Local") } + if clicked(clay.ID("btn_deepseek")) { + if !has_deepseek { push_status(&app.status_msg, &app.action_log, "DeepSeek key missing") } + else { app.use_deepseek_script = true; push_status(&app.status_msg, &app.action_log, "Script source: DeepSeek") } + } + + // Action buttons + if clicked(clay.ID("btn_new")) { + if app.is_dirty && !shift_down { push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Reset_Project, "Confirm reset?")) } + else { push_status(&app.status_msg, &app.action_log, reset_project_session(&app.controller, &app.is_dirty, &app.last_autosave_at, false)) } + } + if clicked(clay.ID("btn_script")) { push_status(&app.status_msg, &app.action_log, run_script_action(&app.controller, pages_count, app.use_deepseek_script, &app.is_dirty)) } + if clicked(clay.ID("btn_panels")) { push_status(&app.status_msg, &app.action_log, run_panels_action(&app.controller, can_gen_panels, &app.is_dirty)) } + if clicked(clay.ID("btn_layout")) { push_status(&app.status_msg, &app.action_log, run_layout_action(&app.controller, can_layout, &app.is_dirty)) } + if clicked(clay.ID("btn_export")) { push_status(&app.status_msg, &app.action_log, run_export_action(&app.controller, &app.export_path, app.export_format, can_export, &app.is_dirty, &app.last_export_at)) } + if clicked(clay.ID("btn_next")) { push_status(&app.status_msg, &app.action_log, run_next_action(&app.controller, &app.export_path, app.export_format, pages_count, app.use_deepseek_script, &app.is_dirty, &app.last_export_at)) } + if clicked(clay.ID("btn_auto")) { push_status(&app.status_msg, &app.action_log, run_auto_all_action(&app.controller, &app.export_path, app.export_format, pages_count, app.use_deepseek_script, &app.is_dirty, &app.last_export_at)) } + if clicked(clay.ID("btn_autosave")) { push_status(&app.status_msg, &app.action_log, run_auto_all_save_action(&app.controller, &app.project_path, &app.export_path, app.export_format, pages_count, app.use_deepseek_script, &app.is_dirty, &app.last_export_at, &app.last_autosave_at, &app.last_save_at)) } + if clicked(clay.ID("btn_save")) { push_status(&app.status_msg, &app.action_log, save_project_session_with_message(&app.project_path, app.controller.state, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, "Saved project")) } + if clicked(clay.ID("btn_open")) { + if app.is_dirty && !shift_down { push_status(&app.status_msg, &app.action_log, request_confirmation(&app.show_confirm_overlay, &app.show_help_overlay, &app.pending_confirm, .Open_Project, "Confirm open?")) } + else { push_status(&app.status_msg, &app.action_log, open_project_session(&app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) } + } + if clicked(clay.ID("btn_autosave_toggle")) { push_status(&app.status_msg, &app.action_log, toggle_autosave_with_message(&app.autosave_enabled)) } + if clicked(clay.ID("btn_help")) { toggle_help_overlay(&app.show_help_overlay) } + if clicked(clay.ID("btn_clear")) { push_status(&app.status_msg, &app.action_log, clear_selected_field_with_message(app.selected_field, &app.controller.state, &app.export_path, &app.local_script_pages, &app.project_path, &app.autosave_interval_text, &app.is_dirty)) } + if clicked(clay.ID("btn_reset")) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, reset_helper_fields_with_message(&app.export_path, &app.local_script_pages, &app.autosave_interval_text, app.export_format)) } + if clicked(clay.ID("btn_as15")) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 15)) } + if clicked(clay.ID("btn_as30")) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 30)) } + if clicked(clay.ID("btn_as60")) { push_dirty_status(&app.is_dirty, &app.status_msg, &app.action_log, set_autosave_interval_text(&app.autosave_interval_text, 60)) } + + // Summary buttons + screen := app.controller.active_screen + if clicked(clay.ID("btn_summary_show")) { + push_status_if_nonempty(&app.status_msg, &app.action_log, toggle_summary_show_if_supported(screen, &app.summary_opts)) + } + if clicked(clay.ID("btn_summary_sort")) { + push_status_if_nonempty(&app.status_msg, &app.action_log, toggle_summary_sort_if_supported(screen, &app.summary_opts)) + } + if clicked(clay.ID("btn_summary_prev")) { + if screen == .Script && len(app.controller.state.script.pages) > 0 { + app.summary_opts.script_page_cursor -= 1 + if app.summary_opts.script_page_cursor < 0 { app.summary_opts.script_page_cursor = len(app.controller.state.script.pages) - 1 } + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Viewing script page %d/%d", app.summary_opts.script_page_cursor+1, len(app.controller.state.script.pages))) + } + } + if clicked(clay.ID("btn_summary_next")) { + if screen == .Script && len(app.controller.state.script.pages) > 0 { + app.summary_opts.script_page_cursor += 1 + if app.summary_opts.script_page_cursor >= len(app.controller.state.script.pages) { app.summary_opts.script_page_cursor = 0 } + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Viewing script page %d/%d", app.summary_opts.script_page_cursor+1, len(app.controller.state.script.pages))) + } + } + + // Log action buttons (only active when not on detail screens) + if screen != .Script && screen != .Panels && screen != .Layout && screen != .Bubbles { + if clicked(clay.ID("btn_log_reset")) { + push_status(&app.status_msg, &app.action_log, reset_log_view_with_message(&app.log_show_lines, &app.log_oldest_first)) + } + if clicked(clay.ID("btn_log_report")) { + diag_ctx := make_diagnostics_action_context(&app.controller, &app.action_log, app.is_dirty, app.autosave_enabled, proj_ok, export_ok, autosave_secs, app.project_path, app.export_path, app.log_show_lines, app.log_oldest_first) + push_status(&app.status_msg, &app.action_log, write_session_report_with_message(diag_ctx)) + } + if clicked(clay.ID("btn_log_copy")) { + diag_ctx := make_diagnostics_action_context(&app.controller, &app.action_log, app.is_dirty, app.autosave_enabled, proj_ok, export_ok, autosave_secs, app.project_path, app.export_path, app.log_show_lines, app.log_oldest_first) + push_status(&app.status_msg, &app.action_log, copy_action_log_snapshot_with_message(diag_ctx)) + } + if clicked(clay.ID("btn_log_diag")) { + diag_ctx := make_diagnostics_action_context(&app.controller, &app.action_log, app.is_dirty, app.autosave_enabled, proj_ok, export_ok, autosave_secs, app.project_path, app.export_path, app.log_show_lines, app.log_oldest_first) + push_status(&app.status_msg, &app.action_log, write_diagnostics_with_message(diag_ctx)) + } + if clicked(clay.ID("btn_log_status_copy")) { + push_status(&app.status_msg, &app.action_log, copy_text_with_status(app.status_msg, "Copied status to clipboard")) + } + if clicked(clay.ID("btn_log_clear")) { + set_status(&app.status_msg, clear_action_log_with_message(&app.action_log)) + } + if clicked(clay.ID("btn_log_diag_copy")) { + diag_ctx := make_diagnostics_action_context(&app.controller, &app.action_log, app.is_dirty, app.autosave_enabled, proj_ok, export_ok, autosave_secs, app.project_path, app.export_path, app.log_show_lines, app.log_oldest_first) + push_status(&app.status_msg, &app.action_log, copy_diagnostics_with_message(diag_ctx)) + } + } + + // Panels detail buttons + if screen == .Panels { + if clicked(clay.ID("btn_panel_regenerate")) { + panel_count := count_script_panels(app.controller.state.script) + if panel_count > 0 { + idx := clamp_panel_cursor(panel_count, app.summary_opts.panel_cursor) + panel, _, ok := panel_by_flat_index(app.controller.state.script, idx) + if ok { + push_status(&app.status_msg, &app.action_log, action_regenerate_panel(&app.controller, panel.panel_id)) + } + } + } + panel_count := count_script_panels(app.controller.state.script) + if panel_count > 0 { + for i in 0.. 0 { + if clicked(clay.ID("btn_bubble_auto_place")) { + page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) + layout_val := app.controller.state.page_layouts[page_idx] + if len(layout_val.panels) > 0 { + panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) + panel_id := layout_val.panels[panel_idx].panel_id + push_status(&app.status_msg, &app.action_log, action_auto_place_bubbles_for_panel(&app.controller, panel_id, layout_val)) + app.is_dirty = true + } + } + if clicked(clay.ID("btn_bubble_add")) { + page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) + layout_val := app.controller.state.page_layouts[page_idx] + if len(layout_val.panels) > 0 { + panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) + panel_id := layout_val.panels[panel_idx].panel_id + push_status(&app.status_msg, &app.action_log, action_add_bubble(&app.controller, panel_id)) + app.is_dirty = true + } + } + } + + // Bubble row clicks + if layout_count > 0 { + page_idx := clamp_layout_cursor(layout_count, app.summary_opts.bubble_page_cursor) + layout_val := app.controller.state.page_layouts[page_idx] + if len(layout_val.panels) > 0 { + panel_idx := clamp_bubble_cursor(len(layout_val.panels), app.summary_opts.bubble_panel_cursor) + panel_id := layout_val.panels[panel_idx].panel_id + panel_panel_count := count_bubbles_for_panel(app.controller.state.speech_bubbles, panel_id) + for i in 0.. 0 { + app.summary_opts.bubble_edit_cursor -= 1 + } + } + } + + // Type selector buttons + bubble_count := count_bubbles_for_panel(app.controller.state.speech_bubbles, panel_id) + if bubble_count > 0 && app.summary_opts.bubble_edit_cursor < bubble_count { + types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect} + for t in types { + btn_id := fmt.tprintf("btn_btype_%d", int(t)) + if clicked(clay.ID(btn_id)) { + push_status(&app.status_msg, &app.action_log, action_update_bubble(&app.controller, panel_id, app.summary_opts.bubble_edit_cursor, t, "")) + app.is_dirty = true + } + } + } + } + } + } + + // Confirm overlay buttons + if clicked(clay.ID("confirm_yes")) && app.show_confirm_overlay { + action := app.pending_confirm + app.show_confirm_overlay = false + app.pending_confirm = .None + push_status(&app.status_msg, &app.action_log, resolve_confirm_action_with_message(action, &app.controller, &app.project_path, &app.export_path, app.export_format, &app.is_dirty, &app.last_autosave_at)) + } + if clicked(clay.ID("confirm_no")) && app.show_confirm_overlay { + app.show_confirm_overlay = false + app.pending_confirm = .None + push_status(&app.status_msg, &app.action_log, "Cancelled destructive action") + } +} + +// ─── Clay UI Primitives ─────────────────────────────────────────── +declare_nav_chip :: proc(id: string, label: string, active: bool) { + bg: clay.Color = clay.Color{0, 0, 0, 0} + if active { bg = CLAY_NAV_HOVER_BG } + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingFit({min = 60, max = 30}), height = clay.SizingFixed(30)}, padding = {top = 4, right = 12, bottom = 4, left = 12}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + text_color: clay.Color = CLAY_TEXT_SECONDARY + if active { text_color = CLAY_TEXT_BRIGHT } + clay_body_text(label, color = text_color) + } +} + +declare_button :: proc(id: string, label: string, bg, hover_bg: clay.Color) { + is_hovered := clay.Hovered() + current_bg: clay.Color = bg + if is_hovered { current_bg = hover_bg } + if clay.UI(clay.ID(id))({ + layout = clay_button_layout(), + backgroundColor = current_bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), + }) { + clay_body_text(label, color = CLAY_TEXT_PRIMARY) + } +} + +declare_button_default :: proc(id: string, label: string) { + declare_button(id, label, CLAY_BTN_DEFAULT, CLAY_BTN_DEFAULT_HOVER) +} + +declare_button_danger :: proc(id: string, label: string) { + declare_button(id, label, CLAY_BTN_DANGER, CLAY_BTN_DANGER_HOVER) +} + +declare_button_soft :: proc(id: string, label: string) { + declare_button(id, label, CLAY_BTN_SOFT, CLAY_BTN_SOFT_HOVER) +} + +declare_button_primary :: proc(id: string, label: string) { + declare_button(id, label, CLAY_ACCENT, CLAY_ACCENT_HOVER) +} + +declare_button_small :: proc(id: string, label: string) { + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingFit({min = 40, max = 24}), height = clay.SizingFixed(24)}, padding = {top = 2, right = 8, bottom = 2, left = 8}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = CLAY_BTN_DEFAULT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = CLAY_TEXT_SECONDARY}) + } +} + +declare_button_recommended :: proc(id: string, label: string) { + if clay.UI(clay.ID(id))({ + layout = clay_button_layout(), + backgroundColor = CLAY_ACCENT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), + border = {color = CLAY_ACCENT_GLOW, width = clay.BorderOutside(2)}, + }) { + clay_body_text(label, color = CLAY_TEXT_BRIGHT) + } +} + +declare_button_state :: proc(id: string, label: string, enabled: bool, recommended := false) { + if !enabled { + if clay.UI(clay.ID(id))({ + layout = clay_button_layout(), + backgroundColor = CLAY_BTN_DISABLED, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), + }) { + clay_body_text(label, color = CLAY_TEXT_DISABLED) + } + } else if recommended { + declare_button_recommended(id, label) + } else { + declare_button_default(id, label) + } +} + +declare_status_badge :: proc(id: string, label: string, ok: bool) { + badge_color: clay.Color = CLAY_ERROR + if ok { badge_color = CLAY_SUCCESS } + badge_bg: clay.Color = CLAY_ERROR_DIM + if ok { badge_bg = CLAY_SUCCESS_DIM } + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingFit({min = 60, max = 22}), height = clay.SizingFixed(22)}, padding = {top = 2, right = 8, bottom = 2, left = 8}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = badge_bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + border = {color = badge_color, width = clay.BorderOutside(1)}, + }) { + clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = badge_color}) + } +} + +// ─── Help Overlay (Clay floating) ────────────────────────────────── +declare_help_overlay :: proc() { + // Backdrop + if clay.UI(clay.ID("HelpBackdrop"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, + backgroundColor = {0, 0, 0, 180}, + }) {} + + // Card + if clay.UI(clay.ID("HelpCard"))({ + layout = {sizing = {width = clay.SizingFixed(860), height = clay.SizingFixed(642)}, layoutDirection = .TopToBottom, padding = {top = 28, right = 30, bottom = 28, left = 30}, childGap = 16}, + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), + border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(2)}, + floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .CenterCenter, parent = .CenterCenter}, zIndex = 10}, + }) { + clay_title_text("Keyboard Shortcuts", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_2XL) + + clay_body_text("Navigation", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) + clay_muted_text("1..8 screens | TAB fields | click to focus | F11 pages | F12 project") + + clay_body_text("Core Actions", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) + clay_muted_text("F5 script F6 panels F7 layout F8 export F9 next F10 auto-all") + clay_muted_text("Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary") + + clay_body_text("Clipboard + Logs", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) + clay_muted_text("Ctrl+L clear log | Ctrl+Shift+L copy log | Ctrl+Shift+T/B log view | Ctrl+Shift+Z reset") + clay_muted_text("Ctrl+Shift+C status | Ctrl+Shift+Y diag copy | Ctrl+Shift+R diag file | Ctrl+Shift+W report") + clay_muted_text("Ctrl+0 reset helpers | Ctrl+V paste | Ctrl+Shift+I copy | Ctrl+Backspace clear") + + clay_body_text("Paths", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) + clay_muted_text("Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export") + clay_muted_text("Ctrl+Shift+J fix project | F fix export | K/M quick-fix P/E | U fix all") + + clay_body_text("Autosave", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) + clay_muted_text("Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60") + + clay_body_text("Safety", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_MD) + clay_muted_text("Dirty guard: Shift-click New/Open | Keyboard confirm: Ctrl+Shift+N / Ctrl+Shift+O") + + clay_body_text("Close help: Esc or /", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_MD) + } +} + +// ─── Confirm Overlay (Clay floating) ──────────────────────────────── +declare_confirm_overlay :: proc(action: Pending_Confirm_Action) { + // Backdrop + if clay.UI(clay.ID("ConfirmBackdrop"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, + backgroundColor = {0, 0, 0, 180}, + }) {} + + // Card + if clay.UI(clay.ID("ConfirmCard"))({ + layout = {sizing = {width = clay.SizingFixed(520), height = clay.SizingFixed(230)}, layoutDirection = .TopToBottom, padding = {top = 34, right = 30, bottom = 24, left = 30}, childGap = 12}, + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), + border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(2)}, + floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .CenterCenter, parent = .CenterCenter}, zIndex = 20}, + }) { + // Accent bar at top + if clay.UI(clay.ID("ConfirmAccentBar"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(8)}}, + backgroundColor = CLAY_ACCENT, + cornerRadius = clay.CornerRadiusAll(4), + }) {} + + clay_title_text("Confirm destructive action", color = CLAY_ERROR, size = CLAY_FONT_SIZE_XL) + action_label := "reset" + if action == .Open_Project { action_label = "open a different project" } + clay_body_text(fmt.tprintf("You have unsaved changes. Do you want to %s?", action_label), color = CLAY_TEXT_SECONDARY) + + if clay.UI(clay.ID("ConfirmBtnRow"))({layout = clay_row_layout()}) { + declare_button_danger("confirm_yes", "Confirm") + declare_button_soft("confirm_no", "Cancel") + } + clay_muted_text("Enter/Y confirm | Esc/N cancel") + } +} + +// ─── Toast (Clay floating) ────────────────────────────────────────── +declare_toast :: proc(log: ^Action_Log) { + if log.count == 0 { return } + age := rl.GetTime() - log.last_push_at + if age > 2.8 { return } + + idx := (log.count - 1) % len(log.entries) + if idx < 0 { idx += len(log.entries) } + msg := log.entries[idx] + bg := CLAY_ERROR + if is_warning_message(msg) { bg = CLAY_WARNING } + else if !is_error_message(msg) { bg = CLAY_SUCCESS } + + if clay.UI(clay.ID("Toast"))({ + layout = {sizing = {width = clay.SizingFit({min = 300, max = 0}), height = clay.SizingFixed(34)}, padding = {top = 6, right = 12, bottom = 6, left = 12}, childAlignment = {y = .Center}}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + border = {color = clay.Color{255, 255, 255, 40}, width = clay.BorderOutside(1)}, + floating = {offset = {f32(shared.LAYOUT.sidebar_width + 8), 70}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 30}, + }) { + clay_body_text(msg, color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_SM) + } +} + +// ─── Screen Summary (Clay) ────────────────────────────────────────── +declare_screen_summary :: proc(app: ^GUI_App_State) { + if clay.UI(clay.ID("SummaryContent"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 4}, + }) { + // Title + stat chips row + if clay.UI(clay.ID("SummaryHeader"))({layout = clay_row_layout()}) { + clay_title_text("Screen Summary", size = CLAY_FONT_SIZE_LG) + // Stat chips + declare_stat_chip("Pages", len(app.controller.state.script.pages)) + declare_stat_chip("Panels", len(app.controller.state.panel_images)) + declare_stat_chip("Layout", len(app.controller.state.page_layouts)) + } + + // Screen-specific content + switch app.controller.active_screen { + case .Story: + clay_muted_text(fmt.tprintf("Idea length: %d chars", len(app.controller.state.story_idea))) + clay_muted_text(fmt.tprintf("Genre: %s", app.controller.state.story_genre)) + clay_muted_text(fmt.tprintf("Audience: %s", app.controller.state.target_audience)) + clay_body_text("Use Generate Script Local to begin", color = CLAY_TEXT_TERTIARY) + + case .Script: + clay_muted_text(fmt.tprintf("Title: %s", app.controller.state.script.title)) + page_count := len(app.controller.state.script.pages) + clay_muted_text(fmt.tprintf("Pages: %d | Characters: %d", page_count, len(app.controller.state.script.characters))) + if page_count == 0 { + clay_body_text("No script pages yet. Generate Script Local to continue.", color = CLAY_TEXT_TERTIARY) + } else { + cursor := app.summary_opts.script_page_cursor + if cursor < 0 { cursor = 0 } + if cursor >= page_count { cursor = page_count - 1 } + page := app.controller.state.script.pages[cursor] + clay_body_text(fmt.tprintf("Viewing page %d/%d (script page #%d)", cursor+1, page_count, page.page_number), color = CLAY_ACCENT) + clay_muted_text(fmt.tprintf("Panels on page: %d", len(page.panels))) + 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)" } + clay_muted_text(fmt.tprintf("• P%d: %s", pn.panel_number, desc)) + if len(pn.dialogue) > 0 { + clay_muted_text(fmt.tprintf(" \"%s\"", pn.dialogue[0].text)) + } + } + if len(page.panels) > show_panels { + clay_body_text(fmt.tprintf("+%d more panels", len(page.panels)-show_panels), color = CLAY_ACCENT) + } + } + + case .Characters: + clay_muted_text(fmt.tprintf("Character count: %d", len(app.controller.state.characters))) + clay_body_text("Character editor is scaffolded", color = CLAY_TEXT_TERTIARY) + clay_body_text("Use script generation to populate", color = CLAY_TEXT_TERTIARY) + + case .Panels: + script_panel_count := count_script_panels(app.controller.state.script) + clay_muted_text(fmt.tprintf("Panel images: %d", len(app.controller.state.panel_images))) + clay_muted_text(fmt.tprintf("Script panels: %d", script_panel_count)) + clay_muted_text(fmt.tprintf("Script pages: %d", len(app.controller.state.script.pages))) + if script_panel_count == 0 { + clay_body_text("No script panels yet. Generate Script first.", color = CLAY_TEXT_TERTIARY) + } else { + pidx := clamp_panel_cursor(script_panel_count, app.summary_opts.panel_cursor) + panel, page_num, ok := panel_by_flat_index(app.controller.state.script, pidx) + if ok { + status := "missing" + if _, has_img := app.controller.state.panel_images[panel.panel_id]; has_img { status = "ready" } + clay_body_text(fmt.tprintf("Viewing panel %d/%d • page %d # %d", pidx+1, script_panel_count, page_num, panel.panel_number), color = CLAY_ACCENT) + clay_muted_text(fmt.tprintf("%s • %s", panel.panel_id, status)) + } + } + + case .Layout: + clay_muted_text(fmt.tprintf("Layout pages: %d", len(app.controller.state.page_layouts))) + clay_muted_text(fmt.tprintf("Page size: %v", app.controller.state.page_size)) + layout_count := len(app.controller.state.page_layouts) + if layout_count == 0 { + clay_body_text("No layouts yet. Use Layout Auto after panels are ready.", color = CLAY_TEXT_TERTIARY) + } else { + show_count := layout_count + if !app.summary_opts.layout_show_all && show_count > 3 { show_count = 3 } + for i in 0 ..< show_count { + idx := i + if app.summary_opts.layout_desc { idx = layout_count - 1 - i } + if idx >= 0 && idx < layout_count { + l := app.controller.state.page_layouts[idx] + clay_muted_text(fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels))) + } + } + } + + case .Bubbles: + bubble_count := 0 + if app.controller.state.speech_bubbles != nil { + for _, bubbles in app.controller.state.speech_bubbles { + bubble_count += len(bubbles) + } + } + clay_muted_text(fmt.tprintf("Total bubbles: %d", bubble_count)) + clay_muted_text(fmt.tprintf("Layout pages: %d", len(app.controller.state.page_layouts))) + if len(app.controller.state.page_layouts) == 0 { + clay_body_text("No layouts yet. Run Layout Auto first.", color = CLAY_TEXT_TERTIARY) + } else if bubble_count == 0 { + clay_body_text("No bubbles yet. Use Auto Place or Add on Bubbles screen.", color = CLAY_TEXT_TERTIARY) + } else { + clay_body_text(fmt.tprintf("Panels with bubbles: %d", len(app.controller.state.speech_bubbles)), color = CLAY_ACCENT) + clay_body_text("Select a panel in Bubbles screen to edit.", color = CLAY_TEXT_TERTIARY) + } + + case .Export: + clay_muted_text(fmt.tprintf("Format: %v", app.controller.state.export_format)) + clay_muted_text(fmt.tprintf("Layouts: %d | Panels: %d", len(app.controller.state.page_layouts), len(app.controller.state.panel_images))) + clay_muted_text(fmt.tprintf("Target: %s", app.export_path)) + if len(app.controller.state.page_layouts) > 0 { + last := app.controller.state.page_layouts[len(app.controller.state.page_layouts)-1] + clay_muted_text(fmt.tprintf("Last layout pattern: %s", last.pattern_id)) + } + reason := export_block_reason(app.controller.state) + if len(reason) > 0 { + clay_body_text(fmt.tprintf("Export blocked: %s", reason), color = CLAY_ERROR) + } else { + clay_body_text("Use Export button or Ctrl+E", color = CLAY_TEXT_TERTIARY) + } + + case .Community: + clay_body_text("Community features coming soon", color = CLAY_TEXT_SECONDARY) + clay_body_text("Current focus: local GUI workflows", color = CLAY_TEXT_SECONDARY) + } + + // Summary nav buttons + screen := app.controller.active_screen + if screen == .Script || screen == .Layout || screen == .Panels || screen == .Bubbles { + show_txt := "Show:Top" + sort_txt := "Sort:Asc" + if screen == .Script { + if app.summary_opts.script_show_all { show_txt = "Show:All" } + if app.summary_opts.script_desc { sort_txt = "Sort:Desc" } + } else { + if app.summary_opts.layout_show_all { show_txt = "Show:All" } + if app.summary_opts.layout_desc { sort_txt = "Sort:Desc" } + } + if clay.UI(clay.ID("SummaryBtnRow"))({layout = clay_row_layout()}) { + declare_button_small("btn_summary_show", show_txt) + declare_button_small("btn_summary_sort", sort_txt) + if screen == .Script { + declare_button_small("btn_summary_prev", "< Pg") + declare_button_small("btn_summary_next", "Pg >") + } else if screen == .Panels { + declare_button_small("btn_summary_prev", "< Pn") + declare_button_small("btn_summary_next", "Pn >") + } else if screen == .Layout { + declare_button_small("btn_summary_prev", "< Ly") + declare_button_small("btn_summary_next", "Ly >") + } else if screen == .Bubbles { + declare_button_small("btn_summary_prev", "< Pn") + declare_button_small("btn_summary_next", "Pn >") + } + } + } + } +} + +// ─── Stat Chip (Clay) ────────────────────────────────────────────── +declare_stat_chip :: proc(label: string, value: int) { + value_text := fmt.tprintf("%d", value) + if clay.UI(clay.ID("StatChip", u32(label[0])))({ + layout = {sizing = {width = clay.SizingFixed(80), height = clay.SizingFixed(28)}, padding = {top = 4, right = 8, bottom = 4, left = 8}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = clay.Color{40, 40, 55, 255}, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = clay.Color{55, 55, 75, 255}, width = clay.BorderOutside(1)}, + }) { + clay_muted_text(label) + clay_body_text(value_text, color = CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) + } +} + +// ─── Script Detail Panel (Clay) ──────────────────────────────────── +declare_script_detail :: proc(app: ^GUI_App_State) { + if clay.UI(clay.ID("ScriptDetailCard"))(clay_card_style()) { + clay_title_text("Script Detail") + + page_count := len(app.controller.state.script.pages) + if page_count == 0 { + clay_muted_text("No script pages yet. Run Generate Script Local.") + return + } + + idx := clamp_script_cursor(page_count, app.summary_opts.script_page_cursor) + page := app.controller.state.script.pages[idx] + + if clay.UI(clay.ID("ScriptDetailStatRow"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 8}, + }) { + declare_stat_chip("Page", idx + 1) + declare_stat_chip("Panels", len(page.panels)) + } + + clay_body_text(fmt.tprintf("Page #%d", page.page_number), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + + if clay.UI(clay.ID("ScriptPanelScroll"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, + }) { + for pn in page.panels { + desc := pn.description + if len(desc) == 0 { desc = "(no description)" } + if clay.UI(clay.ID(fmt.tprintf("ScriptDetailPanel_%d", pn.panel_number)))({ + layout = {layoutDirection = .TopToBottom, padding = {top = 2, right = 4, bottom = 2, left = 4}}, + }) { + clay_body_text(fmt.tprintf("P%d: %s", pn.panel_number, desc), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + if len(pn.dialogue) > 0 { + clay_body_text(fmt.tprintf("\"%s\"", pn.dialogue[0].text), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) + } + } + } + } + } +} + +// ─── Panels Detail Panel (Clay) ──────────────────────────────────── +declare_panels_detail :: proc(app: ^GUI_App_State) { + if clay.UI(clay.ID("PanelsDetailCard"))(clay_card_style()) { + clay_title_text("Panel Results") + + panel_count := count_script_panels(app.controller.state.script) + if panel_count == 0 { + clay_muted_text("No script panels yet. Generate Script first.") + return + } + + idx := clamp_panel_cursor(panel_count, app.summary_opts.panel_cursor) + panel, page_num, ok := panel_by_flat_index(app.controller.state.script, idx) + if !ok { + clay_muted_text("Panel index out of range.") + return + } + + status := "missing" + status_color: clay.Color = CLAY_WARNING + _, has_img := app.controller.state.panel_images[panel.panel_id] + _, has_err := app.controller.state.panel_errors[panel.panel_id] + if has_err { + status = "error" + status_color = CLAY_ERROR + } + if has_img { + status = "ready" + status_color = CLAY_SUCCESS + } + + if clay.UI(clay.ID("PanelDetailHeader"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}, + }) { + clay_body_text(fmt.tprintf("Panel %d/%d • page %d # %d", idx+1, panel_count, page_num, panel.panel_number), color = status_color, size = CLAY_FONT_SIZE_SM) + if has_img { + declare_button_small("btn_panel_regenerate", "Regenerate") + } else { + declare_button_small("btn_panel_regenerate", "Generate") + } + } + + clay_muted_text(fmt.tprintf("id: %s", panel.panel_id)) + if has_err { + err_msg, _ := app.controller.state.panel_errors[panel.panel_id] + clay_body_text(fmt.tprintf("err: %s", err_msg), color = CLAY_ERROR, size = CLAY_FONT_SIZE_SM) + } else if has_img { + img := app.controller.state.panel_images[panel.panel_id] + clay_muted_text(fmt.tprintf("img: %dx%d seed:%d", img.width, img.height, img.seed)) + } else { + clay_muted_text("img: not generated") + } + + desc := panel.description + if len(desc) == 0 { desc = "(no description)" } + clay_body_text(fmt.tprintf("desc: %s", desc), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + + if has_img { + clay_muted_text(fmt.tprintf("src: %s", panel.panel_id)) + } + + if clay.UI(clay.ID("PanelListScroll"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 1}, + }) { + visible_rows: int = 8 + if visible_rows > panel_count { visible_rows = panel_count } + start := idx - visible_rows / 2 + if start < 0 { start = 0 } + end := start + visible_rows + if end > panel_count { end = panel_count } + if end - start < visible_rows { + start = end - visible_rows + if start < 0 { start = 0 } + } + + for i in start..= 80 && val.coverage_pct <= 105 + bindings_ok := val.missing_bindings == 0 + bounds_ok := val.bounds_violations == 0 + + if clay.UI(clay.ID("LayoutValidationRow"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}, + }) { + declare_status_badge("val_coverage", fmt.tprintf("Cov: %.0f%%", val.coverage_pct), coverage_ok) + declare_status_badge("val_bindings", fmt.tprintf("Bind: %d miss", val.missing_bindings), bindings_ok) + declare_status_badge("val_bounds", fmt.tprintf("Bounds: %d", val.bounds_violations), bounds_ok) + } + + clay_muted_text(fmt.tprintf("size: %d x %d", layout_val.width, layout_val.height)) + + if clay.UI(clay.ID("LayoutDetailContent"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, childGap = 8}, + }) { + if clay.UI(clay.ID("LayoutWireframe"))({ + layout = {sizing = {width = clay.SizingPercent(40), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom}, + backgroundColor = CLAY_BG_STRIP, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + for i in 0.. 0.15 && cell.h > 0.15 { + clay.Text(fmt.tprintf("%d", layout_val.panels[i].panel_number), {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = CLAY_TEXT_SECONDARY}) + } + } + } + } + + if clay.UI(clay.ID("LayoutPageScroll"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 1}, + }) { + visible_rows: int = 6 + if visible_rows > layout_count { visible_rows = layout_count } + start := idx - visible_rows / 2 + if start < 0 { start = 0 } + end := start + visible_rows + if end > layout_count { end = layout_count } + if end - start < visible_rows { + start = end - visible_rows + if start < 0 { start = 0 } + } + + for i in start.. 0 { + bubble_idx = clamp_bubble_cursor(bubble_count, app.summary_opts.bubble_edit_cursor) + } + + if clay.UI(clay.ID("BubbleListHeader"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}, + }) { + clay_body_text(fmt.tprintf("Bubbles: %d", bubble_count), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + declare_button_small("btn_bubble_add", "Add") + } + + if clay.UI(clay.ID("BubbleListScroll"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 1}, + }) { + if bubble_count == 0 { + clay_muted_text("No bubbles for this panel. Click Add or Auto Place.") + } else { + for i in 0.. 25 { preview = preview[:25] } + if len(preview) == 0 { preview = "(empty)" } + + if clay.UI(clay.ID(fmt.tprintf("bubble_row_%d", i)))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(22)}, padding = {top = 2, left = 4}, childGap = 4, childAlignment = {y = .Center}}, + backgroundColor = clay.Color{0, 0, 0, 0}, + }) { + mark := " " + if is_selected { mark = "> " } + clay.Text(fmt.tprintf("%s[%s] %s", mark, bubble_type_name(b.type), preview), {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = row_color}) + if is_selected { + declare_button_small(fmt.tprintf("btn_bubble_delete_%d", i), "x") + } + } + } + } + } + + if bubble_count > 0 && bubble_idx < bubble_count { + selected := bubbles_for_panel[bubble_idx] + + if clay.UI(clay.ID("BubbleEditor"))({ + layout = {layoutDirection = .TopToBottom, padding = {top = 4, right = 8, bottom = 4, left = 8}, childGap = 4}, + backgroundColor = CLAY_BG_STRIP, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay_body_text(fmt.tprintf("Editing: %s", bubble_type_name(selected.type)), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + + if clay.UI(clay.ID("BubbleTypeRow"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 2}, + }) { + types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect} + for t in types { + btn_id := fmt.tprintf("btn_btype_%d", int(t)) + if clay.UI(clay.ID(btn_id))({ + layout = {sizing = {width = clay.SizingFit({min = 50, max = 20}), height = clay.SizingFixed(22)}, padding = {top = 2, right = 4, bottom = 2, left = 4}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = clay_color_for_bubble_type(selected.type == t), + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + clay.Text(bubble_type_name(t), {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = clay_text_color_for_bubble_type(selected.type == t)}) + } + } + } + + text_preview := selected.text + if len(text_preview) > 80 { text_preview = text_preview[:80] } + clay_muted_text(fmt.tprintf("text: %s", text_preview)) + } + } + } +} + +clay_color_for_bubble_type :: proc(active: bool) -> clay.Color { + if active { return CLAY_ACCENT } + return CLAY_BTN_DEFAULT +} + +clay_text_color_for_bubble_type :: proc(active: bool) -> clay.Color { + if active { return CLAY_TEXT_BRIGHT } + return CLAY_TEXT_SECONDARY +} + +// ─── Action Log (Clay) ───────────────────────────────────────────── +declare_action_log :: proc(app: ^GUI_App_State) { + if clay.UI(clay.ID("ActionLogCard"))(clay_card_style()) { + clay_title_text("Action Log") + + // Button row + if clay.UI(clay.ID("LogBtnRow"))({layout = clay_row_layout()}) { + declare_button_small("btn_log_reset", "Default") + declare_button_small("btn_log_report", "Report") + declare_button_small("btn_log_copy", "LogCopy") + declare_button_small("btn_log_diag", "DiagFile") + declare_button_small("btn_log_status_copy", "Copy") + declare_button_small("btn_log_clear", "Clear") + declare_button_small("btn_log_diag_copy", "Diag") + } + + order_label := "newest" + if app.log_oldest_first { order_label = "oldest" } + clay_muted_text(fmt.tprintf("View: %d lines, %s first", app.log_show_lines, order_label)) + + // Log entries + if clay.UI(clay.ID("LogScroll"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, + }) { + max_lines: int = int(app.log_show_lines) + if max_lines > app.action_log.count { + max_lines = app.action_log.count + } + if max_lines > len(app.action_log.entries) { + max_lines = len(app.action_log.entries) + } + now := rl.GetTime() + for line in 0 ..< max_lines { + idx := 0 + if app.log_oldest_first { + start_idx := app.action_log.count - max_lines + idx = (start_idx + line) % len(app.action_log.entries) + if idx < 0 { idx += len(app.action_log.entries) } + } else { + idx = (app.action_log.count - 1 - line) % len(app.action_log.entries) + if idx < 0 { idx += len(app.action_log.entries) } + } + age := now - app.action_log.entry_times[idx] + entry_text := fmt.tprintf("[%2.0fs] %s", age, app.action_log.entries[idx]) + entry_color := CLAY_TEXT_SECONDARY + if is_error_message(app.action_log.entries[idx]) { entry_color = CLAY_ERROR } + else if is_warning_message(app.action_log.entries[idx]) { entry_color = CLAY_WARNING } + else { entry_color = CLAY_SUCCESS } + clay_body_text(entry_text, color = entry_color, size = CLAY_FONT_SIZE_SM) + } + } + } +} \ No newline at end of file diff --git a/odin/src/gui/session_helpers.odin b/odin/src/gui/session_helpers.odin index 379538c..07ec14d 100644 --- a/odin/src/gui/session_helpers.odin +++ b/odin/src/gui/session_helpers.odin @@ -109,19 +109,6 @@ toggle_log_order_with_message :: proc(log_oldest_first: ^bool) -> string { 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: diff --git a/odin/src/gui/summary_views.odin b/odin/src/gui/summary_views.odin index fa0bcfe..b9d5a53 100644 --- a/odin/src/gui/summary_views.odin +++ b/odin/src/gui/summary_views.odin @@ -1,37 +1,8 @@ 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 @@ -45,27 +16,6 @@ ready_stage_count :: proc(controller: ui.App_Controller) -> (ready: int, total: 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" @@ -79,139 +29,6 @@ export_block_reason :: proc(state: core.Comic_State) -> string { 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.. 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.. 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 @@ -254,208 +71,6 @@ panel_by_flat_index :: proc(script: core.Comic_Script, panel_idx: int) -> (core. 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.. int { if layout_count <= 0 { return 0 @@ -470,17 +85,16 @@ clamp_layout_cursor :: proc(layout_count, cursor: int) -> int { } Layout_Validation_Result :: struct { - coverage_pct: f32, - missing_bindings: int, + coverage_pct: f32, + missing_bindings: int, bounds_violations: int, - total_panels: int, + total_panels: int, } validate_layout_page :: proc(layout: core.Page_Layout, panel_images: map[string]core.Panel_Image) -> Layout_Validation_Result { result: Layout_Validation_Result result.total_panels = len(layout.panels) - // Coverage: sum of panel cell areas vs page area page_area := f32(layout.width) * f32(layout.height) covered_area: f32 = 0 for p in layout.panels { @@ -492,14 +106,12 @@ validate_layout_page :: proc(layout: core.Page_Layout, panel_images: map[string] result.coverage_pct = covered_area / page_area * 100 } - // Missing bindings: panels without corresponding images for p in layout.panels { if _, has := panel_images[p.panel_id]; !has { result.missing_bindings += 1 } } - // Bounds violations: cells that extend outside [0,1] range for p in layout.panels { c := p.layout_cell if c.x < 0 || c.y < 0 || c.x+c.w > 1.001 || c.y+c.h > 1.001 { @@ -508,149 +120,4 @@ validate_layout_page :: proc(layout: core.Page_Layout, panel_images: map[string] } return result -} - -draw_validation_badge :: proc(x, y, w: i32, label: string, ok: bool) { - bg := UNREADY_BG - border := UNREADY_BORDER - fg := WARNING - if ok { - bg = READY_BG - border = READY_BORDER - fg = SUCCESS - } - rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 22} - rl.DrawRectangleRounded(rec, RADIUS_CHIP, 6, bg) - rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 6, 1.0, border) - draw_text_fitted(label, x+8, y+5, 11, int(w-16), 6, fg) -} - -draw_layout_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) -> (regen_clicked: bool, new_cursor: int) { - new_cursor = cursor - regen_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, "Layout Detail") - draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34}) - layout_count := len(controller.state.page_layouts) - if layout_count == 0 { - draw_summary_line(x+18, y+46, "No layouts yet. Use Layout Auto after panels.", SUMMARY_HINT) - return - } - idx := clamp_layout_cursor(layout_count, cursor) - layout := controller.state.page_layouts[idx] - - // Header line with status - draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d • pattern: %s • %d panels", idx+1, layout_count, layout.pattern_id, len(layout.panels)), SUMMARY_ACCENT) - - // Validation badges - val := validate_layout_page(layout, controller.state.panel_images) - coverage_ok := val.coverage_pct >= 80 && val.coverage_pct <= 105 - bindings_ok := val.missing_bindings == 0 - bounds_ok := val.bounds_violations == 0 - draw_validation_badge(x+18, y+68, 100, fmt.tprintf("Cov: %.0f%%", val.coverage_pct), coverage_ok) - draw_validation_badge(x+124, y+68, 110, fmt.tprintf("Bind: %d miss", val.missing_bindings), bindings_ok) - draw_validation_badge(x+240, y+68, 100, fmt.tprintf("Bounds: %d", val.bounds_violations), bounds_ok) - - // Regenerate button - btn_rec := rl.Rectangle{x = f32(x + w - 100), y = f32(y+42), width = 80, height = 24} - draw_small_button_state(btn_rec, "Regen", true) - if button_clicked(btn_rec) { - regen_clicked = true - } - - // Layout dimensions - draw_summary_subline(x+18, y+94, fmt.tprintf("size: %d x %d", layout.width, layout.height), SUMMARY_SUBLINE) - - // ── Mini wireframe preview ───────────────────────────────── - preview_x := x + 18 - preview_y := y + 114 - preview_max_w: f32 = f32(w) * 0.4 - preview_max_h: f32 = f32(h - 100) - if preview_max_h < 40 { - preview_max_h = 40 - } - - // Scale to fit - lw := f32(layout.width) - lh := f32(layout.height) - if lw < 1 { lw = 1 } - if lh < 1 { lh = 1 } - scale_x := preview_max_w / lw - scale_y := preview_max_h / lh - scale := scale_x - if scale_y < scale { - scale = scale_y - } - pw := lw * scale - ph := lh * scale - - // Draw page outline - page_rec := rl.Rectangle{x = f32(preview_x), y = f32(preview_y), width = pw, height = ph} - rl.DrawRectangleRounded(page_rec, 0.02, 4, BG_STRIP) - rl.DrawRectangleRoundedLinesEx(page_rec, 0.02, 4, 1.0, BORDER_CARD) - - // Draw each panel cell - for i in 0.. 30 && ch > 14 { - draw_text_fitted(num_label, i32(cx + inset + 3), i32(cy + inset + 2), label_sz, int(cw - inset*2 - 4), 5, TEXT_SECONDARY) - } - } - - // ── Page list (right side) ───────────────────────────────── - list_x := x + i32(preview_max_w) + 36 - list_y := y + 114 - list_w := w - i32(preview_max_w) - 54 - row_h: i32 = 18 - rows: i32 = (h - 126) / row_h - if rows < 1 { - rows = 1 - } - start := idx - int(rows/2) - if start < 0 { - start = 0 - } - end := start + int(rows) - if end > layout_count { - end = layout_count - start = end - int(rows) - if start < 0 { - start = 0 - } - } - line: i32 = 0 - for i in start.. string { if px_per_char <= 0 { @@ -15,9 +14,4 @@ fit_text_for_width :: proc(text: string, width_px, px_per_char: int) -> string { 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) -} +} \ No newline at end of file diff --git a/odin/src/gui/theme.odin b/odin/src/gui/theme.odin index 7ccb0f9..25955eb 100644 --- a/odin/src/gui/theme.odin +++ b/odin/src/gui/theme.odin @@ -2,209 +2,4 @@ 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 +BG_BASE :: rl.Color{13, 13, 18, 255} \ No newline at end of file diff --git a/odin/src/gui/widgets.odin b/odin/src/gui/widgets.odin deleted file mode 100644 index c2eb67c..0000000 --- a/odin/src/gui/widgets.odin +++ /dev/null @@ -1,76 +0,0 @@ -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) -} diff --git a/odin/vendor/clay b/odin/vendor/clay new file mode 160000 index 0000000..e6cc369 --- /dev/null +++ b/odin/vendor/clay @@ -0,0 +1 @@ +Subproject commit e6cc36941ab2af5d81107617039d6f527a1c660b