diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2cae70b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# comic-odin / comicsroll + +Dual-language repo: **React/TypeScript frontend** (`src/`) + **Odin native desktop app** (`odin/`). + +## Build & Test — Odin (`odin/`) + +```bash +# Build debug binary +./odin/build.sh # builds C deps + odin src/app + +# Or directly: +odin build odin/src/app -target:linux_amd64 -collection:clay=$(pwd)/odin/vendor/clay/bindings/odin/clay-odin + +# Run all tests +odin test odin/tests -target:linux_amd64 -collection:clay=$(pwd)/odin/vendor/clay/bindings/odin/clay-odin + +# Run single test +odin test odin/tests -target:linux_amd64 -collection:clay=$(pwd)/odin/vendor/clay/bindings/odin/clay-odin -define:ODIN_TEST_NAMES=tests. + +# Run binary +./bin/comic_odin gui # Raylib GUI +./bin/comic_odin tui # Terminal TUI +``` + +## Build & Test — React Frontend (`src/`) + +```bash +npm run dev # vite dev server +npm run build # tsc -b && vite build +npm run lint # eslint . (strictTypeChecked, no any) +npm run typecheck # tsc --noEmit +npm run test # vitest run (jsdom, globals) +npm run test:ui # vitest --ui +``` + +## Architecture + +### React (Vite 8, React 19, TypeScript 6, Tailwind 3, Zustand 5) +- **No URL routing** — manual `useState` + zustand `workflow.currentStep` drives component switching in `App.tsx`. +- **Single zustand store** (`src/store/comicStore.ts`) with `persist` middleware. +- **Two API services**: DeepSeek (via OpenAI SDK for script gen) and fal.ai (via `@fal-ai/client` for images). +- **`@` path alias** → `./src`. +- **`verbatimModuleSyntax: true`** — type-only imports need `import type`. +- **`noUnusedLocals`, `noUnusedParameters` both `true`**. +- `framer-motion`, `fabric`, `react-router-dom` installed but unused. +- No test files exist yet (vitest configured and ready). + +### Odin (Raylib + Clay layout engine + osdialog) +- **Single binary**, entry: `odin/src/app/main.odin` → `run_cli_from_process_args()` → `.Gui` → `gui.run_gui_app()` +- **8 source packages**: `app` (entry), `core` (domain), `shared` (config/errors), `adapters` (DeepSeek/fal/export/storage), `ui` (controller/screens/jobs), `gui` (Raylib+Clay UI), `osdialog` (file dialogs). +- Clay imported as `import clay "clay:."` via collection `-collection:clay=vendor/clay/bindings/odin/clay-odin`. +- **ols.json** defines the `clay` collection path. + +### Workflow state machine +``` +Story_Input → Generating_Script → Script_Review → Character_Setup → +Generating_Panels → Layout → Speech_Bubbles → Complete +``` +Defined in `core/workflow.odin` (`can_transition()`). In the React app, zustand's `workflow.currentStep` mirrors this. + +## Clay Layout Gotchas (Critical) + +- **`SizingPercent()` expects 0–1, not 0–100.** `SizingPercent(55)` = 5500% (off-screen). Use `SizingPercent(0.55)`. +- **`SizingFit({})` cards + `SizingGrow({})` children = invisible.** A Fit-height card computes its height from children's *initial* CloseElement sizes. Grow children have initial height 0, so the card stays tiny. If a card contains a Grow-height child (scroll list, wireframe), override the card height to `SizingGrow({})`. +- **`backgroundColor` on image elements generates a covering RECTANGLE.** Remove `backgroundColor` from image elements to let the IMAGE command be visible. +- **Texture pointers** (`rawptr(tex_ptr)`) require `reserve(&app.panel_textures, N)` after map creation to avoid pointer invalidation on reallocation. +- **Clay v0.14** is vendored at `vendor/clay/`. The Odin binding is at `vendor/clay/bindings/odin/clay-odin/clay.odin`. + +## Raygui Integration + +- `rl.GuiTextBox` overlays on top of Clay layout. After Clay renders, get element bounds via `clay.GetElementData(id)`. +- `GuiTextBox` returns `true` on click — NOT on text change. Compare buffer length after call to detect edits. +- `rl.EndScissorMode()` must be called before `rl.GuiTextBox` and after to clear stale scissor state. + +## Memory + +- **Odin**: `persistent_pool` (256KB) in `gui/runtime.odin` for strings that must survive temp allocator resets. +- **Odin**: All `delete()`'d string defaults in `new_initial_state()` must use `strings.clone()` to avoid `free(): invalid pointer` crashes. +- **React**: Zustand store persisted to localStorage (`comic-creator-storage`). `partialize` excludes ephemeral state. + +## API Keys (.env) + +``` +DEEPSEEK_API_KEY=sk-... +FAL_API_KEY=... +VITE_DEEPSEEK_API_KEY=sk-... +VITE_FAL_KEY=... +``` diff --git a/app b/app new file mode 100755 index 0000000..0ff0f9d Binary files /dev/null and b/app differ diff --git a/odin/app b/odin/app new file mode 100755 index 0000000..ada0189 Binary files /dev/null and b/odin/app differ diff --git a/odin/assets/2gcegsLrN8TD4j03CyxLo.jpg b/odin/assets/2gcegsLrN8TD4j03CyxLo.jpg new file mode 100644 index 0000000..684de30 Binary files /dev/null and b/odin/assets/2gcegsLrN8TD4j03CyxLo.jpg differ diff --git a/odin/assets/2pEjv5xq_W3sLl_eBKXt5.jpg b/odin/assets/2pEjv5xq_W3sLl_eBKXt5.jpg new file mode 100644 index 0000000..f4b59ff Binary files /dev/null and b/odin/assets/2pEjv5xq_W3sLl_eBKXt5.jpg differ diff --git a/odin/assets/3Jl1TM8UmRdrHnD8orOBY.jpg b/odin/assets/3Jl1TM8UmRdrHnD8orOBY.jpg new file mode 100644 index 0000000..866856c Binary files /dev/null and b/odin/assets/3Jl1TM8UmRdrHnD8orOBY.jpg differ diff --git a/odin/assets/46l-wNnIP46lkFUR3SEtp.jpg b/odin/assets/46l-wNnIP46lkFUR3SEtp.jpg new file mode 100644 index 0000000..baf9114 Binary files /dev/null and b/odin/assets/46l-wNnIP46lkFUR3SEtp.jpg differ diff --git a/odin/assets/55G16W3E5x8x73czZLob8.jpg b/odin/assets/55G16W3E5x8x73czZLob8.jpg new file mode 100644 index 0000000..0756fc5 Binary files /dev/null and b/odin/assets/55G16W3E5x8x73czZLob8.jpg differ diff --git a/odin/assets/6U3oQ9eoLsNeGHCCnbx52.jpg b/odin/assets/6U3oQ9eoLsNeGHCCnbx52.jpg new file mode 100644 index 0000000..dae8d90 Binary files /dev/null and b/odin/assets/6U3oQ9eoLsNeGHCCnbx52.jpg differ diff --git a/odin/assets/8WnwlSeWALOOZlUUXtB35.jpg b/odin/assets/8WnwlSeWALOOZlUUXtB35.jpg new file mode 100644 index 0000000..a74f54c Binary files /dev/null and b/odin/assets/8WnwlSeWALOOZlUUXtB35.jpg differ diff --git a/odin/assets/9A2y5RiOc-4-CDb7FDX-z.jpg b/odin/assets/9A2y5RiOc-4-CDb7FDX-z.jpg new file mode 100644 index 0000000..358891e Binary files /dev/null and b/odin/assets/9A2y5RiOc-4-CDb7FDX-z.jpg differ diff --git a/odin/assets/Bdv2I8wkmplzmUHH3uiwU.jpg b/odin/assets/Bdv2I8wkmplzmUHH3uiwU.jpg new file mode 100644 index 0000000..bb65b45 Binary files /dev/null and b/odin/assets/Bdv2I8wkmplzmUHH3uiwU.jpg differ diff --git a/odin/assets/Bod0ehqYOHm7IIM_8TrsN.jpg b/odin/assets/Bod0ehqYOHm7IIM_8TrsN.jpg new file mode 100644 index 0000000..e6cc70c Binary files /dev/null and b/odin/assets/Bod0ehqYOHm7IIM_8TrsN.jpg differ diff --git a/odin/assets/CM_buwj12cDqtePWooe79.jpg b/odin/assets/CM_buwj12cDqtePWooe79.jpg new file mode 100644 index 0000000..17b3058 Binary files /dev/null and b/odin/assets/CM_buwj12cDqtePWooe79.jpg differ diff --git a/odin/assets/DZ5gMnv66X5-QwnG11lYe.jpg b/odin/assets/DZ5gMnv66X5-QwnG11lYe.jpg new file mode 100644 index 0000000..806792f Binary files /dev/null and b/odin/assets/DZ5gMnv66X5-QwnG11lYe.jpg differ diff --git a/odin/assets/Dz6fu_IwR0-tvOTWO7ccW.jpg b/odin/assets/Dz6fu_IwR0-tvOTWO7ccW.jpg new file mode 100644 index 0000000..796fe34 Binary files /dev/null and b/odin/assets/Dz6fu_IwR0-tvOTWO7ccW.jpg differ diff --git a/odin/assets/EX8QU_eu27eeV3Oalyerw.jpg b/odin/assets/EX8QU_eu27eeV3Oalyerw.jpg new file mode 100644 index 0000000..098f796 Binary files /dev/null and b/odin/assets/EX8QU_eu27eeV3Oalyerw.jpg differ diff --git a/odin/assets/F3y1b1f3EzzPpXYI6DeY1.jpg b/odin/assets/F3y1b1f3EzzPpXYI6DeY1.jpg new file mode 100644 index 0000000..6643159 Binary files /dev/null and b/odin/assets/F3y1b1f3EzzPpXYI6DeY1.jpg differ diff --git a/odin/assets/FJVw0TPgGJSyB9GcHi9RZ.jpg b/odin/assets/FJVw0TPgGJSyB9GcHi9RZ.jpg new file mode 100644 index 0000000..a88a0cb Binary files /dev/null and b/odin/assets/FJVw0TPgGJSyB9GcHi9RZ.jpg differ diff --git a/odin/assets/GYgTd1B7TcieGrp5TGaii.jpg b/odin/assets/GYgTd1B7TcieGrp5TGaii.jpg new file mode 100644 index 0000000..6103aad Binary files /dev/null and b/odin/assets/GYgTd1B7TcieGrp5TGaii.jpg differ diff --git a/odin/assets/HWqLtvvJLcNqpasvlUq_K.jpg b/odin/assets/HWqLtvvJLcNqpasvlUq_K.jpg new file mode 100644 index 0000000..617304f Binary files /dev/null and b/odin/assets/HWqLtvvJLcNqpasvlUq_K.jpg differ diff --git a/odin/assets/IK_zAvkpLPuuwYh4jEtAu.jpg b/odin/assets/IK_zAvkpLPuuwYh4jEtAu.jpg new file mode 100644 index 0000000..5c409d9 Binary files /dev/null and b/odin/assets/IK_zAvkpLPuuwYh4jEtAu.jpg differ diff --git a/odin/assets/J_vkLN0IPDteP6le9gCwa.jpg b/odin/assets/J_vkLN0IPDteP6le9gCwa.jpg new file mode 100644 index 0000000..4020a7d Binary files /dev/null and b/odin/assets/J_vkLN0IPDteP6le9gCwa.jpg differ diff --git a/odin/assets/K09uo89t9OA_Ac5-03T60.jpg b/odin/assets/K09uo89t9OA_Ac5-03T60.jpg new file mode 100644 index 0000000..9889b89 Binary files /dev/null and b/odin/assets/K09uo89t9OA_Ac5-03T60.jpg differ diff --git a/odin/assets/KbiWO4HRdHmFc7RVk8tz3.jpg b/odin/assets/KbiWO4HRdHmFc7RVk8tz3.jpg new file mode 100644 index 0000000..f8f53f4 Binary files /dev/null and b/odin/assets/KbiWO4HRdHmFc7RVk8tz3.jpg differ diff --git a/odin/assets/KoX2HMxt-NbNus2jzUJhk.jpg b/odin/assets/KoX2HMxt-NbNus2jzUJhk.jpg new file mode 100644 index 0000000..dc6932f Binary files /dev/null and b/odin/assets/KoX2HMxt-NbNus2jzUJhk.jpg differ diff --git a/odin/assets/L8rCWVxqZoeMA-v4INxF9.jpg b/odin/assets/L8rCWVxqZoeMA-v4INxF9.jpg new file mode 100644 index 0000000..f556890 Binary files /dev/null and b/odin/assets/L8rCWVxqZoeMA-v4INxF9.jpg differ diff --git a/odin/assets/LdVN2fUfluDU8AnEnhVdO.jpg b/odin/assets/LdVN2fUfluDU8AnEnhVdO.jpg new file mode 100644 index 0000000..ff52fe5 Binary files /dev/null and b/odin/assets/LdVN2fUfluDU8AnEnhVdO.jpg differ diff --git a/odin/assets/M18H8gzQr78AyVbHqQRvp.jpg b/odin/assets/M18H8gzQr78AyVbHqQRvp.jpg new file mode 100644 index 0000000..2b81a56 Binary files /dev/null and b/odin/assets/M18H8gzQr78AyVbHqQRvp.jpg differ diff --git a/odin/assets/MIB7_CUoSOtcWkGVDZMNf.jpg b/odin/assets/MIB7_CUoSOtcWkGVDZMNf.jpg new file mode 100644 index 0000000..64076bb Binary files /dev/null and b/odin/assets/MIB7_CUoSOtcWkGVDZMNf.jpg differ diff --git a/odin/assets/OjM7dpUq_b5WV1jnOKTx-.jpg b/odin/assets/OjM7dpUq_b5WV1jnOKTx-.jpg new file mode 100644 index 0000000..3911c10 Binary files /dev/null and b/odin/assets/OjM7dpUq_b5WV1jnOKTx-.jpg differ diff --git a/odin/assets/PKUrqw26x0bictBGfKUoo.jpg b/odin/assets/PKUrqw26x0bictBGfKUoo.jpg new file mode 100644 index 0000000..383a743 Binary files /dev/null and b/odin/assets/PKUrqw26x0bictBGfKUoo.jpg differ diff --git a/odin/assets/PMnRpKSmJeINBDsLRuxDw.jpg b/odin/assets/PMnRpKSmJeINBDsLRuxDw.jpg new file mode 100644 index 0000000..6fc8cfd Binary files /dev/null and b/odin/assets/PMnRpKSmJeINBDsLRuxDw.jpg differ diff --git a/odin/assets/PgqOdRQcUaAEskxy7SoU3.jpg b/odin/assets/PgqOdRQcUaAEskxy7SoU3.jpg new file mode 100644 index 0000000..cbc31b9 Binary files /dev/null and b/odin/assets/PgqOdRQcUaAEskxy7SoU3.jpg differ diff --git a/odin/assets/QRzzHh3Z_lwzambb0_Gdy.jpg b/odin/assets/QRzzHh3Z_lwzambb0_Gdy.jpg new file mode 100644 index 0000000..6d6a937 Binary files /dev/null and b/odin/assets/QRzzHh3Z_lwzambb0_Gdy.jpg differ diff --git a/odin/assets/QgVb2D2EAkF46XgyNG05i.jpg b/odin/assets/QgVb2D2EAkF46XgyNG05i.jpg new file mode 100644 index 0000000..329e2a1 Binary files /dev/null and b/odin/assets/QgVb2D2EAkF46XgyNG05i.jpg differ diff --git a/odin/assets/RTuYLiKv-X7NCeXpD6fER.jpg b/odin/assets/RTuYLiKv-X7NCeXpD6fER.jpg new file mode 100644 index 0000000..1553cba Binary files /dev/null and b/odin/assets/RTuYLiKv-X7NCeXpD6fER.jpg differ diff --git a/odin/assets/SFUWZB370vG5UR_7E4QZx.jpg b/odin/assets/SFUWZB370vG5UR_7E4QZx.jpg new file mode 100644 index 0000000..2771603 Binary files /dev/null and b/odin/assets/SFUWZB370vG5UR_7E4QZx.jpg differ diff --git a/odin/assets/T49RL0lKhmhAKsM8JX0rd.jpg b/odin/assets/T49RL0lKhmhAKsM8JX0rd.jpg new file mode 100644 index 0000000..78b637a Binary files /dev/null and b/odin/assets/T49RL0lKhmhAKsM8JX0rd.jpg differ diff --git a/odin/assets/UUd1kSUH9UPmJWzYmX21U.jpg b/odin/assets/UUd1kSUH9UPmJWzYmX21U.jpg new file mode 100644 index 0000000..5d6386f Binary files /dev/null and b/odin/assets/UUd1kSUH9UPmJWzYmX21U.jpg differ diff --git a/odin/assets/UeOTNnJLlD1l1FqEQ_E8d.jpg b/odin/assets/UeOTNnJLlD1l1FqEQ_E8d.jpg new file mode 100644 index 0000000..7829c59 Binary files /dev/null and b/odin/assets/UeOTNnJLlD1l1FqEQ_E8d.jpg differ diff --git a/odin/assets/VG0prZpMFnkfgYlrL4iZD.jpg b/odin/assets/VG0prZpMFnkfgYlrL4iZD.jpg new file mode 100644 index 0000000..ee9da3a Binary files /dev/null and b/odin/assets/VG0prZpMFnkfgYlrL4iZD.jpg differ diff --git a/odin/assets/W9Mhstcb_7ka6_YI9ZiTT.jpg b/odin/assets/W9Mhstcb_7ka6_YI9ZiTT.jpg new file mode 100644 index 0000000..83d2b22 Binary files /dev/null and b/odin/assets/W9Mhstcb_7ka6_YI9ZiTT.jpg differ diff --git a/odin/assets/XWr3AayLIwUr-stSPFDZr.jpg b/odin/assets/XWr3AayLIwUr-stSPFDZr.jpg new file mode 100644 index 0000000..cb49a45 Binary files /dev/null and b/odin/assets/XWr3AayLIwUr-stSPFDZr.jpg differ diff --git a/odin/assets/YccGhV3Yt1kgNCIfTjDFt.jpg b/odin/assets/YccGhV3Yt1kgNCIfTjDFt.jpg new file mode 100644 index 0000000..b95f803 Binary files /dev/null and b/odin/assets/YccGhV3Yt1kgNCIfTjDFt.jpg differ diff --git a/odin/assets/Z3cMhd91v6ThsQl_Vn-ph.jpg b/odin/assets/Z3cMhd91v6ThsQl_Vn-ph.jpg new file mode 100644 index 0000000..b32f05b Binary files /dev/null and b/odin/assets/Z3cMhd91v6ThsQl_Vn-ph.jpg differ diff --git a/odin/assets/be1K0ROeKdOueIMNXdApi.jpg b/odin/assets/be1K0ROeKdOueIMNXdApi.jpg new file mode 100644 index 0000000..8f117c6 Binary files /dev/null and b/odin/assets/be1K0ROeKdOueIMNXdApi.jpg differ diff --git a/odin/assets/c_SZNcI8g7wJpBniBANi_.jpg b/odin/assets/c_SZNcI8g7wJpBniBANi_.jpg new file mode 100644 index 0000000..33830b8 Binary files /dev/null and b/odin/assets/c_SZNcI8g7wJpBniBANi_.jpg differ diff --git a/odin/assets/d8XYp2Yyb5uSK70aY_i5L.jpg b/odin/assets/d8XYp2Yyb5uSK70aY_i5L.jpg new file mode 100644 index 0000000..23a46d2 Binary files /dev/null and b/odin/assets/d8XYp2Yyb5uSK70aY_i5L.jpg differ diff --git a/odin/assets/dBgJnQBWa_5KrmRhwPzfy.jpg b/odin/assets/dBgJnQBWa_5KrmRhwPzfy.jpg new file mode 100644 index 0000000..5fa8999 Binary files /dev/null and b/odin/assets/dBgJnQBWa_5KrmRhwPzfy.jpg differ diff --git a/odin/assets/df6YUrSA3Rey-HoC5_VE8.jpg b/odin/assets/df6YUrSA3Rey-HoC5_VE8.jpg new file mode 100644 index 0000000..e8c5068 Binary files /dev/null and b/odin/assets/df6YUrSA3Rey-HoC5_VE8.jpg differ diff --git a/odin/assets/dlK6N_EWhA1pyEvglDhvN.jpg b/odin/assets/dlK6N_EWhA1pyEvglDhvN.jpg new file mode 100644 index 0000000..ed74d56 Binary files /dev/null and b/odin/assets/dlK6N_EWhA1pyEvglDhvN.jpg differ diff --git a/odin/assets/fTsq0Gg_P9LY2U0JNB4am.jpg b/odin/assets/fTsq0Gg_P9LY2U0JNB4am.jpg new file mode 100644 index 0000000..86187ef Binary files /dev/null and b/odin/assets/fTsq0Gg_P9LY2U0JNB4am.jpg differ diff --git a/odin/assets/flivfVOq4rymbyzGPRjum.jpg b/odin/assets/flivfVOq4rymbyzGPRjum.jpg new file mode 100644 index 0000000..88de001 Binary files /dev/null and b/odin/assets/flivfVOq4rymbyzGPRjum.jpg differ diff --git a/odin/assets/fonts/AdwaitaMono-Regular.ttf b/odin/assets/fonts/AdwaitaMono-Regular.ttf new file mode 100644 index 0000000..7f95a67 Binary files /dev/null and b/odin/assets/fonts/AdwaitaMono-Regular.ttf differ diff --git a/odin/assets/fonts/AdwaitaSans-Regular.ttf b/odin/assets/fonts/AdwaitaSans-Regular.ttf new file mode 100644 index 0000000..6fcafd9 Binary files /dev/null and b/odin/assets/fonts/AdwaitaSans-Regular.ttf differ diff --git a/odin/assets/hQPWF8gu4b6zhG-MibArE.jpg b/odin/assets/hQPWF8gu4b6zhG-MibArE.jpg new file mode 100644 index 0000000..27b0139 Binary files /dev/null and b/odin/assets/hQPWF8gu4b6zhG-MibArE.jpg differ diff --git a/odin/assets/hk_V5i0kctqjcr-eeMzOg.jpg b/odin/assets/hk_V5i0kctqjcr-eeMzOg.jpg new file mode 100644 index 0000000..cdbdca4 Binary files /dev/null and b/odin/assets/hk_V5i0kctqjcr-eeMzOg.jpg differ diff --git a/odin/assets/hwU3YX9GljfeCVCPq84yI.jpg b/odin/assets/hwU3YX9GljfeCVCPq84yI.jpg new file mode 100644 index 0000000..cad70bd Binary files /dev/null and b/odin/assets/hwU3YX9GljfeCVCPq84yI.jpg differ diff --git a/odin/assets/i2RsodqUYTIMPzONyEYCx.jpg b/odin/assets/i2RsodqUYTIMPzONyEYCx.jpg new file mode 100644 index 0000000..3d7bc12 Binary files /dev/null and b/odin/assets/i2RsodqUYTIMPzONyEYCx.jpg differ diff --git a/odin/assets/izlV0OeemZJIArgzJkvY7.jpg b/odin/assets/izlV0OeemZJIArgzJkvY7.jpg new file mode 100644 index 0000000..716d724 Binary files /dev/null and b/odin/assets/izlV0OeemZJIArgzJkvY7.jpg differ diff --git a/odin/assets/jTMceH-aye5tdqVL5ULqe.jpg b/odin/assets/jTMceH-aye5tdqVL5ULqe.jpg new file mode 100644 index 0000000..f244fb8 Binary files /dev/null and b/odin/assets/jTMceH-aye5tdqVL5ULqe.jpg differ diff --git a/odin/assets/jWjNSjl862ZHNsBII6PK0.jpg b/odin/assets/jWjNSjl862ZHNsBII6PK0.jpg new file mode 100644 index 0000000..e1d7130 Binary files /dev/null and b/odin/assets/jWjNSjl862ZHNsBII6PK0.jpg differ diff --git a/odin/assets/kTupPN30lFeuPRVwWELwI.jpg b/odin/assets/kTupPN30lFeuPRVwWELwI.jpg new file mode 100644 index 0000000..f3a43d3 Binary files /dev/null and b/odin/assets/kTupPN30lFeuPRVwWELwI.jpg differ diff --git a/odin/assets/ky3eRf3sxaOBGUNWTSZDx.jpg b/odin/assets/ky3eRf3sxaOBGUNWTSZDx.jpg new file mode 100644 index 0000000..2f5ad7a Binary files /dev/null and b/odin/assets/ky3eRf3sxaOBGUNWTSZDx.jpg differ diff --git a/odin/assets/lLXSWYxVFUhmvOcxfuVoI.jpg b/odin/assets/lLXSWYxVFUhmvOcxfuVoI.jpg new file mode 100644 index 0000000..42a1ccf Binary files /dev/null and b/odin/assets/lLXSWYxVFUhmvOcxfuVoI.jpg differ diff --git a/odin/assets/n7KVB2LeoAIEmtQMbnOjp.jpg b/odin/assets/n7KVB2LeoAIEmtQMbnOjp.jpg new file mode 100644 index 0000000..1a4ef80 Binary files /dev/null and b/odin/assets/n7KVB2LeoAIEmtQMbnOjp.jpg differ diff --git a/odin/assets/naOK8Gd8PESgsQaHvP2xx.jpg b/odin/assets/naOK8Gd8PESgsQaHvP2xx.jpg new file mode 100644 index 0000000..ae6e0eb Binary files /dev/null and b/odin/assets/naOK8Gd8PESgsQaHvP2xx.jpg differ diff --git a/odin/assets/p2RXMpWkJE9u2T2nz-oJ6.jpg b/odin/assets/p2RXMpWkJE9u2T2nz-oJ6.jpg new file mode 100644 index 0000000..ed74d56 Binary files /dev/null and b/odin/assets/p2RXMpWkJE9u2T2nz-oJ6.jpg differ diff --git a/odin/assets/p_aV09rkFmmsTQLQmHX6I.jpg b/odin/assets/p_aV09rkFmmsTQLQmHX6I.jpg new file mode 100644 index 0000000..c18e895 Binary files /dev/null and b/odin/assets/p_aV09rkFmmsTQLQmHX6I.jpg differ diff --git a/odin/assets/pfwQSWsEGG22ezSjEor1S.jpg b/odin/assets/pfwQSWsEGG22ezSjEor1S.jpg new file mode 100644 index 0000000..083420b Binary files /dev/null and b/odin/assets/pfwQSWsEGG22ezSjEor1S.jpg differ diff --git a/odin/assets/qWmJ1EcBuGyIkWc2Cry4V.jpg b/odin/assets/qWmJ1EcBuGyIkWc2Cry4V.jpg new file mode 100644 index 0000000..d482be6 Binary files /dev/null and b/odin/assets/qWmJ1EcBuGyIkWc2Cry4V.jpg differ diff --git a/odin/assets/qspLI2_R3yS_PdSI_hD7T.jpg b/odin/assets/qspLI2_R3yS_PdSI_hD7T.jpg new file mode 100644 index 0000000..0f95cb4 Binary files /dev/null and b/odin/assets/qspLI2_R3yS_PdSI_hD7T.jpg differ diff --git a/odin/assets/shhHdWzvEUXFEFa0WpBUb.jpg b/odin/assets/shhHdWzvEUXFEFa0WpBUb.jpg new file mode 100644 index 0000000..33d0aee Binary files /dev/null and b/odin/assets/shhHdWzvEUXFEFa0WpBUb.jpg differ diff --git a/odin/assets/sydqHbpgUP3AbEB2KRIe1.jpg b/odin/assets/sydqHbpgUP3AbEB2KRIe1.jpg new file mode 100644 index 0000000..e25006b Binary files /dev/null and b/odin/assets/sydqHbpgUP3AbEB2KRIe1.jpg differ diff --git a/odin/assets/tY5opo8r3AHg7D1PVClJz.jpg b/odin/assets/tY5opo8r3AHg7D1PVClJz.jpg new file mode 100644 index 0000000..478015d Binary files /dev/null and b/odin/assets/tY5opo8r3AHg7D1PVClJz.jpg differ diff --git a/odin/assets/uNO4KmIsLeN9asYlpG1aW.jpg b/odin/assets/uNO4KmIsLeN9asYlpG1aW.jpg new file mode 100644 index 0000000..97d6dec Binary files /dev/null and b/odin/assets/uNO4KmIsLeN9asYlpG1aW.jpg differ diff --git a/odin/assets/vr0d18GJMCxRnnCBFWKU0.jpg b/odin/assets/vr0d18GJMCxRnnCBFWKU0.jpg new file mode 100644 index 0000000..20bef36 Binary files /dev/null and b/odin/assets/vr0d18GJMCxRnnCBFWKU0.jpg differ diff --git a/odin/assets/wN1Dhg_HnvJ0oU5xC86UU.jpg b/odin/assets/wN1Dhg_HnvJ0oU5xC86UU.jpg new file mode 100644 index 0000000..014da88 Binary files /dev/null and b/odin/assets/wN1Dhg_HnvJ0oU5xC86UU.jpg differ diff --git a/odin/assets/wUA9Y90MDARwLJQMKM_iE.jpg b/odin/assets/wUA9Y90MDARwLJQMKM_iE.jpg new file mode 100644 index 0000000..3cd3233 Binary files /dev/null and b/odin/assets/wUA9Y90MDARwLJQMKM_iE.jpg differ diff --git a/odin/assets/ww-HB0SrT8-O-nQzmfHZY.jpg b/odin/assets/ww-HB0SrT8-O-nQzmfHZY.jpg new file mode 100644 index 0000000..44f224c Binary files /dev/null and b/odin/assets/ww-HB0SrT8-O-nQzmfHZY.jpg differ diff --git a/odin/assets/x4mR37Mj9XHaED4Wc25IL.jpg b/odin/assets/x4mR37Mj9XHaED4Wc25IL.jpg new file mode 100644 index 0000000..2908b5c Binary files /dev/null and b/odin/assets/x4mR37Mj9XHaED4Wc25IL.jpg differ diff --git a/odin/assets/y7m7vcnEIFC8hj8KmDMN_.jpg b/odin/assets/y7m7vcnEIFC8hj8KmDMN_.jpg new file mode 100644 index 0000000..044e339 Binary files /dev/null and b/odin/assets/y7m7vcnEIFC8hj8KmDMN_.jpg differ diff --git a/odin/assets/yIuc7V6dQYmvn0c6Ej2ms.jpg b/odin/assets/yIuc7V6dQYmvn0c6Ej2ms.jpg new file mode 100644 index 0000000..4e01039 Binary files /dev/null and b/odin/assets/yIuc7V6dQYmvn0c6Ej2ms.jpg differ diff --git a/odin/comic.pdf b/odin/comic.pdf index ef8fcdd..7c01ff6 100644 Binary files a/odin/comic.pdf and b/odin/comic.pdf differ diff --git a/odin/gui_diagnostics.txt b/odin/gui_diagnostics.txt index b98c245..32b8b7f 100644 --- a/odin/gui_diagnostics.txt +++ b/odin/gui_diagnostics.txt @@ -1 +1 @@ -screen=Story workflow=Story_Input next=generate script local dirty=no autosave=yes(20s) content=pages:0,panels:0,layouts:0,chars:0 paths=P:yes,E:yes project=./gui_project.comic.json export=./gui_export.pdf log=0,newest uptime=38.2s \ No newline at end of file +screen=Characters workflow=Complete next=export pdf dirty=no autosave=yes(20s) content=pages:2,panels:12,layouts:2,chars:2 paths=P:yes,E:yes project=./gui_project.comic.json export=comic.pdf log=6,newest uptime=77.6s \ No newline at end of file diff --git a/odin/gui_export.pdf b/odin/gui_export.pdf index ef8fcdd..2089eff 100644 Binary files a/odin/gui_export.pdf and b/odin/gui_export.pdf differ diff --git a/odin/gui_project.comic.json b/odin/gui_project.comic.json index eb30787..014ce57 100644 --- a/odin/gui_project.comic.json +++ b/odin/gui_project.comic.json @@ -9,40 +9,533 @@ "last_modified_iso": "" }, "user_mode": 0, - "story_idea": "two balls roli", - "story_genre": "action", + "story_idea": "two balls roling doen the street", + "story_genre": "mystery", "target_audience": "general", - "art_style": "manga", + "art_style": "sketch", "script": { - "title": "", - "synopsis": "", + "title": "The Rolling Enigma", + "synopsis": "Two ordinary balls roll down a deserted street at dusk, their path revealing clues to a mysterious event that has unsettled the town.", "characters": [ + { + "id": "char_001", + "name": "Red Ball", + "role": 0, + "description": "A bright red, slightly scuffed rubber ball, about the size of a baseball, with a faint smiley face drawn on it in black marker. It appears worn but determined.", + "prompt_template": { + "age": "", + "gender": "", + "hair_color": "", + "hair_style": "", + "skin_tone": "", + "eye_color": "", + "body_type": "", + "outfit": "", + "accessories": "", + "distinguishing_features": "" + }, + "reference_image_url": "", + "character_sheet_urls": [ + ], + "seed": 780351378, + "color_palette": { + "hair": "", + "eyes": "", + "skin": "", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "panel_001" + }, + { + "id": "char_002", + "name": "Blue Ball", + "role": 0, + "description": "A blue, slightly larger ball with a star pattern and a small dent. It seems to follow the red ball with a sense of purpose.", + "prompt_template": { + "age": "", + "gender": "", + "hair_color": "", + "hair_style": "", + "skin_tone": "", + "eye_color": "", + "body_type": "", + "outfit": "", + "accessories": "", + "distinguishing_features": "" + }, + "reference_image_url": "", + "character_sheet_urls": [ + + ], + "seed": 401047035, + "color_palette": { + "hair": "", + "eyes": "", + "skin": "", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "panel_001" + } ], "pages": [ + { + "page_number": 1, + "layout_type": 1, + "panels": [ + { + "panel_id": "panel_001", + "panel_number": 1, + "shot_type": 0, + "description": "Sketchy, ink-wash style. A long, empty street at dusk, with old-fashioned street lamps casting pools of light. Two balls, one red and one blue, sit at the top of a gentle slope. The atmosphere is quiet and slightly eerie, with long shadows stretching across the pavement.", + "characters_present": [ + "char_001", + "char_002" + ], + "dialogue": [ + ], + "caption": "At the edge of town, where the streetlights flicker and the asphalt cracks, two balls begin to roll.", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_002", + "panel_number": 2, + "shot_type": 1, + "description": "Sketchy, cross-hatched shading. The balls roll side by side, picking up speed. The red ball leads slightly, its smiley face now visible. The blue ball follows, its star pattern catching the light. The street is empty except for a parked car in the background.", + "characters_present": [ + "char_001", + "char_002" + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + "tumble... tumble..." + ], + "transition_from_previous": 1 + }, + { + "panel_id": "panel_003", + "panel_number": 3, + "shot_type": 3, + "description": "Sketchy, dynamic lines. The red ball's smiley face is focused, almost anthropomorphic. The ball bounces over a crack, leaving a faint trail of dust. The lighting is low, with the streetlamp creating a strong contrast.", + "characters_present": [ + "char_001" + ], + "dialogue": [ + + ], + "caption": "They have no eyes, but they seem to see. No legs, yet they run.", + "sound_effects": [ + "bounce... creak..." + ], + "transition_from_previous": 4 + }, + { + "panel_id": "panel_004", + "panel_number": 4, + "shot_type": 6, + "description": "Sketchy, loose perspective from above. The balls roll around a corner, their shadows elongated. A street sign points 'Elm St.' but is slightly askew. The neighborhood looks abandoned, with boarded-up windows.", + "characters_present": [ + "char_001", + "char_002" + ], + "dialogue": [ + + ], + "caption": "Following a path only they know.", + "sound_effects": [ + + ], + "transition_from_previous": 3 + }, + { + "panel_id": "panel_005", + "panel_number": 5, + "shot_type": 5, + "description": "Sketchy, rough lines. Over the red ball's shoulder, the blue ball is seen rolling past a broken streetlamp that flickers. The light casts an eerie glow on the blue ball's dent.", + "characters_present": [ + "char_001", + "char_002" + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + "flicker... hum..." + ], + "transition_from_previous": 1 + } + ] + }, + { + "page_number": 2, + "layout_type": 3, + "panels": [ + { + "panel_id": "panel_006", + "panel_number": 1, + "shot_type": 1, + "description": "Sketchy, dramatic lighting. The balls stop at a storm drain. The red ball teeters on the edge, while the blue ball nudges it gently. The drain is dark and deep, with a faint metallic smell suggested by the sketchy lines.", + "characters_present": [ + "char_001", + "char_002" + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 4 + }, + { + "panel_id": "panel_007", + "panel_number": 2, + "shot_type": 3, + "description": "Sketchy, intense close-up of the red ball's smiley face. The marker lines seem to twist into a worried expression. A single tear-like smudge appears on the ball. The background is dark, with only a sliver of light.", + "characters_present": [ + "char_001" + ], + "dialogue": [ + + ], + "caption": "Remembering.", + "sound_effects": [ + + ], + "transition_from_previous": 1 + }, + { + "panel_id": "panel_008", + "panel_number": 3, + "shot_type": 4, + "description": "Sketchy, almost abstract. Extreme close-up of the blue ball's dent, revealing it to be a tiny, stylized 'X' mark. The surface texture is rough, with visible graphite strokes.", + "characters_present": [ + "char_002" + ], + "dialogue": [ + + ], + "caption": "Marked.", + "sound_effects": [ + + ], + "transition_from_previous": 3 + }, + { + "panel_id": "panel_009", + "panel_number": 4, + "shot_type": 6, + "description": "Sketchy, bird's-eye view. The red ball rolls into the storm drain, followed by the blue. The drain grate is slightly ajar. The street is empty, with the flickering streetlamp as the only light source.", + "characters_present": [ + "char_001", + "char_002" + ], + "dialogue": [ + + ], + "caption": "Into the dark.", + "sound_effects": [ + "clink... splash..." + ], + "transition_from_previous": 4 + }, + { + "panel_id": "panel_010", + "panel_number": 5, + "shot_type": 0, + "description": "Sketchy, panoramic view. A wider shot of the street, now silent and still. The storm drain is closed. A single red balloon floats away in the distance. The mood is melancholic and unresolved.", + "characters_present": [ + + ], + "dialogue": [ + + ], + "caption": "Some mysteries are never solved. They just keep rolling.", + "sound_effects": [ + + ], + "transition_from_previous": 1 + } + ] + } ] }, "characters": [ + { + "id": "char_001", + "name": "Red Ball", + "role": 0, + "description": "A bright red, slightly scuffed rubber ball, about the size of a baseball, with a faint smiley face drawn on it in black marker. It appears worn but determined.", + "prompt_template": { + "age": "", + "gender": "male", + "hair_color": "black", + "hair_style": "", + "skin_tone": "light", + "eye_color": "black", + "body_type": "", + "outfit": "", + "accessories": "", + "distinguishing_features": "" + }, + "reference_image_url": "https://v3b.fal.media/files/b/0a9c0614/7cRK7_UN5SIMLxMFawwy3.jpg", + "character_sheet_urls": [ + ], + "seed": 780351378, + "color_palette": { + "hair": "black", + "eyes": "black", + "skin": "light", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "panel_001" + }, + { + "id": "char_002", + "name": "Blue Ball", + "role": 0, + "description": "A blue, slightly larger ball with a star pattern and a small dent. It seems to follow the red ball with a sense of purpose.", + "prompt_template": { + "age": "", + "gender": "male", + "hair_color": "red", + "hair_style": "", + "skin_tone": "light", + "eye_color": "blue", + "body_type": "large", + "outfit": "", + "accessories": "", + "distinguishing_features": "" + }, + "reference_image_url": "https://v3b.fal.media/files/b/0a9c0614/UfXqbVx2qVOKOuY13ZbeN.jpg", + "character_sheet_urls": [ + + ], + "seed": 401047035, + "color_palette": { + "hair": "red", + "eyes": "blue", + "skin": "light", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "panel_001" + } ], "panel_images": { - + "panel_001": { + "url": "https://v3b.fal.media/files/b/0a9c0615/3Jl1TM8UmRdrHnD8orOBY.jpg", + "width": 1344, + "height": 768, + "seed": 1843769822, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, ink-wash style. A long, empty street at dusk, with old-fashioned street lamps casting pools of light. Two balls, one red and one blue, sit at the top of a gentle slope. The atmosphere is quiet and slightly eerie, with long shadows stretching across the pavement.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Establishing shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_007": { + "url": "https://v3b.fal.media/files/b/0a9c0617/IK_zAvkpLPuuwYh4jEtAu.jpg", + "width": 896, + "height": 1152, + "seed": 654246121, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, intense close-up of the red ball's smiley face. The marker lines seem to twist into a worried expression. A single tear-like smudge appears on the ball. The background is dark, with only a sliver of light.. Characters: male with black hair, black eyes, light skin, build, wearing . Close_Up shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_006": { + "url": "https://v3b.fal.media/files/b/0a9c0617/x4mR37Mj9XHaED4Wc25IL.jpg", + "width": 1344, + "height": 768, + "seed": 1843769817, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, dramatic lighting. The balls stop at a storm drain. The red ball teeters on the edge, while the blue ball nudges it gently. The drain is dark and deep, with a faint metallic smell suggested by the sketchy lines.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Wide shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_003": { + "url": "https://v3b.fal.media/files/b/0a9c0616/8WnwlSeWALOOZlUUXtB35.jpg", + "width": 896, + "height": 1152, + "seed": 1142705242, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, dynamic lines. The red ball's smiley face is focused, almost anthropomorphic. The ball bounces over a crack, leaving a faint trail of dust. The lighting is low, with the streetlamp creating a strong contrast.. Characters: male with black hair, black eyes, light skin, build, wearing . Close_Up shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_009": { + "url": "https://v3b.fal.media/files/b/0a9c0617/YccGhV3Yt1kgNCIfTjDFt.jpg", + "width": 1344, + "height": 768, + "seed": 1355310701, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, bird's-eye view. The red ball rolls into the storm drain, followed by the blue. The drain grate is slightly ajar. The street is empty, with the flickering streetlamp as the only light source.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Aerial shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_002": { + "url": "https://v3b.fal.media/files/b/0a9c0616/lLXSWYxVFUhmvOcxfuVoI.jpg", + "width": 1344, + "height": 768, + "seed": 654246116, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, cross-hatched shading. The balls roll side by side, picking up speed. The red ball leads slightly, its smiley face now visible. The blue ball follows, its star pattern catching the light. The street is empty except for a parked car in the background.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Wide shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_010": { + "url": "https://v3b.fal.media/files/b/0a9c0618/XWr3AayLIwUr-stSPFDZr.jpg", + "width": 1344, + "height": 768, + "seed": 441640636, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, panoramic view. A wider shot of the street, now silent and still. The storm drain is closed. A single red balloon floats away in the distance. The mood is melancholic and unresolved.. Characters: . Establishing shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_008": { + "url": "https://v3b.fal.media/files/b/0a9c0617/pfwQSWsEGG22ezSjEor1S.jpg", + "width": 1024, + "height": 1024, + "seed": 1142705237, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, almost abstract. Extreme close-up of the blue ball's dent, revealing it to be a tiny, stylized 'X' mark. The surface texture is rough, with visible graphite strokes.. Characters: male with red hair, blue eyes, light skin, large build, wearing . Extreme_Close_Up shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_005": { + "url": "https://v3b.fal.media/files/b/0a9c0616/ky3eRf3sxaOBGUNWTSZDx.jpg", + "width": 1152, + "height": 896, + "seed": 441640662, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, rough lines. Over the red ball's shoulder, the blue ball is seen rolling past a broken streetlamp that flickers. The light casts an eerie glow on the blue ball's dent.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Over_Shoulder shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + }, + "panel_004": { + "url": "https://v3b.fal.media/files/b/0a9c0616/46l-wNnIP46lkFUR3SEtp.jpg", + "width": 1344, + "height": 768, + "seed": 1355310696, + "prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, loose perspective from above. The balls roll around a corner, their shadows elongated. A street sign points 'Elm St.' but is slightly askew. The neighborhood looks abandoned, with boarded-up windows.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Aerial shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece" + } }, "panel_errors": { }, "page_layouts": [ - + { + "page_number": 1, + "pattern_id": "grid-2x2", + "panels": [ + { + "panel_id": "panel_001", + "panel_number": 1, + "layout_cell": { + "x": 0.02000000, + "y": 0.02000000, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_002", + "panel_number": 2, + "layout_cell": { + "x": 0.50999999, + "y": 0.02000000, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_003", + "panel_number": 3, + "layout_cell": { + "x": 0.02000000, + "y": 0.50999999, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_004", + "panel_number": 4, + "layout_cell": { + "x": 0.50999999, + "y": 0.50999999, + "w": 0.47000000, + "h": 0.47000000 + } + } + ], + "width": 800, + "height": 1280 + }, + { + "page_number": 2, + "pattern_id": "manga-3-tier", + "panels": [ + { + "panel_id": "panel_005", + "panel_number": 5, + "layout_cell": { + "x": 0.02000000, + "y": 0.02000000, + "w": 0.30000001, + "h": 0.30000001 + } + }, + { + "panel_id": "panel_006", + "panel_number": 1, + "layout_cell": { + "x": 0.34999999, + "y": 0.02000000, + "w": 0.30000001, + "h": 0.30000001 + } + }, + { + "panel_id": "panel_007", + "panel_number": 2, + "layout_cell": { + "x": 0.68000001, + "y": 0.02000000, + "w": 0.30000001, + "h": 0.30000001 + } + }, + { + "panel_id": "panel_008", + "panel_number": 3, + "layout_cell": { + "x": 0.02000000, + "y": 0.34999999, + "w": 0.47000000, + "h": 0.30000001 + } + }, + { + "panel_id": "panel_009", + "panel_number": 4, + "layout_cell": { + "x": 0.50999999, + "y": 0.34999999, + "w": 0.47000000, + "h": 0.30000001 + } + }, + { + "panel_id": "panel_010", + "panel_number": 5, + "layout_cell": { + "x": 0.02000000, + "y": 0.68000001, + "w": 0.95999998, + "h": 0.30000001 + } + } + ], + "width": 800, + "height": 1280 + } ], "speech_bubbles": { }, "export_format": 0, - "page_size": 0, + "page_size": 3, "color_profile": 0, "workflow": { - "current_step": 0, + "current_step": 5, "completed_steps": [ ], diff --git a/odin/gui_session_report.txt b/odin/gui_session_report.txt index 942db87..812891a 100644 Binary files a/odin/gui_session_report.txt and b/odin/gui_session_report.txt differ diff --git a/odin/quick loic-cli-quick-1186622491/quick.pdP b/odin/quick loic-cli-quick-1186622491/quick.pdP new file mode 100644 index 0000000..eed419b Binary files /dev/null and b/odin/quick loic-cli-quick-1186622491/quick.pdP differ diff --git a/odin/src/adapters/deepseek.odin b/odin/src/adapters/deepseek.odin index 8d9a902..50e0253 100644 --- a/odin/src/adapters/deepseek.odin +++ b/odin/src/adapters/deepseek.odin @@ -281,9 +281,62 @@ deepseek_json_escape :: proc(s: string) -> string { return string(out[:]) } +SCRIPT_SYSTEM_PROMPT :: `You are an expert comic book writer and storyboard artist. +Your task is to take a user's story idea and transform it into a detailed comic book script optimized for AI image generation. + +OUTPUT FORMAT: Return ONLY valid JSON in this exact structure: +{ + "title": "Comic Title", + "synopsis": "One-paragraph summary", + "characters": [ + { + "id": "char_001", + "name": "Character Name", + "role": "protagonist|antagonist|supporting", + "description": "Detailed visual description for image generation: age, gender, hair color/style, eye color, skin tone, body type, clothing, distinguishing features. Be extremely specific.", + "firstAppearancePanel": "panel_001" + } + ], + "pages": [ + { + "pageNumber": 1, + "layoutType": "grid|manga|western|action|dialogue|splash", + "panels": [ + { + "panelId": "panel_001", + "panelNumber": 1, + "shotType": "establishing|wide|medium|close-up|extreme-close-up|over-shoulder|aerial", + "description": "Detailed scene description for image generation. Include: setting, lighting, mood, character positions, action, camera angle. 2-3 sentences.", + "charactersPresent": ["char_001"], + "dialogue": [ + { + "speakerId": "char_001", + "text": "Dialogue text", + "bubbleType": "normal|thought|shout|whisper", + "emotion": "happy|sad|angry|surprised|neutral|determined" + } + ], + "caption": "Optional narration caption", + "soundEffects": ["BANG!", "CRASH!"], + "transitionFromPrevious": "none|fade|wipe|dissolve|action-lines" + } + ] + } + ] +} + +RULES: +- 4-8 panels per page for standard pages, 1 panel for splash pages +- Vary shot types across panels for visual interest +- Include at least one establishing shot per scene +- Dialogue should be concise (1-3 lines per bubble) +- Description fields must be IMAGE-GENERATION-READY: vivid, specific, include art style keywords +- Character descriptions must be EXTREMELY detailed and consistent for all panels +- Include mood and lighting keywords in every panel description` + build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string { user_content := fmt.aprintf( - "Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.", + "Create a %d-page comic script based on this idea: \"%s\".\nGenre: %s\nTarget Art Style: %s (incorporate style keywords into panel descriptions)\nTarget Audience: %s", opts.num_pages, opts.story_idea, opts.genre, @@ -292,26 +345,15 @@ build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string { ) defer delete(user_content) - messages := [2]Deepseek_Request_Message{ - {role = "system", content = "You are an expert comic writer. Return JSON only."}, - {role = "user", content = user_content}, - } - body := Deepseek_Request_Body{ - model = "deepseek-chat", - messages = messages[:], - response_format = Deepseek_Request_Response_Format{type = "json_object"}, - temperature = 0.8, - } - request_json, merr := json.marshal(body, {}, context.allocator) - if merr == nil { - return string(request_json) - } + escaped_user := deepseek_json_escape(user_content) + defer delete(escaped_user) + escaped_system := deepseek_json_escape(SCRIPT_SYSTEM_PROMPT) + defer delete(escaped_system) - escaped := deepseek_json_escape(user_content) - defer delete(escaped) return fmt.aprintf( - "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8}", - escaped, + "{{\"model\":\"deepseek-chat\",\"messages\":[{{\"role\":\"system\",\"content\":\"%s\"}},{{\"role\":\"user\",\"content\":\"%s\"}}],\"response_format\":{{\"type\":\"json_object\"}},\"temperature\":0.8}", + escaped_system, + escaped_user, ) } @@ -803,7 +845,7 @@ stream_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: G auth := fmt.aprintf("Authorization: Bearer %s", cfg.deepseek_api_key) user_content := fmt.aprintf( - "Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.", + "Create a %d-page comic script based on this idea: \"%s\".\nGenre: %s\nTarget Art Style: %s (incorporate style keywords into panel descriptions)\nTarget Audience: %s", opts.num_pages, opts.story_idea, opts.genre, @@ -812,9 +854,15 @@ stream_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: G ) defer delete(user_content) + escaped_user := deepseek_json_escape(user_content) + defer delete(escaped_user) + escaped_system := deepseek_json_escape(SCRIPT_SYSTEM_PROMPT) + defer delete(escaped_system) + body_json := fmt.aprintf( - "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8,\"stream\":true}", - deepseek_json_escape(user_content), + "{{\"model\":\"deepseek-chat\",\"messages\":[{{\"role\":\"system\",\"content\":\"%s\"}},{{\"role\":\"user\",\"content\":\"%s\"}}],\"response_format\":{{\"type\":\"json_object\"}},\"temperature\":0.8,\"stream\":true}", + escaped_system, + escaped_user, ) defer delete(body_json) diff --git a/odin/src/adapters/export.odin b/odin/src/adapters/export.odin index b5a1476..b3fd15f 100644 --- a/odin/src/adapters/export.odin +++ b/odin/src/adapters/export.odin @@ -138,12 +138,18 @@ render_page_to_image :: proc( continue } - // Download/copy panel to temp + // Resolve source path: use file:// URLs directly, copy HTTP URLs ext := file_ext_from_url(img.url) - panel_tmp := fmt.aprintf("%s/panel_%s%s", temp_dir, p.panel_id, ext) - defer delete(panel_tmp) - if derr := download_or_copy_panel(img.url, panel_tmp); !shared.is_ok(derr) { - return "", derr + panel_src: string + if strings.has_prefix(img.url, "file://") { + panel_src = strings.clone(img.url[7:]) + } else { + panel_tmp := fmt.aprintf("%s/panel_%s%s", temp_dir, p.panel_id, ext) + defer delete(panel_tmp) + if derr := download_or_copy_panel(img.url, panel_tmp); !shared.is_ok(derr) { + return "", derr + } + panel_src = strings.clone(panel_tmp) } // Calculate pixel position from layout cell fractions @@ -160,21 +166,23 @@ render_page_to_image :: proc( // Resize panel to fit cell resized := fmt.aprintf("%s/panel_%s_resized.png", temp_dir, p.panel_id) defer delete(resized) - resize_cmd: [dynamic]string - append(&resize_cmd, "magick") - append(&resize_cmd, panel_tmp) - append(&resize_cmd, "-resize") - append(&resize_cmd, fmt.aprintf("%dx%d!", pw, ph)) - append(&resize_cmd, resized) - defer delete(resize_cmd) - if rerr := run_command(resize_cmd[:]); !shared.is_ok(rerr) { + + // Write resize command to a temp shell script to avoid string lifetime issues + script_path := fmt.aprintf("%s/resize_%s.sh", temp_dir, p.panel_id) + defer delete(script_path) + size_str := fmt.aprintf("%dx%d!", pw, ph) + defer delete(size_str) + // Build script content manually + script_lines := strings.concatenate([]string{"#!/bin/sh\nset -e\nmagick \"", panel_src, "\" -resize \"", size_str, "\" \"", resized, "\"\n"}) + _ = os.write_entire_file(script_path, transmute([]byte)script_lines) + + sh_cmd := []string{"sh", script_path} + if rerr := run_command(sh_cmd[:]); !shared.is_ok(rerr) { msg := fmt.aprintf("failed to resize panel %s: %s", p.panel_id, rerr.message) - err_out := shared.new_error(.Export, msg, true) - delete(msg) - return "", err_out + return "", shared.new_error(.Export, msg, true) } - append(&entries, Panel_Render_Entry{path = resized, px = px, py = py, pw = pw, ph = ph}) + append(&entries, Panel_Render_Entry{path = strings.clone(resized), px = px, py = py, pw = pw, ph = ph}) } if len(entries) == 0 { @@ -188,9 +196,7 @@ render_page_to_image :: proc( defer delete(blank_cmd) if err := run_command(blank_cmd[:]); !shared.is_ok(err) { msg := fmt.aprintf("failed to create blank page: %s", err.message) - err_out := shared.new_error(.Export, msg, true) - delete(msg) - return "", err_out + return "", shared.new_error(.Export, msg, true) } result := strings.clone(out_path) return result, shared.ok() @@ -213,17 +219,29 @@ render_page_to_image :: proc( append(&composite_cmd, out_path) - if err := run_command(composite_cmd[:]); !shared.is_ok(err) { + // Write composite command to a temp shell script + comp_script_path := fmt.aprintf("%s/composite_%03d.sh", temp_dir, page_idx) + defer delete(comp_script_path) + comp_script: strings.Builder + strings.write_string(&comp_script, "#!/bin/sh\nset -e\n") + for arg, j in composite_cmd { + if j > 0 { strings.write_string(&comp_script, " ") } + strings.write_string(&comp_script, "\"") + strings.write_string(&comp_script, arg) + strings.write_string(&comp_script, "\"") + } + strings.write_string(&comp_script, "\n") + _ = os.write_entire_file(comp_script_path, transmute([]byte)strings.to_string(comp_script)) + + sh_comp_cmd := []string{"sh", comp_script_path} + if err := run_command(sh_comp_cmd[:]); !shared.is_ok(err) { msg := fmt.aprintf("failed to render page %d: %s", page_idx+1, err.message) - err_out := shared.new_error(.Export, msg, true) - delete(msg) - return "", err_out + return "", shared.new_error(.Export, msg, true) } result := strings.clone(out_path) return result, shared.ok() } - stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error { staged_count := 0 for p, idx in ordered { diff --git a/odin/src/adapters/fal.odin b/odin/src/adapters/fal.odin index f4360c3..412e3f1 100644 --- a/odin/src/adapters/fal.odin +++ b/odin/src/adapters/fal.odin @@ -7,6 +7,29 @@ import "core:strings" import "../core" import "../shared" +// ─── Persistent String Pool ──────────────────────────────────────── +@(private) +fal_pool: [64 * 1024]u8 +@(private) +fal_pool_offset: int + +@(private) +fal_clone :: proc(s: string) -> string { + if len(s) == 0 { return "" } + total := len(s) + 1 + if fal_pool_offset + total > len(fal_pool) { + // Pool full — fall back to strings.clone (heap) + return strings.clone(s) + } + start := fal_pool_offset + for c, i in s { + fal_pool[start + i] = u8(c) + } + fal_pool[start + len(s)] = 0 + fal_pool_offset += total + return string(fal_pool[start:start+len(s)]) +} + Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, negative_prompt: string, seed: i64, image_size: string, reference_images: []string, reference_strength: f32) -> (image_url: string, status_code: int, err: shared.App_Error) Fal_Generation_Queue :: struct { @@ -112,6 +135,27 @@ fal_json_escape :: proc(s: string) -> string { case '\t': append(&out, '\\') append(&out, 't') + case '\b': + append(&out, '\\') + append(&out, 'b') + case '\f': + append(&out, '\\') + append(&out, 'f') + case '\v': + append(&out, '\\') + append(&out, 'u') + append(&out, '0') + append(&out, '0') + append(&out, '0') + append(&out, 'b') + case 0 ..= 8, 0x0E ..= 0x1F: + append(&out, '\\') + append(&out, 'u') + append(&out, '0') + append(&out, '0') + hex := "0123456789abcdef" + append(&out, hex[u8(c) >> 4]) + append(&out, hex[u8(c) & 0x0f]) case: append(&out, u8(c)) } @@ -133,7 +177,7 @@ default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_pro append(&ref_items, ',') } escaped := fal_json_escape(reference_images[i]) - item_str := fmt.aprintf("{\"url\":\"%s\"}", escaped) + item_str := fmt.aprintf("{{\"url\":\"%s\"}}", escaped) for b in item_str { append(&ref_items, u8(b)) } @@ -154,7 +198,7 @@ default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_pro size_json = fmt.aprintf(",\"image_size\":\"%s\"", fal_json_escape(image_size)) } - payload := fmt.aprintf("{\"prompt\":\"%s\"%s%s%s,\"seed\":%d}", fal_json_escape(prompt), neg_json, size_json, ref_json, seed) + payload := fmt.aprintf("{{\"prompt\":\"%s\"%s%s%s,\"seed\":%d}}", fal_json_escape(prompt), neg_json, size_json, ref_json, seed) cmd := [13]string{ "curl", "-sS", "-X", "POST", url, @@ -178,7 +222,7 @@ default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_pro } if status_code >= 400 { - return "", status_code, shared.ok() + return "", status_code, shared.validation_error(fmt.aprintf("fal request failed (%d): %s", status_code, body)) } resp, parse_err := fal_parse_response_body(body) @@ -218,7 +262,7 @@ fal_backoff_ms :: proc(initial_ms, attempt: int) -> int { return initial_ms * mul } -generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) { +generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> (string, shared.App_Error) { if len(cfg.fal_api_key) == 0 { return "", shared.config_error("FAL_API_KEY is missing") } @@ -232,7 +276,9 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: style_key := core.parse_art_style_key(art_style) style_keywords := core.get_style_keywords(style_key) - prompt := fmt.aprintf("%s. %s, standing in neutral pose facing camera, character portrait, clean background, studio lighting, white background, front-facing portrait, centered composition. %s", style_keywords, core.build_character_prompt(c, "", "", "", ""), core.QUALITY_MODIFIER) + genre_kw := core.get_genre_keywords(genre) + audience_kw := core.get_audience_keywords(audience) + prompt := fmt.aprintf("%s. %s, standing in neutral pose facing camera, character portrait, clean background, studio lighting, white background, front-facing portrait, centered composition. %s. %s. %s", style_keywords, core.build_character_prompt_with_style(c, "", "", "", art_style), genre_kw, audience_kw, core.QUALITY_MODIFIER) attempts := client.max_retries if attempts < 1 { @@ -249,7 +295,7 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: } else if len(url) == 0 { last_err = shared.generation_error("fal returned empty image url") } else { - return url, shared.ok() + return fal_clone(url), shared.ok() } if attempt < attempts && shared.should_retry(last_err) { @@ -262,7 +308,7 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: return "", last_err } -generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) { +generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id, genre, audience: string) -> (core.Panel_Image, shared.App_Error) { if len(cfg.fal_api_key) == 0 { return core.Panel_Image{}, shared.config_error("FAL_API_KEY is missing") } @@ -279,6 +325,8 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core // Build prompt with art style keywords style_key := core.parse_art_style_key(art_style) style_keywords := core.get_style_keywords(style_key) + genre_kw := core.get_genre_keywords(genre) + audience_kw := core.get_audience_keywords(audience) // Build character descriptions char_desc: [dynamic]u8 @@ -316,7 +364,7 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core } defer delete(ref_images) - prompt := fmt.aprintf("%s comic panel. %s. Characters: %s. %s shot. %s", style_keywords, panel.description, char_str, panel.shot_type, core.QUALITY_MODIFIER) + prompt := fmt.aprintf("%s comic panel. %s. Characters: %s. %s shot. %s. %s. %s", style_keywords, panel.description, char_str, panel.shot_type, genre_kw, audience_kw, core.QUALITY_MODIFIER) attempts := client.max_retries if attempts < 1 { @@ -335,7 +383,7 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core } else { // Return dimensions from response (we'll use the image_size preset to estimate) w, h := dimensions_from_sdxl_size(image_size) - return core.Panel_Image{url = url, width = w, height = h, seed = seed, prompt = prompt}, shared.ok() + return core.Panel_Image{url = fal_clone(url), width = w, height = h, seed = seed, prompt = prompt}, shared.ok() } if attempt < attempts && shared.should_retry(last_err) { @@ -359,12 +407,12 @@ dimensions_from_sdxl_size :: proc(image_size: string) -> (int, int) { return 1024, 1024 } -generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id: string, progress_callback: #type proc(current, total: int) -> ()) -> (map[string]core.Panel_Image, shared.App_Error) { +generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id, genre, audience: string, progress_callback: #type proc(current, total: int) -> ()) -> (map[string]core.Panel_Image, shared.App_Error) { results := make(map[string]core.Panel_Image) total := len(panels) for p, idx in panels { - img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id) + img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id, genre, audience) if !shared.is_ok(err) { return results, err } @@ -377,10 +425,10 @@ generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, pane return results, shared.ok() } -generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) { +generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> (string, shared.App_Error) { q := new_fal_queue(2) client := new_fal_client(&q) - return generate_character_reference(client, cfg, c, art_style) + return generate_character_reference(client, cfg, c, art_style, genre, audience) } Character_Sheet_Pose :: struct { @@ -395,7 +443,7 @@ CHARACTER_SHEET_POSES :: [4]Character_Sheet_Pose{ Character_Sheet_Pose{name = "back", prompt_suffix = "back view showing hair and outfit from behind"}, } -generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> ([]string, shared.App_Error) { +generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> ([]string, shared.App_Error) { if len(cfg.fal_api_key) == 0 { return nil, shared.config_error("FAL_API_KEY is missing") } @@ -405,6 +453,8 @@ generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core style_key := core.parse_art_style_key(art_style) style_keywords := core.get_style_keywords(style_key) + genre_kw := core.get_genre_keywords(genre) + audience_kw := core.get_audience_keywords(audience) sheet_urls: [dynamic]string all_failed := true @@ -416,9 +466,8 @@ generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core pose_seed := c.seed + i64(i) - // Build prompt for this pose - base_desc := core.build_character_prompt(c, "", "", "", "") - prompt := fmt.aprintf("%s. %s, %s, character sheet, clean background, studio lighting, white background, centered composition. %s", style_keywords, base_desc, pose.prompt_suffix, core.QUALITY_MODIFIER) + base_desc := core.build_character_prompt_with_style(c, "", "", "", art_style) + prompt := fmt.aprintf("%s. %s, %s, character sheet, clean background, studio lighting, white background, centered composition. %s. %s. %s", style_keywords, base_desc, pose.prompt_suffix, genre_kw, audience_kw, core.QUALITY_MODIFIER) // Use first image as reference for subsequent poses ref_images: [dynamic]string @@ -447,14 +496,14 @@ generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core return sheet_urls[:], shared.ok() } -generate_character_sheet_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> ([]string, shared.App_Error) { +generate_character_sheet_stub :: proc(cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> ([]string, shared.App_Error) { q := new_fal_queue(2) client := new_fal_client(&q) - return generate_character_sheet(client, cfg, c, art_style) + return generate_character_sheet(client, cfg, c, art_style, genre, audience) } -generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) { +generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id, genre, audience: string) -> (core.Panel_Image, shared.App_Error) { q := new_fal_queue(2) client := new_fal_client(&q) - return generate_panel_image(client, cfg, panel, characters, art_style, project_id) + return generate_panel_image(client, cfg, panel, characters, art_style, project_id, genre, audience) } diff --git a/odin/src/app/cli.odin b/odin/src/app/cli.odin index 7a8be27..a774515 100644 --- a/odin/src/app/cli.odin +++ b/odin/src/app/cli.odin @@ -303,12 +303,11 @@ build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel if cerr != nil || !state.exited || state.exit_code != 0 { delete(out_path) msg := fmt.aprintf("failed to create panel image: %s", string(stderr)) - defer delete(msg) return nil, shared.new_error(.Generation, msg, true) } - url := fmt.aprintf("file://%s", out_path) - prompt := fmt.aprintf("local panel %d", idx+1) + url := strings.clone(fmt.aprintf("file://%s", out_path)) + prompt := strings.clone(fmt.aprintf("local panel %d", idx+1)) images[p.panel_id] = core.Panel_Image{url = url, width = gen_w, height = gen_h, seed = i64(idx + 1), prompt = prompt} delete(out_path) } @@ -889,7 +888,7 @@ run_tui_command :: proc(controller: ^ui.App_Controller, input: string, last_job_ q := adapters.new_fal_queue(2) client := adapters.new_fal_client(&q) - images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id, nil) + images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id, controller.state.story_genre, controller.state.target_audience, nil) if !shared.is_ok(gerr) { controller.state.workflow.is_generating = false controller.state.workflow.error_message = gerr.message diff --git a/odin/src/core/character_parser.odin b/odin/src/core/character_parser.odin index ef7be57..f47f6dd 100644 --- a/odin/src/core/character_parser.odin +++ b/odin/src/core/character_parser.odin @@ -206,6 +206,30 @@ extract_color_palette :: proc(description: string) -> Color_Palette { } } +update_character_from_description :: proc(c: Character) -> Character { + updated := c + template := parse_description_to_template(c.description) + color_palette := extract_color_palette(c.description) + updated.prompt_template = template + updated.color_palette = color_palette + if updated.seed == 0 { + updated.seed = derive_seed_from_string(c.name) + } + return updated +} + +update_all_characters_from_descriptions :: proc(characters: []Character) -> []Character { + if len(characters) == 0 { + return characters + } + + updated: [dynamic]Character + for c in characters { + append(&updated, update_character_from_description(c)) + } + return updated[:] +} + template_to_string :: proc(t: Character_Prompt_Template) -> string { parts: [dynamic]string diff --git a/odin/src/core/character_prompt.odin b/odin/src/core/character_prompt.odin index 5158ccf..e9f8c32 100644 --- a/odin/src/core/character_prompt.odin +++ b/odin/src/core/character_prompt.odin @@ -8,7 +8,7 @@ DEFAULT_PROMPT_BODY :: "average" DEFAULT_PROMPT_OUTFIT :: "casual clothing" DEFAULT_PROMPT_ACCESSORIES :: "no accessories" -build_character_prompt :: proc(c: Character, action, setting, lighting, art_style: string) -> string { +build_character_prompt :: proc(c: Character, action, setting, lighting: string) -> string { t := c.prompt_template age := t.age @@ -23,8 +23,7 @@ build_character_prompt :: proc(c: Character, action, setting, lighting, art_styl if len(accessories) == 0 { accessories = DEFAULT_PROMPT_ACCESSORIES } return fmt.aprintf( - "%s style. %s-year-old %s, %s %s hair, %s eyes, %s skin, %s build, wearing %s, %s, %s, %s, %s, %s", - art_style, + "%syear-old %s, %s %s hair, %s eyes, %s skin, %s build, wearing %s, %s, %s, %s, %s, %s", age, gender, t.hair_color, @@ -41,6 +40,14 @@ build_character_prompt :: proc(c: Character, action, setting, lighting, art_styl ) } +build_character_prompt_with_style :: proc(c: Character, action, setting, lighting, art_style: string) -> string { + base := build_character_prompt(c, action, setting, lighting) + if len(art_style) == 0 { + return base + } + return fmt.aprintf("%s style. %s", art_style, base) +} + derive_seed_from_string :: proc(s: string) -> i64 { hash: i32 = 0 for r in s { diff --git a/odin/src/core/prompt_consts.odin b/odin/src/core/prompt_consts.odin index 043a4cc..b5fd3ef 100644 --- a/odin/src/core/prompt_consts.odin +++ b/odin/src/core/prompt_consts.odin @@ -24,6 +24,63 @@ get_style_keywords :: proc(key: Art_Style_Key) -> string { return keywords[int(key)] } +Genre_Keyword_Entry :: struct { + key: string, + keywords: string, +} + +GENRE_KEYWORDS :: [14]Genre_Keyword_Entry{ + {"action", "action-packed, dynamic compositions, explosive motion, intense combat, heroic moments, dramatic clashes"}, + {"adventure", "epic adventure, sprawling landscapes, exploration, treasure maps, ancient ruins, journey narrative"}, + {"romance", "romantic atmosphere, tender moments, soft lighting, emotional glances, intimate compositions, warm palette"}, + {"comedy", "comedic, exaggerated expressions, slapstick poses, funny situations, dynamic sight gags, lively energy"}, + {"horror", "horror atmosphere, dark shadows, eerie lighting, unsettling composition, creepy textures, menacing ambiance"}, + {"sci-fi", "science fiction, futuristic technology, space environments, advanced gadgets, alien architecture, high-tech aesthetics"}, + {"fantasy", "fantasy world, magical effects, mythical creatures, enchanted environments, arcane symbols, ethereal lighting"}, + {"mystery", "mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist"}, + {"slice of life", "slice of life, everyday warmth, cozy interiors, natural lighting, relatable moments, gentle pacing"}, + {"drama", "dramatic tension, emotional intensity, cinematic lighting, powerful expressions, charged atmosphere"}, + {"superhero", "superhero, heroic poses, dynamic action, bold colors, iconic costumes, epic scale, power effects"}, + {"mecha", "mecha, giant robots, mechanical details, cockpit interiors, metallic surfaces, transformable machines"}, + {"thriller", "thriller, tense atmosphere, paranoid compositions, sharp shadows, edge-of-seat framing, cliffhanger moments"}, + {"historical", "historical, period-accurate details, classical architecture, period costumes, aged textures, antiqued palette"}, +} + +get_genre_keywords :: proc(genre: string) -> string { + if len(genre) == 0 { return "" } + lower := strings.to_lower(strings.trim_space(genre)) + for entry in GENRE_KEYWORDS { + if strings.contains(lower, entry.key) { + return entry.keywords + } + } + return "" +} + +Audience_Keyword_Entry :: struct { + key: string, + keywords: string, +} + +AUDIENCE_KEYWORDS :: [5]Audience_Keyword_Entry{ + {"children", "child-friendly, bright colors, simple shapes, round features, safe content, age-appropriate, whimsical"}, + {"teens", "teen-oriented, expressive characters, relatable themes, modern style, vibrant energy, coming-of-age"}, + {"young adult", "young adult, sophisticated storytelling, complex characters, stylish art, mature themes, contemporary feel"}, + {"adults", "mature audience, sophisticated composition, nuanced storytelling, detailed artwork, complex themes"}, + {"all ages", "all-ages appeal, clean art, universal themes, accessible design, family-friendly, broad appeal"}, +} + +get_audience_keywords :: proc(audience: string) -> string { + if len(audience) == 0 { return "" } + lower := strings.to_lower(strings.trim_space(audience)) + for entry in AUDIENCE_KEYWORDS { + if strings.contains(lower, entry.key) { + return entry.keywords + } + } + return "" +} + parse_art_style_key :: proc(raw: string) -> Art_Style_Key { lower := strings.to_lower(raw) if strings.contains(lower, "manga") { return .Manga } diff --git a/odin/src/core/state.odin b/odin/src/core/state.odin index 5aa35e4..154c613 100644 --- a/odin/src/core/state.odin +++ b/odin/src/core/state.odin @@ -1,20 +1,22 @@ package core +import "core:strings" + new_initial_state :: proc() -> Comic_State { iso := "" return Comic_State{ project = Project_Metadata{ - project_id = "proj_todo", - project_name = "Untitled Comic", + project_id = strings.clone("proj_todo"), + project_name = strings.clone("Untitled Comic"), created_at_iso = iso, last_modified_iso = iso, }, user_mode = .Casual, story_idea = "", - story_genre = "action", - target_audience = "general", - art_style = "manga", + story_genre = strings.clone("action"), + target_audience = strings.clone("general"), + art_style = strings.clone("manga"), export_format = .PDF, page_size = .A4, color_profile = .RGB, diff --git a/odin/src/gui/actions.odin b/odin/src/gui/actions.odin index aa6add4..ed33756 100644 --- a/odin/src/gui/actions.odin +++ b/odin/src/gui/actions.odin @@ -27,15 +27,73 @@ action_generate_deepseek_script :: proc(controller: ^ui.App_Controller, pages: i } core.dispose_script(&controller.state.script) controller.state.script = script - controller.state.characters = controller.state.script.characters + + // Auto-parse character descriptions into structured prompt templates + parsed_chars := core.update_all_characters_from_descriptions(script.characters) + controller.state.characters = parsed_chars + controller.active_screen = .Script controller.state.workflow.current_step = .Script_Review - return "Generated DeepSeek script" + return fmt.aprintf("Generated DeepSeek script with %d characters, %d pages", len(parsed_chars), len(script.pages)) +} + +action_parse_character_descriptions :: proc(controller: ^ui.App_Controller) -> string { + if len(controller.state.characters) == 0 { + return "No characters to parse" + } + + updated := core.update_all_characters_from_descriptions(controller.state.characters) + // Dispose old characters array and replace + delete(controller.state.characters) + controller.state.characters = updated + + return fmt.aprintf("Parsed %d character descriptions", len(updated)) } action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string { - _ = controller; _ = panel_id - return "Single panel regen not supported; regenerate all panels via FAL" + cfg := shared.load_config() + if len(cfg.fal_api_key) == 0 { + return "FAL_API_KEY is missing" + } + + target_panel: core.Panel + found := false + for page in controller.state.script.pages { + for p in page.panels { + if p.panel_id == panel_id { + target_panel = p + found = true + break + } + } + if found { break } + } + if !found { return "Panel not found" } + + img, err := adapters.generate_panel_image_stub(cfg, target_panel, controller.state.characters, controller.state.art_style, "gui-project", controller.state.story_genre, controller.state.target_audience) + if !shared.is_ok(err) { + return fmt.aprintf("FAL fail: %s", err.message) + } + + if controller.state.panel_images == nil { + controller.state.panel_images = make(map[string]core.Panel_Image) + } + // Clone URL to persistent pool to survive frame resets + img.url = pool_clone(img.url) + controller.state.panel_images[panel_id] = img + return fmt.aprintf("Generated panel %s", panel_id) +} + +action_update_panel_prompt :: proc(controller: ^ui.App_Controller, panel_id: string, new_desc: string) -> string { + for i in 0.. string { @@ -247,11 +305,21 @@ save_project_session_with_message :: proc(project_path: ^string, state: core.Com } action_export :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format) -> string { + controller.state.workflow.is_generating = true + controller.state.workflow.generation_progress = 10 + opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} + controller.state.workflow.generation_progress = 40 + err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) + controller.state.workflow.generation_progress = 90 + if !shared.is_ok(err) { + controller.state.workflow.is_generating = false return err.message } + controller.state.workflow.generation_progress = 100 + controller.state.workflow.is_generating = false controller.active_screen = .Export controller.state.workflow.current_step = .Complete controller.state.export_format = export_format @@ -306,9 +374,16 @@ run_panels_action :: proc(controller: ^ui.App_Controller, queue: ^adapters.Fal_G if len(cfg.fal_api_key) == 0 { return "FAL_API_KEY not set" } + // Create default queue if none provided + default_q: adapters.Fal_Generation_Queue + q_ptr := queue + if q_ptr == nil { + default_q = adapters.new_fal_queue(2) + q_ptr = &default_q + } panels := collect_script_panels(controller.state.script) - client := adapters.new_fal_client(queue) - images, err := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, "digital art, comic style", "gui-project", nil) + client := adapters.new_fal_client(q_ptr) + images, err := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, "gui-project", controller.state.story_genre, controller.state.target_audience, nil) if strings.contains(err.message, "error") || strings.contains(err.message, "fail") { return fmt.tprintf("FAL panels failed: %s", err.message) } @@ -318,8 +393,19 @@ run_panels_action :: proc(controller: ^ui.App_Controller, queue: ^adapters.Fal_G delete(img.prompt) } delete(controller.state.panel_images) - controller.state.panel_images = images - return fmt.tprintf("Generated %d panels via FAL", len(images)) + // Clone URLs to persistent pool before storing + cloned: map[string]core.Panel_Image + for pid, img in images { + cloned[pid] = core.Panel_Image{ + url = pool_clone(img.url), + width = img.width, + height = img.height, + seed = img.seed, + prompt = pool_clone(img.prompt), + } + } + controller.state.panel_images = cloned + return fmt.tprintf("Generated %d panels via FAL", len(cloned)) } run_layout_action :: proc(controller: ^ui.App_Controller, can_layout: bool, is_dirty: ^bool) -> string { @@ -547,7 +633,7 @@ action_generate_character_reference :: proc(controller: ^ui.App_Controller, char return "Character not found" } - url, err := adapters.generate_character_reference_stub(cfg, target_char, controller.state.art_style) + url, err := adapters.generate_character_reference_stub(cfg, target_char, controller.state.art_style, controller.state.story_genre, controller.state.target_audience) if !shared.is_ok(err) { return err.message } @@ -587,7 +673,7 @@ action_generate_character_sheet :: proc(controller: ^ui.App_Controller, characte return "Character not found" } - sheet_urls, err := adapters.generate_character_sheet_stub(cfg, target_char, controller.state.art_style) + sheet_urls, err := adapters.generate_character_sheet_stub(cfg, target_char, controller.state.art_style, controller.state.story_genre, controller.state.target_audience) if !shared.is_ok(err) { return err.message } @@ -612,6 +698,56 @@ action_generate_character_sheet :: proc(controller: ^ui.App_Controller, characte return fmt.tprintf("Generated %d-pose sheet for %s", len(sheet_urls), target_char.name) } +action_generate_all_character_refs :: proc(controller: ^ui.App_Controller) -> string { + cfg := shared.load_config() + if len(cfg.fal_api_key) == 0 { + return "FAL_API_KEY is missing" + } + if len(controller.state.characters) == 0 { + return "No characters to generate" + } + + count := 0 + for c in controller.state.characters { + if len(c.reference_image_url) > 0 { continue } + url, err := adapters.generate_character_reference_stub(cfg, c, controller.state.art_style, controller.state.story_genre, controller.state.target_audience) + if shared.is_ok(err) { + new_chars: [dynamic]core.Character + for ch in controller.state.characters { + nc := ch + if ch.id == c.id { nc.reference_image_url = url } + append(&new_chars, nc) + } + delete(controller.state.characters) + controller.state.characters = new_chars[:] + count += 1 + } else { + return fmt.aprintf("Failed on %s: %s", c.name, err.message) + } + } + return fmt.aprintf("Generated %d character references", count) +} + +action_update_character_desc :: proc(controller: ^ui.App_Controller, character_id: string, new_desc: string) -> string { + new_chars: [dynamic]core.Character + found := false + for c in controller.state.characters { + new_c := c + if c.id == character_id { + // Free old description? We don't have deep free logic everywhere, but we can assign the new one. + new_c.description = new_desc // Assume new_desc is cloned elsewhere if needed, or just static string inside GUI buffer + found = true + } + append(&new_chars, new_c) + } + if !found { + return "Character not found" + } + delete(controller.state.characters) + controller.state.characters = new_chars[:] + return "Character description updated" +} + action_update_bubble_text :: proc(controller: ^ui.App_Controller, bubble_id: string, new_text: string) -> string { if controller.state.speech_bubbles == nil { return "No bubbles to update" diff --git a/odin/src/gui/chrome.odin b/odin/src/gui/chrome.odin index 0129b1e..a3b7438 100644 --- a/odin/src/gui/chrome.odin +++ b/odin/src/gui/chrome.odin @@ -6,31 +6,29 @@ import "../core" import "../shared" import "../ui" -// ─── Sidebar Declaration ───────────────────────────────────────── -declare_sidebar :: proc(app: ^GUI_App_State) { +declare_sidebar :: proc(app: ^GUI_App_State, bp: shared.Breakpoint) { + collapsed := app.sidebar_collapsed || bp == .Compact screens := []ui.App_Screen{.Story, .Script, .Characters, .Panels, .Layout, .Bubbles, .Export, .Community} - icons := []string{"1", "2", "3", "4", "5", "6", "7", "8"} - names := []string{"Story", "Script", "Chars", "Panels", "Layout", "Bubbles", "Export", "Commty"} + icons := []string{"S", "Sc", "Ch", "Pn", "Ly", "Bb", "Ex", "Cm"} + names := []string{"Story", "Script", "Characters", "Panels", "Layout", "Bubbles", "Export", "Community"} - if clay.UI(clay.ID("Sidebar"))({ - layout = {sizing = {width = clay.SizingFixed(220), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 12, right = 12, bottom = 12, left = 12}, childGap = 2}, - backgroundColor = CLAY_BG_SIDEBAR, - }) { - // Brand - clay_body_text("comic-odin", color = CLAY_ACCENT, size = 16) - clay_muted_text("Pipeline GUI") + if collapsed { + if clay.UI(clay.ID("SidebarLogoC"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(36)}, childAlignment = {x = .Center, y = .Center}}, + }) { + clay_title_text("CO", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + } - // Pipeline progress bar ready, total := ready_stage_count(app.controller) progress := f32(0) if total > 0 { progress = f32(ready) / f32(total) } if clay.UI(clay.ID("SidebarProgress"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(4)}, layoutDirection = .LeftToRight}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(3)}, layoutDirection = .LeftToRight}, backgroundColor = CLAY_PROGRESS_TRACK, cornerRadius = clay.CornerRadiusAll(2), }) { if progress > 0 { - pct := f32(progress * 100); if pct > 100 { pct = 100 } + pct := f32(progress); if pct > 1 { pct = 1 } if clay.UI(clay.ID("SidebarPgFill"))({ layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}}, backgroundColor = CLAY_PROGRESS_FILL, @@ -38,64 +36,133 @@ declare_sidebar :: proc(app: ^GUI_App_State) { }) {} } } - clay_muted_text(fmt.tprintf("%d/4 done", ready)) - // Navigation + declare_divider("SidebarDividerTop") + for i in 0 ..< len(screens) { is_active := app.controller.active_screen == screens[i] - bg := CLAY_NAV_HOVER_BG + is_hov := clay.Hovered() + bg := clay.Color{0, 0, 0, 0} 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 } + else if is_hov { bg = CLAY_NAV_HOVER_BG } if clay.UI(clay.ID("Nav", u32(i)))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, padding = {top = 4, right = 8, bottom = 4, left = 8}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 6}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(36)}, padding = {top = 4, right = 4, bottom = 4, left = 4}, childAlignment = {x = .Center, y = .Center}, layoutDirection = .LeftToRight}, 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(fmt.tprintf("%s %s", icons[i], names[i]), color = text_color) + text_color: clay.Color = CLAY_TEXT_TERTIARY + if is_active { text_color = CLAY_ACCENT } + else if is_hov { text_color = CLAY_TEXT_PRIMARY } + clay_body_text(icons[i], color = text_color, size = CLAY_FONT_SIZE_XS) } } - // Spacer gap_spacer := clay.UI(clay.ID("SidebarGap")) if gap_spacer({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, }) {} + declare_button_small("btn_help", "?") + return + } - // Project name + help - if clay.UI(clay.ID("SidebarFooter"))({ - layout = {layoutDirection = .LeftToRight, childAlignment = {y = .Center}, childGap = 4}, - }) { - proj_name := app.project_path - if len(proj_name) > 18 { proj_name = fmt.tprintf("...%s", proj_name[len(proj_name)-15:]) } - clay_muted_text(proj_name) - declare_button_small("btn_help", "?") + if clay.UI(clay.ID("SidebarLogo"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(40)}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 8}, + }) { + clay_title_text("comic-odin", color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_MD) + } + + ready, total := ready_stage_count(app.controller) + progress := f32(0) + if total > 0 { progress = f32(ready) / f32(total) } + if clay.UI(clay.ID("SidebarProgress"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(3)}, layoutDirection = .LeftToRight}, + backgroundColor = CLAY_PROGRESS_TRACK, + cornerRadius = clay.CornerRadiusAll(2), + }) { + if progress > 0 { + pct := f32(progress); if pct > 1 { pct = 1 } + if clay.UI(clay.ID("SidebarPgFill"))({ + layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}}, + backgroundColor = CLAY_PROGRESS_FILL, + cornerRadius = clay.CornerRadiusAll(2), + }) {} } } + + if clay.UI(clay.ID("SidebarProgressLabel"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(20)}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 4}, + }) { + clay_body_text(fmt.tprintf("%d of 4 done", ready), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) + } + + declare_divider("SidebarDivider1") + + if clay.UI(clay.ID("SidebarNavSection"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, layoutDirection = .TopToBottom, childGap = 2}, + }) { + clay_label_text("WORKSPACE") + for i in 0 ..< len(screens) { + is_active := app.controller.active_screen == screens[i] + is_hov := clay.Hovered() + bg := clay.Color{0, 0, 0, 0} + if is_active { bg = CLAY_NAV_ACTIVE_BG } + else if is_hov { bg = CLAY_NAV_HOVER_BG } + + if clay.UI(clay.ID("Nav", u32(i)))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, padding = {top = 4, right = 10, bottom = 4, left = 10}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 8}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + if clay.UI(clay.ID(fmt.tprintf("NavIcon%d", i)))({ + layout = {sizing = {width = clay.SizingFixed(24), height = clay.SizingFixed(24)}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = CLAY_ACCENT_SURFACE if is_active else clay.Color{0, 0, 0, 0}, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XS), + }) { + icon_color := CLAY_TEXT_TERTIARY + if is_active { icon_color = CLAY_ACCENT } + clay.Text(icons[i], {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = icon_color}) + } + text_color: clay.Color = CLAY_TEXT_SECONDARY + if is_active { text_color = CLAY_TEXT_BRIGHT } + else if is_hov { text_color = CLAY_TEXT_PRIMARY } + clay_body_text(names[i], color = text_color, size = CLAY_FONT_SIZE_SM) + } + } + } + + gap_spacer := clay.UI(clay.ID("SidebarGap")) + if gap_spacer({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, + }) {} + + declare_button_small("btn_toggle_sidebar", "Collapse") + clay_body_text("Ctrl+B", color = CLAY_TEXT_DISABLED, size = CLAY_FONT_SIZE_XS) + + declare_divider("SidebarDivider2") + + if clay.UI(clay.ID("SidebarFooter"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 6}, + }) { + proj_name := app.project_path + if len(proj_name) > 18 { proj_name = fmt.tprintf("...%s", proj_name[len(proj_name)-15:]) } + clay_body_text(proj_name, color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) + declare_button_small("btn_help", "?") + } } -// ─── Pipeline Bar ───────────────────────────────────────────────── - -// ─── Pipeline Bar ───────────────────────────────────────────────── -declare_pipeline_bar :: proc(app: ^GUI_App_State) { +declare_pipeline_bar :: proc(app: ^GUI_App_State, bp: shared.Breakpoint) { if clay.UI(clay.ID("PipelineBar"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 54, max = 48})}, padding = {top = 8, right = 16, bottom = 8, left = 16}, childGap = 12, childAlignment = {y = .Center}, layoutDirection = .LeftToRight}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 50, max = 56})}, padding = {top = 8, right = 20, bottom = 8, left = 20}, childGap = 16, childAlignment = {y = .Center}, layoutDirection = .LeftToRight}, backgroundColor = CLAY_BG_TOPBAR, - border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 0, 1, 0}}, + border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 1, 0, 0}}, }) { - // Screen name if clay.UI(clay.ID("PipelineTitle"))({ - layout = {sizing = {width = clay.SizingFixed(110)}}, + layout = {sizing = {width = clay.SizingFixed(120)}}, }) { clay_title_text(ui.screen_name(app.controller.active_screen), size = CLAY_FONT_SIZE_LG) } - // 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 @@ -107,54 +174,61 @@ declare_pipeline_bar :: proc(app: ^GUI_App_State) { {"Layout", layout_ok}, {"Export", export_ok}, } - for i in 0 ..< len(steps) { - var_name := steps[i].name - is_current := (i == 0 && !script_ok) || (i == 1 && script_ok && !panels_ok) || (i == 2 && panels_ok && !layout_ok) || (i == 3 && layout_ok && !export_ok) || (i == 3 && export_ok) - circle_color: clay.Color = CLAY_BTN_DISABLED - if steps[i].done { circle_color = CLAY_SUCCESS } - if is_current && !steps[i].done { circle_color = CLAY_ACCENT } - if clay.UI(clay.ID("PStep", u32(i)))({ - layout = {sizing = {width = clay.SizingFixed(84), height = clay.SizingFixed(36)}, layoutDirection = .TopToBottom, childAlignment = {x = .Center}, childGap = 1}, - backgroundColor = CLAY_NAV_HOVER_BG if clay.Hovered() else clay.Color{0, 0, 0, 0}, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), - }) { - mark := "○" - mark_color: clay.Color = CLAY_TEXT_TERTIARY - if steps[i].done { mark = "●"; mark_color = CLAY_SUCCESS } - if is_current && !steps[i].done { mark_color = CLAY_ACCENT } - clay.Text(mark, {fontId = CLAY_FONT_BODY, fontSize = 14, textColor = mark_color}) - clay.Text(var_name, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = mark_color}) - } - if i < len(steps) - 1 { - line_color: clay.Color = clay.Color{255, 255, 255, 20} - if steps[i].done { line_color = CLAY_SUCCESS } - if clay.UI(clay.ID("PLine", u32(i)))({ - layout = {sizing = {width = clay.SizingFixed(12), height = clay.SizingFixed(1)}}, - backgroundColor = line_color, - }) {} + if clay.UI(clay.ID("PipelineSteps"))({ + layout = {layoutDirection = .LeftToRight, childGap = 0, childAlignment = {y = .Center}, sizing = {width = clay.SizingFit({}), height = clay.SizingFit({})}}, + }) { + for i in 0 ..< len(steps) { + is_current := (i == 0 && !script_ok) || (i == 1 && script_ok && !panels_ok) || (i == 2 && panels_ok && !layout_ok) || (i == 3 && layout_ok && !export_ok) || (i == 3 && export_ok) + is_hov := clay.Hovered() + bg := clay.Color{0, 0, 0, 0} + if is_hov { bg = CLAY_NAV_HOVER_BG } + + if clay.UI(clay.ID("PStep", u32(i)))({ + layout = {sizing = {width = clay.SizingFixed(72), height = clay.SizingFixed(32)}, layoutDirection = .TopToBottom, childAlignment = {x = .Center}, childGap = 2}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + dot_color: clay.Color = CLAY_TEXT_DISABLED + if steps[i].done { dot_color = CLAY_SUCCESS } + if is_current && !steps[i].done { dot_color = CLAY_ACCENT } + + if clay.UI(clay.ID(fmt.tprintf("PStepDot%d", i)))({ + layout = {sizing = {width = clay.SizingFixed(8), height = clay.SizingFixed(8)}}, + backgroundColor = dot_color, + cornerRadius = clay.CornerRadiusAll(4), + }) {} + + clay.Text(steps[i].name, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = dot_color}) + } + if i < len(steps) - 1 { + line_color: clay.Color = CLAY_BORDER_SUBTLE + if steps[i].done { line_color = CLAY_SUCCESS } + if clay.UI(clay.ID("PLine", u32(i)))({ + layout = {sizing = {width = clay.SizingFixed(16), height = clay.SizingFixed(2)}}, + backgroundColor = line_color, + cornerRadius = clay.CornerRadiusAll(1), + }) {} + } } } - // Spacer pspacer := clay.UI(clay.ID("PBarSpacer")) if pspacer({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {} - // Pipeline progress count ready, total := ready_stage_count(app.controller) if clay.UI(clay.ID("PBarProgress"))({ - layout = {layoutDirection = .LeftToRight, childAlignment = {y = .Center}, childGap = 4}, + layout = {layoutDirection = .LeftToRight, childAlignment = {y = .Center}, childGap = 8}, }) { - clay_body_text(fmt.tprintf("%d/%d", ready, total), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) progress := f32(0) if total > 0 { progress = f32(ready) / f32(total) } - if progress > 0 && progress <= 100 { - pct := f32(progress * 100); if pct > 100 { pct = 100 } - if clay.UI(clay.ID("PBarMinibar"))({ - layout = {sizing = {width = clay.SizingFixed(60), height = clay.SizingFixed(4)}, layoutDirection = .LeftToRight}, - backgroundColor = CLAY_PROGRESS_TRACK, - cornerRadius = clay.CornerRadiusAll(2), - }) { + if clay.UI(clay.ID("PBarMinibar"))({ + layout = {sizing = {width = clay.SizingFixed(64), height = clay.SizingFixed(4)}, layoutDirection = .LeftToRight}, + backgroundColor = CLAY_PROGRESS_TRACK, + cornerRadius = clay.CornerRadiusAll(2), + }) { + pct := f32(progress); if pct > 1 { pct = 1 } + if pct > 0 { if clay.UI(clay.ID("PBarMinibarFill"))({ layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}}, backgroundColor = CLAY_PROGRESS_FILL, @@ -162,28 +236,30 @@ declare_pipeline_bar :: proc(app: ^GUI_App_State) { }) {} } } + clay_body_text(fmt.tprintf("%d/%d", ready, total), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } - // Status message (truncated) - clay_body_text(fmt.tprintf("C:%d P:%d", len(app.controller.state.characters), len(app.controller.state.panel_images)), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - msg := app.status_msg - if len(msg) > 40 { msg = fmt.tprintf("%s...", msg[:37]) } - clay_body_text(msg, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + is_casual := app.controller.state.user_mode == .Casual + if clay.UI(clay.ID("ModeToggleRow"))({ + layout = {layoutDirection = .LeftToRight, childGap = 2, padding = {top = 2, right = 2, bottom = 2, left = 2}}, + backgroundColor = CLAY_BG_STRIP, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + }) { + declare_nav_chip("btn_mode_casual", "Casual", is_casual) + declare_nav_chip("btn_mode_pro", "Pro", !is_casual) + } } } -// ─── Workspace Declaration ──────────────────────────────────────── - -// ─── Workspace Declaration ──────────────────────────────────────── -declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool, pages_count: int) { +declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool, pages_count: int, bp: shared.Breakpoint, main_w: i32) { screen := app.controller.active_screen if clay.UI(clay.ID("Workspace"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 12, right = 16, bottom = 12, left = 16}, childGap = 8}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = CLAY_SPACE_12, right = CLAY_SPACE_20, bottom = CLAY_SPACE_12, left = CLAY_SPACE_20}, childGap = CLAY_SPACE_12}, }) { switch screen { case .Story: - declare_story_workspace(app) + declare_story_workspace(app, bp) declare_action_log(app) case .Script: declare_script_workspace(app) @@ -206,33 +282,25 @@ declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_e } } -// ─── Script Workspace ──────────────────────────────────────────── - -// ─── Bottom Bar ─────────────────────────────────────────────────── -declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export: bool, next_hint: string, has_fal_key: bool) { +declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export: bool, next_hint: string, has_fal_key: bool, bp: shared.Breakpoint) { if clay.UI(clay.ID("BottomBar"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 50, max = 44})}, padding = {top = 6, right = 12, bottom = 6, left = 12}, childGap = 8, childAlignment = {y = .Center}, layoutDirection = .LeftToRight}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 48, max = 52})}, padding = {top = 6, right = 16, bottom = 6, left = 16}, childGap = 6, childAlignment = {y = .Center}, layoutDirection = .LeftToRight}, backgroundColor = CLAY_BG_TOPBAR, border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 1, 0, 0}}, }) { - // Left: File ops - declare_button_danger("btn_new", "New") - declare_button_soft("btn_open", "Open") - declare_button_soft("btn_save", "Save") + declare_button_default("btn_new", "New") + declare_button_default("btn_open", "Open") + declare_button_default("btn_save", "Save") - // Spacer bbspacer1 := clay.UI(clay.ID("BBSpacer1")) if bbspacer1({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {} - // Center: Primary CTA cta_label := fmt.tprintf("Next: %s", next_hint) declare_button_recommended("btn_next", cta_label) - // Spacer bbspacer2 := clay.UI(clay.ID("BBSpacer2")) if bbspacer2({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {} - // Right: quick actions if has_fal_key { declare_nav_chip("btn_fal_panels", "FAL", app.use_fal_panels) } @@ -241,9 +309,7 @@ declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_ declare_button_small("btn_layout", "Layout") declare_button_small("btn_export", "Export") declare_button_primary("btn_auto", "Auto-All") - declare_button_soft("btn_autosave_toggle", - fmt.tprintf("⏱ %s", "ON" if app.autosave_enabled else "OFF")) + declare_button_ghost("btn_autosave_toggle", + fmt.tprintf("%s", "ON" if app.autosave_enabled else "OFF")) } } - -// ─── Click Processing ────────────────────────────────────────────── diff --git a/odin/src/gui/clay_layout.odin b/odin/src/gui/clay_layout.odin index f7ddd95..484a689 100644 --- a/odin/src/gui/clay_layout.odin +++ b/odin/src/gui/clay_layout.odin @@ -4,15 +4,14 @@ import clay "clay:." import "base:runtime" import "core:fmt" import "core:math" +import "core:os" import "core:strings" import rl "vendor:raylib" -// --- 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, @@ -21,19 +20,16 @@ Raylib_Font :: struct { raylib_fonts: [dynamic]Raylib_Font -// --- 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() if errorData.errorType == .DuplicateId || errorData.errorType == .PercentageOver1 { return } fmt.eprintf("CLAY ERROR: %v\n", errorData) } -// --- Clay State --- Clay_State :: struct { arena_memory: [dynamic]u8, font_default: rl.Font, @@ -52,7 +48,26 @@ load_font_or_default :: proc(path: cstring) -> rl.Font { return rl.GetFontDefault() } -// --- Init / Shutdown --- +font_path_sans :: proc() -> cstring { + if os.exists("assets/fonts/AdwaitaSans-Regular.ttf") { + return "assets/fonts/AdwaitaSans-Regular.ttf" + } + if os.exists("/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf") { + return "/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf" + } + return "" +} + +font_path_mono :: proc() -> cstring { + if os.exists("assets/fonts/AdwaitaMono-Regular.ttf") { + return "assets/fonts/AdwaitaMono-Regular.ttf" + } + if os.exists("/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf") { + return "/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf" + } + return "" +} + clay_init :: proc(screen_w: i32, screen_h: i32) { min_memory_size := clay.MinMemorySize() clay_state.arena_memory = make([dynamic]u8, min_memory_size) @@ -61,9 +76,11 @@ clay_init :: proc(screen_w: i32, screen_h: i32) { clay.Initialize(arena, {f32(screen_w), f32(screen_h)}, {handler = clay_error_handler}) clay.SetMeasureTextFunction(clay_measure_text, nil) - clay_state.font_default = load_font_or_default("/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf") - clay_state.font_title = load_font_or_default("/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf") - clay_state.font_mono = load_font_or_default("/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf") + sans_path := font_path_sans() + mono_path := font_path_mono() + clay_state.font_default = load_font_or_default(sans_path) + clay_state.font_title = load_font_or_default(sans_path) + clay_state.font_mono = load_font_or_default(mono_path) raylib_fonts = make([dynamic]Raylib_Font, 0, 3) append(&raylib_fonts, Raylib_Font{fontId = CLAY_FONT_BODY, font = clay_state.font_default}) @@ -94,7 +111,6 @@ clay_update_input :: proc() { 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() @@ -129,7 +145,22 @@ clay_measure_text :: proc "c" (text: clay.StringSlice, config: ^clay.TextElement return {width = line_width * scaleFactor + total_spacing, height = f32(config.fontSize)} } -// --- Rendering --- +draw_shadow_rect :: proc(x, y, w, h: f32, radius: f32, shadow: rl.Color) { + if shadow.a == 0 { return } + offset := f32(4) + blur := f32(12) + spread := f32(2) + sx := x - spread + offset + sy := y - spread + offset + sw := w + spread * 2 + blur + sh := h + spread * 2 + blur + if radius > 0 { + rl.DrawRectangleRounded({sx, sy, sw, sh}, radius / min(sw, sh), 8, shadow) + } else { + rl.DrawRectangle(i32(sx), i32(sy), i32(sw), i32(sh), shadow) + } +} + 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) @@ -155,7 +186,16 @@ clay_raylib_render :: proc(render_commands: ^clay.ClayArray(clay.RenderCommand)) 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) + if texture.width > 0 && texture.height > 0 { + scale := min(bounds.width / f32(texture.width), bounds.height / f32(texture.height)) + draw_w := f32(texture.width) * scale + draw_h := f32(texture.height) * scale + offset_x := bounds.x + (bounds.width - draw_w) * 0.5 + offset_y := bounds.y + (bounds.height - draw_h) * 0.5 + if scale > 0.001 && draw_w > 1 && draw_h > 1 { + rl.DrawTextureEx(texture^, {offset_x, offset_y}, 0, scale, rl.WHITE) + } + } case .ScissorStart: rl.BeginScissorMode( i32(math.round(bounds.x)), @@ -268,8 +308,6 @@ clay_raylib_render :: proc(render_commands: ^clay.ClayArray(clay.RenderCommand)) } } -// --- Layout helpers (procs because Clay helpers require runtime evaluation) --- - clay_card_layout :: proc() -> clay.LayoutConfig { return clay.LayoutConfig { layoutDirection = .TopToBottom, @@ -282,6 +320,18 @@ clay_card_layout :: proc() -> clay.LayoutConfig { } } +clay_section_layout :: proc() -> clay.LayoutConfig { + return clay.LayoutConfig { + layoutDirection = .TopToBottom, + sizing = { + width = clay.SizingGrow({}), + height = clay.SizingFit({}), + }, + padding = {top = CLAY_SPACE_16, right = CLAY_SPACE_16, bottom = CLAY_SPACE_16, left = CLAY_SPACE_16}, + childGap = CLAY_SPACE_10, + } +} + clay_row_layout :: proc() -> clay.LayoutConfig { return clay.LayoutConfig { layoutDirection = .LeftToRight, @@ -298,7 +348,7 @@ clay_input_layout :: proc() -> clay.LayoutConfig { layoutDirection = .LeftToRight, sizing = { width = clay.SizingGrow({}), - height = clay.SizingFixed(40), + height = clay.SizingFixed(f32(CLAY_HEIGHT_MD)), }, padding = {top = 8, right = 12, bottom = 8, left = 12}, } @@ -309,25 +359,39 @@ clay_button_layout :: proc() -> clay.LayoutConfig { layoutDirection = .LeftToRight, sizing = { width = clay.SizingFit({}), - height = clay.SizingFixed(38), + height = clay.SizingFixed(f32(CLAY_HEIGHT_MD)), }, - padding = {top = 8, right = 16, bottom = 8, left = 16}, + padding = {top = 6, right = 16, bottom = 6, 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), + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(1)}, } } -// --- Text helpers --- +clay_elevated_card_style :: proc() -> clay.ElementDeclaration { + return { + layout = clay_card_layout(), + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), + border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(1)}, + } +} + +clay_section_style :: proc() -> clay.ElementDeclaration { + return { + layout = clay_section_layout(), + backgroundColor = CLAY_BG_CARD, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), + border = {color = CLAY_BORDER_SUBTLE, width = clay.BorderOutside(1)}, + } +} 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}) @@ -339,4 +403,8 @@ clay_title_text :: proc(text: string, color: clay.Color = CLAY_TEXT_BRIGHT, size 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 +} + +clay_label_text :: proc(text: string) { + clay.Text(text, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = CLAY_TEXT_TERTIARY, letterSpacing = 1}) +} diff --git a/odin/src/gui/clay_theme.odin b/odin/src/gui/clay_theme.odin index c1b7a32..77479dd 100644 --- a/odin/src/gui/clay_theme.odin +++ b/odin/src/gui/clay_theme.odin @@ -1,65 +1,85 @@ package gui import clay "clay:." +import rl "vendor:raylib" -// --- Color Palette --- -// --- Clay Color Palette (modernized dark theme) --- -// Named with CLAY_ prefix to avoid collision with existing theme.odin +CLAY_BG_BASE :: clay.Color{10, 10, 15, 255} +RL_BG_BASE :: rl.Color{10, 10, 15, 255} + +CLAY_BG_SIDEBAR :: clay.Color{14, 14, 22, 255} +RL_BG_SIDEBAR :: rl.Color{14, 14, 22, 255} + +CLAY_BG_TOPBAR :: clay.Color{16, 16, 24, 255} + +CLAY_BG_CARD :: clay.Color{22, 22, 34, 255} + +CLAY_BG_CARD_ALT :: clay.Color{18, 18, 30, 255} + +CLAY_BG_STRIP :: clay.Color{28, 28, 42, 255} -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_BG_INPUT :: clay.Color{16, 16, 26, 255} + +CLAY_BG_HOVER :: clay.Color{30, 30, 46, 255} + +CLAY_BG_SELECTED :: clay.Color{34, 34, 52, 255} + +CLAY_BORDER_CARD :: clay.Color{38, 38, 56, 255} +CLAY_BORDER_SUBTLE :: clay.Color{28, 28, 42, 255} +CLAY_BORDER_DIVIDER :: clay.Color{24, 24, 38, 255} +CLAY_BORDER_FOCUS :: clay.Color{99, 102, 241, 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_ACCENT_HOVER :: clay.Color{129, 132, 255, 255} +CLAY_ACCENT_MUTED :: clay.Color{79, 70, 229, 200} +CLAY_ACCENT_SURFACE:: clay.Color{67, 56, 202, 35} +CLAY_ACCENT_GLOW :: clay.Color{99, 102, 241, 50} -CLAY_TEXT_PRIMARY :: clay.Color{245, 245, 250, 255} -CLAY_TEXT_SECONDARY :: clay.Color{190, 190, 210, 255} -CLAY_TEXT_TERTIARY :: clay.Color{145, 145, 170, 255} -CLAY_TEXT_DISABLED :: clay.Color{100, 100, 120, 255} +CLAY_TEXT_PRIMARY :: clay.Color{240, 240, 248, 255} +CLAY_TEXT_SECONDARY :: clay.Color{175, 175, 200, 255} +CLAY_TEXT_TERTIARY :: clay.Color{120, 120, 150, 255} +CLAY_TEXT_DISABLED :: clay.Color{75, 75, 100, 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_SUCCESS :: clay.Color{52, 211, 153, 255} +CLAY_SUCCESS_DIM :: clay.Color{52, 211, 153, 80} +CLAY_WARNING :: clay.Color{251, 191, 36, 255} +CLAY_WARNING_DIM :: clay.Color{251, 191, 36, 80} +CLAY_ERROR :: clay.Color{248, 113, 113, 255} +CLAY_ERROR_DIM :: clay.Color{248, 113, 113, 80} +CLAY_INFO :: clay.Color{96, 165, 250, 255} +CLAY_INFO_DIM :: clay.Color{96, 165, 250, 80} -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_BTN_DEFAULT :: clay.Color{32, 32, 48, 255} +CLAY_BTN_DEFAULT_HOVER :: clay.Color{44, 44, 62, 255} +CLAY_BTN_SOFT :: clay.Color{50, 46, 110, 255} +CLAY_BTN_SOFT_HOVER :: clay.Color{66, 60, 132, 255} +CLAY_BTN_DANGER :: clay.Color{180, 30, 30, 255} +CLAY_BTN_DANGER_HOVER :: clay.Color{210, 45, 45, 255} +CLAY_BTN_DISABLED :: clay.Color{24, 24, 36, 255} +CLAY_BTN_GHOST :: clay.Color{0, 0, 0, 0} +CLAY_BTN_GHOST_HOVER :: clay.Color{255, 255, 255, 12} 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_NAV_ACTIVE_BG :: clay.Color{99, 102, 241, 18} +CLAY_NAV_HOVER_BG :: clay.Color{255, 255, 255, 8} -CLAY_INPUT_BORDER :: clay.Color{55, 55, 75, 255} +CLAY_INPUT_BORDER :: clay.Color{42, 42, 62, 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} +CLAY_PROGRESS_TRACK :: clay.Color{24, 24, 38, 255} +CLAY_PROGRESS_FILL := clay.Color{99, 102, 241, 255} -// --- Spacing (4px grid) --- -// --- Spacing (4px grid) --- +CLAY_SHADOW_SM :: rl.Color{0, 0, 0, 30} +CLAY_SHADOW_MD :: rl.Color{0, 0, 0, 50} +CLAY_SHADOW_LG :: rl.Color{0, 0, 0, 70} + +CLAY_SPACE_2 :: u16(2) CLAY_SPACE_4 :: u16(4) +CLAY_SPACE_6 :: u16(6) CLAY_SPACE_8 :: u16(8) +CLAY_SPACE_10 :: u16(10) CLAY_SPACE_12 :: u16(12) CLAY_SPACE_16 :: u16(16) CLAY_SPACE_20 :: u16(20) @@ -67,23 +87,158 @@ CLAY_SPACE_24 :: u16(24) CLAY_SPACE_32 :: u16(32) CLAY_SPACE_40 :: u16(40) CLAY_SPACE_48 :: u16(48) +CLAY_SPACE_64 :: u16(64) -// --- Font Sizes --- -// --- Font Sizes --- -CLAY_FONT_SIZE_SM :: u16(15) -CLAY_FONT_SIZE_MD :: u16(17) -CLAY_FONT_SIZE_LG :: u16(21) -CLAY_FONT_SIZE_XL :: u16(26) -CLAY_FONT_SIZE_2XL:: u16(32) +CLAY_FONT_SIZE_XS :: u16(12) +CLAY_FONT_SIZE_SM :: u16(14) +CLAY_FONT_SIZE_MD :: u16(16) +CLAY_FONT_SIZE_LG :: u16(20) +CLAY_FONT_SIZE_XL :: u16(24) +CLAY_FONT_SIZE_2XL :: u16(30) +CLAY_FONT_SIZE_3XL :: u16(36) -// --- Corner Radius --- -// --- 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) +CLAY_RADIUS_XS :: f32(3) +CLAY_RADIUS_SM :: f32(6) +CLAY_RADIUS_MD :: f32(10) +CLAY_RADIUS_LG :: f32(14) +CLAY_RADIUS_XL :: f32(20) +CLAY_RADIUS_2XL :: f32(28) -// --- 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) + +CLAY_HEIGHT_SM :: u16(28) +CLAY_HEIGHT_MD :: u16(36) +CLAY_HEIGHT_LG :: u16(44) +CLAY_HEIGHT_XL :: u16(52) + +Design_Tokens :: struct { + surfaces: struct { + base: clay.Color, + sidebar: clay.Color, + topbar: clay.Color, + card: clay.Color, + card_alt: clay.Color, + strip: clay.Color, + overlay: clay.Color, + input: clay.Color, + hover: clay.Color, + selected: clay.Color, + }, + borders: struct { + card: clay.Color, + subtle: clay.Color, + divider: clay.Color, + focus: clay.Color, + }, + accent: struct { + primary: clay.Color, + hover: clay.Color, + muted: clay.Color, + surface: clay.Color, + glow: clay.Color, + }, + text: struct { + primary: clay.Color, + secondary: clay.Color, + tertiary: clay.Color, + disabled: clay.Color, + bright: clay.Color, + }, + semantic: struct { + success: clay.Color, + warning: clay.Color, + error: clay.Color, + info: clay.Color, + }, + spacing: struct { + xs: u16, + sm: u16, + md: u16, + lg: u16, + xl: u16, + xxl: u16, + }, + typography: struct { + xs: u16, + sm: u16, + md: u16, + lg: u16, + xl: u16, + xxl: u16, + }, + radius: struct { + xs: f32, + sm: f32, + md: f32, + lg: f32, + xl: f32, + }, +} + +derive_tokens :: proc() -> Design_Tokens { + return Design_Tokens{ + surfaces = { + base = CLAY_BG_BASE, + sidebar = CLAY_BG_SIDEBAR, + topbar = CLAY_BG_TOPBAR, + card = CLAY_BG_CARD, + card_alt = CLAY_BG_CARD_ALT, + strip = CLAY_BG_STRIP, + overlay = CLAY_BG_OVERLAY, + input = CLAY_BG_INPUT, + hover = CLAY_BG_HOVER, + selected = CLAY_BG_SELECTED, + }, + borders = { + card = CLAY_BORDER_CARD, + subtle = CLAY_BORDER_SUBTLE, + divider = CLAY_BORDER_DIVIDER, + focus = CLAY_BORDER_FOCUS, + }, + accent = { + primary = CLAY_ACCENT, + hover = CLAY_ACCENT_HOVER, + muted = CLAY_ACCENT_MUTED, + surface = CLAY_ACCENT_SURFACE, + glow = CLAY_ACCENT_GLOW, + }, + text = { + primary = CLAY_TEXT_PRIMARY, + secondary = CLAY_TEXT_SECONDARY, + tertiary = CLAY_TEXT_TERTIARY, + disabled = CLAY_TEXT_DISABLED, + bright = CLAY_TEXT_BRIGHT, + }, + semantic = { + success = CLAY_SUCCESS, + warning = CLAY_WARNING, + error = CLAY_ERROR, + info = CLAY_INFO, + }, + spacing = { + xs = CLAY_SPACE_4, + sm = CLAY_SPACE_8, + md = CLAY_SPACE_16, + lg = CLAY_SPACE_24, + xl = CLAY_SPACE_32, + xxl = CLAY_SPACE_48, + }, + typography = { + xs = CLAY_FONT_SIZE_XS, + sm = CLAY_FONT_SIZE_SM, + md = CLAY_FONT_SIZE_MD, + lg = CLAY_FONT_SIZE_LG, + xl = CLAY_FONT_SIZE_XL, + xxl = CLAY_FONT_SIZE_2XL, + }, + radius = { + xs = CLAY_RADIUS_XS, + sm = CLAY_RADIUS_SM, + md = CLAY_RADIUS_MD, + lg = CLAY_RADIUS_LG, + xl = CLAY_RADIUS_XL, + }, + } +} diff --git a/odin/src/gui/detail_panels.odin b/odin/src/gui/detail_panels.odin index 926e988..2f321d9 100644 --- a/odin/src/gui/detail_panels.odin +++ b/odin/src/gui/detail_panels.odin @@ -2,13 +2,13 @@ package gui import clay "clay:." import "core:fmt" +import "core:strings" import "../core" import rl "vendor:raylib" -// ─── 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") + declare_section_header("ScriptDetailHeader", "Script Detail") page_count := len(app.controller.state.script.pages) if page_count == 0 { @@ -20,7 +20,7 @@ declare_script_detail :: proc(app: ^GUI_App_State) { 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}, + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 12}, }) { declare_stat_chip("stat_page", "Page", idx + 1) declare_stat_chip("stat_panels", "Panels", len(page.panels)) @@ -29,17 +29,22 @@ declare_script_detail :: proc(app: ^GUI_App_State) { 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}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 4}, }) { for pn in page.panels { desc := pn.description if len(desc) == 0 { desc = "(no description)" } + is_hov := clay.Hovered() + bg := clay.Color{0, 0, 0, 0} + if is_hov { bg = CLAY_BG_HOVER } if clay.UI(clay.ID(fmt.tprintf("ScriptDetailPanel_%d", pn.panel_number)))({ - layout = {layoutDirection = .TopToBottom, padding = {top = 2, right = 4, bottom = 2, left = 4}}, + layout = {layoutDirection = .TopToBottom, padding = {top = 6, right = 8, bottom = 6, left = 8}, childGap = 2}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), }) { 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) + clay_body_text(fmt.tprintf("\"%s\"", pn.dialogue[0].text), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } } } @@ -47,12 +52,9 @@ declare_script_detail :: proc(app: ^GUI_App_State) { } } -// ─── Panels Detail Panel (Clay) ──────────────────────────────────── - -// ─── 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") + declare_section_header("PanelsDetailHeader", "Panel Results") panel_count := count_script_panels(app.controller.state.script) if panel_count == 0 { @@ -83,7 +85,9 @@ declare_panels_detail :: proc(app: ^GUI_App_State) { 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) + clay_body_text(fmt.tprintf("Panel %d/%d", idx+1, panel_count), color = status_color, size = CLAY_FONT_SIZE_SM) + clay_muted_text(fmt.tprintf("page %d # %d", page_num, panel.panel_number)) + clay.UI(clay.ID("PanelDetailHdrSpacer"))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) if has_img { declare_button_small("btn_panel_regenerate", "Regenerate") } else { @@ -91,9 +95,7 @@ declare_panels_detail :: proc(app: ^GUI_App_State) { } } - // Panel image preview + info side-by-side if has_img { - // Try to load and show the image panel_img := app.controller.state.panel_images[panel.panel_id] _, loaded := load_panel_texture(&app.panel_textures, panel.panel_id, panel_img.url) img_shown := false @@ -101,12 +103,12 @@ declare_panels_detail :: proc(app: ^GUI_App_State) { tex_ptr := &app.panel_textures[panel.panel_id] if tex_ptr.id != 0 { if clay.UI(clay.ID("PanelImageRow"))({ - layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 12}, + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = CLAY_SPACE_12}, }) { if clay.UI(clay.ID("PanelImagePreview"))({ layout = {sizing = {width = clay.SizingFixed(200), height = clay.SizingFixed(200)}}, backgroundColor = CLAY_BG_STRIP, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), image = {imageData = rawptr(tex_ptr)}, }) {} if clay.UI(clay.ID("PanelImageInfo"))({layout = {layoutDirection = .TopToBottom, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 2}}) { @@ -116,10 +118,10 @@ declare_panels_detail :: proc(app: ^GUI_App_State) { clay_body_text(fmt.tprintf("err: %s", err_msg), color = CLAY_ERROR, size = CLAY_FONT_SIZE_SM) } img := panel_img - clay_muted_text(fmt.tprintf("img: %dx%d seed:%d", img.width, img.height, img.seed)) + clay_muted_text(fmt.tprintf("%dx%d seed:%d", img.width, img.height, img.seed)) 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) + clay_body_text(desc, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) } } img_shown = true @@ -132,32 +134,26 @@ declare_panels_detail :: proc(app: ^GUI_App_State) { clay_body_text(fmt.tprintf("err: %s", err_msg), color = CLAY_ERROR, size = CLAY_FONT_SIZE_SM) } img := panel_img - clay_muted_text(fmt.tprintf("img: %dx%d seed:%d (failed to load)", img.width, img.height, img.seed)) + clay_muted_text(fmt.tprintf("%dx%d seed:%d (failed to load)", img.width, img.height, img.seed)) 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) + clay_body_text(desc, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) } } else { 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") + clay_muted_text("not generated yet") } 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)) - } + clay_body_text(desc, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) } if clay.UI(clay.ID("PanelListScroll"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 1}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, }) { visible_rows: int = 8 if visible_rows > panel_count { visible_rows = panel_count } @@ -173,13 +169,11 @@ declare_panels_detail :: proc(app: ^GUI_App_State) { for i in start.. 100 { w_pct = 100 } else if w_pct < 0 { w_pct = 0 } - h_pct := f32(cell.h * 100); if h_pct > 100 { h_pct = 100 } else if h_pct < 0 { h_pct = 0 } - if clay.UI(clay.ID(fmt.tprintf("wire_cell_%d", i)))({ - layout = {sizing = {width = clay.SizingPercent(w_pct), height = clay.SizingPercent(h_pct)}, padding = {top = 2, left = 2, right = 2, bottom = 2}}, - backgroundColor = CLAY_ACCENT_SURFACE, - cornerRadius = clay.CornerRadiusAll(2), - border = {color = CLAY_ACCENT_MUTED, width = clay.BorderOutside(1)}, + if clay.UI(clay.ID("LayoutWireframe"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom}, + backgroundColor = CLAY_BG_STRIP, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + }) {} + + if app.layout_selected_panel >= 0 && app.layout_selected_panel < len(layout_val.panels) { + sel_panel := layout_val.panels[app.layout_selected_panel] + if clay.UI(clay.ID("LayoutPanelInfo"))({ + layout = {layoutDirection = .TopToBottom, padding = {top = 8, right = 10, bottom = 8, left = 10}, childGap = 4, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}}, + backgroundColor = CLAY_BG_CARD_ALT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + border = {color = CLAY_ACCENT, width = clay.BorderOutside(1)}, }) { - if cell.w > 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}) + status_label := "missing" + status_color := CLAY_TEXT_TERTIARY + if _, has_img := app.controller.state.panel_images[sel_panel.panel_id]; has_img { + status_label = "ready" + status_color = CLAY_SUCCESS + } + if _, has_err := app.controller.state.panel_errors[sel_panel.panel_id]; has_err { + status_label = "error" + status_color = CLAY_ERROR + } + if clay.UI(clay.ID("LayoutPanelInfoHeader"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 6}, + }) { + clay_body_text(fmt.tprintf("Panel %d", sel_panel.panel_number), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + declare_status_badge("layout_sel_status", status_label, status_label == "ready") + } + + panel_data, _, panel_ok := panel_by_flat_index(app.controller.state.script, app.layout_selected_panel) + if panel_ok { + clay_body_text(shot_type_label(panel_data.shot_type), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) + desc := panel_data.description + if len(desc) > 80 { desc = fmt.tprintf("%s...", desc[:77]) } + clay_body_text(desc, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_XS) + if len(panel_data.characters_present) > 0 { + if clay.UI(clay.ID("LayoutPanelChars"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}}) { + for cid in panel_data.characters_present { + declare_nav_chip(fmt.tprintf("layout_char_%s", cid), cid, false) + } + } + } + } else { + clay_muted_text(sel_panel.panel_id) } } } } if clay.UI(clay.ID("LayoutPageScroll"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 1}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, }) { + clay_label_text("PAGES") visible_rows: int = 6 if visible_rows > layout_count { visible_rows = layout_count } start := idx - visible_rows / 2 @@ -283,16 +314,22 @@ declare_layout_detail :: proc(app: ^GUI_App_State) { for i in start.. 25 { preview = preview[:25] } if len(preview) == 0 { preview = "(empty)" } + row_bg := clay.Color{0, 0, 0, 0} + if is_selected { row_bg = CLAY_BG_SELECTED } + else if is_hov { row_bg = CLAY_BG_HOVER } + if clay.UI(clay.ID(fmt.tprintf("bubble_row_%d", i)))({ - layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(26)}, padding = {top = 2, left = 4}, childGap = 4, childAlignment = {y = .Center}}, - backgroundColor = clay.Color{0, 0, 0, 0}, + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(26)}, padding = {top = 2, right = 6, bottom = 2, left = 6}, childGap = 4, childAlignment = {y = .Center}}, + backgroundColor = row_bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XS), }) { - 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}) + clay.Text(fmt.tprintf("[%s] %s", bubble_type_name(b.type), preview), {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = row_color}) if is_selected { + clay.UI(clay.ID(fmt.tprintf("bubble_row_spacer_%d", i)))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) declare_button_small(fmt.tprintf("btn_bubble_delete_%d", i), "x") } } @@ -388,9 +430,10 @@ declare_bubbles_detail :: proc(app: ^GUI_App_State) { 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), + layout = {layoutDirection = .TopToBottom, padding = {top = 10, right = 12, bottom = 10, left = 12}, childGap = 6, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}}, + backgroundColor = CLAY_BG_CARD_ALT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + border = {color = CLAY_BORDER_SUBTLE, width = clay.BorderOutside(1)}, }) { clay_body_text(fmt.tprintf("Editing: %s", bubble_type_name(selected.type)), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) @@ -400,27 +443,30 @@ declare_bubbles_detail :: proc(app: ^GUI_App_State) { types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect} for t in types { btn_id := fmt.tprintf("btn_btype_%d", int(t)) + is_active := selected.type == t + is_hov := clay.Hovered() + bg := clay_color_for_bubble_type(is_active) + if !is_active && is_hov { bg = CLAY_BTN_DEFAULT_HOVER } if clay.UI(clay.ID(btn_id))({ - layout = {sizing = {width = clay.SizingFit({min = 50, max = 20}), height = clay.SizingFixed(26)}, padding = {top = 2, right = 4, bottom = 2, left = 4}, childAlignment = {x = .Center, y = .Center}}, - backgroundColor = clay_color_for_bubble_type(selected.type == t), + layout = {sizing = {width = clay.SizingFit({min = 44, max = 20}), height = clay.SizingFixed(24)}, padding = {top = 2, right = 6, bottom = 2, left = 6}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = bg, 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)}) + clay.Text(bubble_type_name(t), {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = clay_text_color_for_bubble_type(is_active)}) } } } - // Text editing area if clay.UI(clay.ID("BubbleTextRow"))({ layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4, childAlignment = {y = .Center}}, }) { - clay_muted_text("Text:") + clay_label_text("TEXT") text_to_show := app.bubble_edit_text if app.selected_field == 7 else selected.text if len(text_to_show) == 0 && app.selected_field != 7 { text_to_show = "(click to edit)" } if clay.UI(clay.ID("field_bubble_text"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, padding = {top = 6, right = 8, bottom = 6, left = 8}}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(f32(CLAY_HEIGHT_SM))}, padding = {top = 6, right = 8, bottom = 6, left = 8}}, backgroundColor = CLAY_BG_INPUT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), border = {color = CLAY_INPUT_FOCUS if app.selected_field == 7 else CLAY_INPUT_BORDER, width = clay.BorderOutside(1)}, @@ -436,6 +482,151 @@ declare_bubbles_detail :: proc(app: ^GUI_App_State) { } +draw_layout_wireframe :: proc(app: ^GUI_App_State) { + if app.controller.active_screen != .Layout { return } + layout_count := len(app.controller.state.page_layouts) + if layout_count == 0 { return } + + wire_data := clay.GetElementData(clay.ID("LayoutWireframe")) + if !wire_data.found { return } + bounds := wire_data.boundingBox + if bounds.width <= 0 || bounds.height <= 0 { return } + + idx := clamp_layout_cursor(layout_count, app.summary_opts.layout_page_cursor) + if idx >= layout_count { return } + layout_val := app.controller.state.page_layouts[idx] + + padding := f32(8) + avail_w := bounds.width - padding * 2 + avail_h := bounds.height - padding * 2 + + page_aspect := f32(layout_val.width) / f32(layout_val.height) + if layout_val.width <= 0 || layout_val.height <= 0 { page_aspect = 0.707 } + + inner_w := avail_w + inner_h := avail_h + if inner_w / inner_h > page_aspect { + inner_w = inner_h * page_aspect + } else { + inner_h = inner_w / page_aspect + } + + inner_x := bounds.x + padding + (avail_w - inner_w) * 0.5 + inner_y := bounds.y + padding + (avail_h - inner_h) * 0.5 + + rl.DrawRectangleRounded( + {bounds.x, bounds.y, bounds.width, bounds.height}, + 0.03, 6, + rl.Color{12, 12, 20, 255}, + ) + + rl.DrawRectangleRounded( + {inner_x, inner_y, inner_w, inner_h}, + 0.02, 4, + rl.Color{16, 16, 24, 255}, + ) + + app.wireframe_cell_count = 0 + + for i in 0.. 4 && ch > 4 { + scale_x := cw / f32(tex.width) + scale_y := ch / f32(tex.height) + scale := min(scale_x, scale_y) + draw_w := f32(tex.width) * scale + draw_h := f32(tex.height) * scale + offset_x := cx + (cw - draw_w) * 0.5 + offset_y := cy + (ch - draw_h) * 0.5 + + rl.DrawTextureEx(tex, {offset_x, offset_y}, 0, scale, rl.WHITE) + drawn_image = true + } + } + + if !drawn_image { + bg := rl.Color{28, 28, 44, 220} + if has_error { + bg = rl.Color{50, 25, 25, 220} + } + rl.DrawRectangleRounded({cx, cy, cw, ch}, 0.03, 4, bg) + + if cw > 40 && ch > 30 { + no_img_text := "No image" + if has_error { + no_img_text = "Error" + } + rl.DrawText( + strings.unsafe_string_to_cstring(no_img_text), + i32(cx + (cw - 52) * 0.5), i32(cy + ch * 0.5 - 6), + 10, + rl.Color{100, 100, 130, 180}, + ) + } + } + + if is_selected { + rl.DrawRectangleRoundedLines({cx - 1, cy - 1, cw + 2, ch + 2}, 0.03, 4, rl.Color{99, 102, 241, 60}) + rl.DrawRectangleRoundedLines({cx, cy, cw, ch}, 0.03, 4, border_color) + } else { + rl.DrawRectangleRoundedLines({cx, cy, cw, ch}, 0.03, 4, border_color) + } + + if cw > 20 && ch > 14 { + badge_w: i32 = 18 + if panel_layout.panel_number >= 10 { badge_w = 24 } + rl.DrawRectangle(i32(cx + 2), i32(cy + 2), badge_w, 14, rl.Color{0, 0, 0, 170}) + num_text := fmt.tprintf("%d", panel_layout.panel_number) + rl.DrawText( + strings.unsafe_string_to_cstring(num_text), + i32(cx + 4), i32(cy + 3), + 10, + rl.Color{220, 220, 240, 230}, + ) + } + + if has_error && cw > 20 && ch > 14 { + rl.DrawText("!", i32(cx + cw - 10), i32(cy + 2), 10, rl.Color{248, 113, 113, 230}) + } + } +} + clay_color_for_bubble_type :: proc(active: bool) -> clay.Color { if active { return CLAY_ACCENT } return CLAY_BTN_DEFAULT @@ -446,29 +637,24 @@ clay_text_color_for_bubble_type :: proc(active: bool) -> clay.Color { return CLAY_TEXT_SECONDARY } -// ─── Action Log (Clay) ───────────────────────────────────────────── - -// ─── 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()}) { + if clay.UI(clay.ID("ActionLogHeader"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}, + }) { + declare_section_header("ActionLogTitle", "Action Log") + clay.UI(clay.ID("ActionLogHeaderSpacer"))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) declare_button_small("btn_log_reset", "Reset View") - declare_button_small("btn_log_report", "Session Report") - declare_button_small("btn_log_copy", "Copy Log") - declare_button_small("btn_log_diag", "Diagnostics") - declare_button_small("btn_log_status_copy", "Copy Status") - declare_button_small("btn_log_clear", "Clear Log") - declare_button_small("btn_log_diag_copy", "Copy Diag") + declare_button_small("btn_log_report", "Report") + declare_button_small("btn_log_copy", "Copy") + declare_button_small("btn_log_diag", "Diag") + declare_button_small("btn_log_clear", "Clear") } 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)) + clay_body_text(fmt.tprintf("%d lines, %s first", app.log_show_lines, order_label), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) - // Log entries if clay.UI(clay.ID("LogScroll"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, }) { @@ -496,8 +682,8 @@ declare_action_log :: proc(app: ^GUI_App_State) { 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) + clay_body_text(entry_text, color = entry_color, size = CLAY_FONT_SIZE_XS) } } } -} \ No newline at end of file +} diff --git a/odin/src/gui/local_helpers.odin b/odin/src/gui/local_helpers.odin index 5a600e5..a5b4053 100644 --- a/odin/src/gui/local_helpers.odin +++ b/odin/src/gui/local_helpers.odin @@ -33,13 +33,4 @@ local_panel_id_by_index :: proc(i: int) -> string { return fmt.tprintf("panel_local_overflow_%d", i) } -append_char :: proc(dst: ^string, ch: rune) { - dst^ = fmt.aprintf("%s%c", dst^, ch) -} -pop_char :: proc(dst: ^string) { - if len(dst^) == 0 { - return - } - dst^ = dst^[:len(dst^)-1] -} diff --git a/odin/src/gui/primitives.odin b/odin/src/gui/primitives.odin index 70e16cb..2ae9f17 100644 --- a/odin/src/gui/primitives.odin +++ b/odin/src/gui/primitives.odin @@ -3,31 +3,34 @@ package gui import clay "clay:." import "core:fmt" -// ─── 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 active { bg = CLAY_NAV_ACTIVE_BG } + is_hov := clay.Hovered() + hov_bg: clay.Color = CLAY_NAV_HOVER_BG + if is_hov && !active { bg = hov_bg } if clay.UI(clay.ID(id))({ - layout = {sizing = {width = clay.SizingFit({min = 70, max = 34}), height = clay.SizingFixed(34)}, padding = {top = 4, right = 12, bottom = 4, left = 12}, childAlignment = {x = .Center, y = .Center}}, + layout = {sizing = {width = clay.SizingFit({min = 44, max = 120}), height = clay.SizingFixed(f32(CLAY_HEIGHT_SM))}, padding = {top = 4, right = 10, bottom = 4, left = 10}, 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) + else if is_hov { text_color = CLAY_TEXT_PRIMARY } + clay_body_text(label, color = text_color, size = CLAY_FONT_SIZE_SM) } } 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 } + 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), + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), }) { - clay_body_text(label, color = CLAY_TEXT_PRIMARY) + clay_body_text(label, color = CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) } } @@ -47,24 +50,53 @@ 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) { +declare_button_ghost :: proc(id: string, label: string) { + is_hov := clay.Hovered() + bg := CLAY_BTN_GHOST + if is_hov { bg = CLAY_BTN_GHOST_HOVER } 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, + layout = clay_button_layout(), + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + }) { + clay_body_text(label, color = CLAY_TEXT_SECONDARY if !is_hov else CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) + } +} + +declare_button_small :: proc(id: string, label: string) { + is_hov := clay.Hovered() + bg := CLAY_BTN_DEFAULT + if is_hov { bg = CLAY_BTN_DEFAULT_HOVER } + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingFit({min = 36, max = 24}), height = clay.SizingFixed(f32(CLAY_HEIGHT_SM - 4))}, padding = {top = 2, right = 8, bottom = 2, left = 8}, childAlignment = {x = .Center, y = .Center}}, + backgroundColor = bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), }) { - clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = CLAY_TEXT_SECONDARY}) + clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = CLAY_TEXT_SECONDARY if !is_hov else CLAY_TEXT_PRIMARY}) } } declare_button_recommended :: proc(id: string, label: string) { + is_hov := clay.Hovered() + bg := CLAY_ACCENT + if is_hov { bg = CLAY_ACCENT_HOVER } if clay.UI(clay.ID(id))({ layout = clay_button_layout(), - backgroundColor = CLAY_ACCENT, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), border = {color = CLAY_ACCENT_GLOW, width = clay.BorderOutside(2)}, }) { - clay_body_text(label, color = CLAY_TEXT_BRIGHT) + clay_body_text(label, color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_SM) + } +} + +declare_button_disabled :: proc(id: string, label: string) { + if clay.UI(clay.ID(id))({ + layout = clay_button_layout(), + backgroundColor = CLAY_BTN_DISABLED, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + }) { + clay_body_text(label, color = CLAY_TEXT_DISABLED, size = CLAY_FONT_SIZE_SM) } } @@ -74,28 +106,74 @@ declare_status_badge :: proc(id: string, label: string, ok: bool) { badge_bg: clay.Color = CLAY_ERROR_DIM if ok { badge_bg = CLAY_SUCCESS_DIM } if clay.UI(clay.ID(id))({ - layout = {sizing = {width = clay.SizingFit({min = 70, max = 28}), height = clay.SizingFixed(26)}, padding = {top = 2, right = 8, bottom = 2, left = 8}, childAlignment = {x = .Center, y = .Center}}, + layout = {sizing = {width = clay.SizingFit({min = 60, max = 28}), 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), + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), border = {color = badge_color, width = clay.BorderOutside(1)}, }) { - clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = badge_color}) + clay.Text(label, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = badge_color}) } } - -// ─── Stat Chip (Clay) ────────────────────────────────────────────── -// ─── Stat Chip (Clay) ────────────────────────────────────────────── declare_stat_chip :: proc(id: string, label: string, value: int) { value_text := fmt.tprintf("%d", value) + is_hov := clay.Hovered() + bg := CLAY_BG_STRIP + if is_hov { bg = CLAY_BG_HOVER } if clay.UI(clay.ID(id))({ - layout = {sizing = {width = clay.SizingFixed(90), height = clay.SizingFixed(34)}, padding = {top = 4, right = 8, bottom = 4, left = 8}, childAlignment = {x = .Center, y = .Center}}, - backgroundColor = clay.Color{40, 40, 55, 255}, + layout = {sizing = {width = clay.SizingFixed(86), height = clay.SizingFixed(32)}, padding = {top = 4, right = 8, bottom = 4, left = 8}, childAlignment = {x = .Center, y = .Center}, childGap = 2, layoutDirection = .TopToBottom}, + backgroundColor = bg, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), - border = {color = clay.Color{55, 55, 75, 255}, width = clay.BorderOutside(1)}, + border = {color = CLAY_BORDER_SUBTLE, width = clay.BorderOutside(1)}, }) { - clay_muted_text(label) + clay_label_text(label) clay_body_text(value_text, color = CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) } } +declare_section_header :: proc(id: string, title: string, subtitle: string = "") { + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, layoutDirection = .TopToBottom, childGap = 2}, + }) { + clay_title_text(title, size = CLAY_FONT_SIZE_XL) + if len(subtitle) > 0 { + clay_muted_text(subtitle) + } + } +} + +declare_divider :: proc(id: string) { + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(1)}}, + backgroundColor = CLAY_BORDER_DIVIDER, + }) {} +} + +declare_empty_state :: proc(id: string, title: string, description: string, hint: string = "") { + if clay.UI(clay.ID(id))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, layoutDirection = .TopToBottom, padding = {top = CLAY_SPACE_32, right = CLAY_SPACE_16, bottom = CLAY_SPACE_32, left = CLAY_SPACE_16}, childAlignment = {x = .Center}, childGap = CLAY_SPACE_8}, + backgroundColor = CLAY_BG_CARD_ALT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), + border = {color = CLAY_BORDER_SUBTLE, width = clay.BorderOutside(1)}, + }) { + clay_title_text(title, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_LG) + clay_body_text(description, color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) + if len(hint) > 0 { + clay_body_text(hint, color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) + } + } +} + +declare_info_callout :: proc(id: string, message: string, ok: bool) { + badge_color: clay.Color = CLAY_ERROR + badge_bg: clay.Color = CLAY_ERROR_DIM + if ok { badge_color = CLAY_SUCCESS; badge_bg = CLAY_SUCCESS_DIM } + if clay.UI(clay.ID(id))({ + layout = {layoutDirection = .TopToBottom, padding = {top = 8, right = 12, bottom = 8, left = 12}, childGap = 4, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}}, + backgroundColor = badge_bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = badge_color, width = {0, 0, 0, 2, 0}}, + }) { + clay_body_text(message, color = badge_color, size = CLAY_FONT_SIZE_SM) + } +} diff --git a/odin/src/gui/runtime.odin b/odin/src/gui/runtime.odin index bbb30e2..a53c141 100644 --- a/odin/src/gui/runtime.odin +++ b/odin/src/gui/runtime.odin @@ -1,7 +1,11 @@ package gui +import "core:c" import clay "clay:." import "core:fmt" +import "core:math" +import "core:os" +import filepath "core:path/filepath" import "core:strings" import rl "vendor:raylib" import "../core" @@ -9,15 +13,22 @@ import "../shared" import "../ui" // GUI_App_State holds all mutable GUI-local state for the main loop. +FIELD_BUF_SIZE :: 1024 + +ANIM_DURATION_SIDEBAR :: 0.25 +ANIM_DURATION_OVERLAY :: 0.2 +ANIM_DURATION_SLIDE :: 0.3 + 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 + selected_field: int, export_path: string, project_path: string, local_script_pages: string, autosave_interval_text: string, export_format: core.Export_Format, use_fal_panels: bool, + sidebar_collapsed: bool, status_msg: string, is_dirty: bool, autosave_enabled: bool, @@ -33,20 +44,110 @@ GUI_App_State :: struct { show_confirm_overlay: bool, pending_confirm: Pending_Confirm_Action, panel_textures: map[string]rl.Texture2D, + wireframe_cell_rects: [16]rl.Rectangle, + wireframe_cell_count: int, + layout_selected_panel: int, bubble_edit_text: string, + editing_char_id: string, + char_edit_text: string, + editing_panel_id: string, + panel_edit_text: string, + field_buf: [FIELD_BUF_SIZE]u8, + // ─── Animation state ────────────────────────────────────────── + sidebar_anim: f32, // current animated sidebar width (px) + overlay_alpha: f32, // 0→1 fade progress for active overlay + prev_screen: ui.App_Screen, + slide_offset: f32, // horizontal offset for screen transitions + slide_progress: f32, // 0→1 progress of slide animation } clicked :: proc(id: clay.ElementId) -> bool { return clay.PointerOver(id) && rl.IsMouseButtonPressed(.LEFT) } +// ─── Persistent String Pool (survives temp-allocator resets) ──── +@(private) +persistent_pool: [256 * 1024]u8 +@(private) +persistent_offset: int + +@(private) +pool_clone :: proc(s: string) -> string { + if len(s) == 0 { return "" } + total := len(s) + 1 + if persistent_offset + total > len(persistent_pool) { + // Pool full — fall back to strings.clone (heap) instead of corrupting old data + return strings.clone(s) + } + start := persistent_offset + for c, i in s { + persistent_pool[start + i] = u8(c) + } + persistent_pool[start + len(s)] = 0 + persistent_offset += total + return string(persistent_pool[start:start+len(s)]) +} + // ─── Panel Image Loading ───────────────────────────────────────── @(private) -file_url_to_path :: proc(url: string) -> string { +download_path_cache: map[string]string +@(private) +download_failed_cache: map[string]bool + +@(private) +resolve_image_path :: proc(url: string) -> string { + if download_path_cache == nil { + download_path_cache = make(map[string]string, context.allocator) + } + if download_failed_cache == nil { + download_failed_cache = make(map[string]bool, context.allocator) + } + if strings.has_prefix(url, "file://") { return url[7:] } - return url + if !strings.has_prefix(url, "http://") && !strings.has_prefix(url, "https://") { + // Reject non-URL garbage (could be corrupted temp-allocator data) + if len(url) == 0 || url[0] < 32 || url[0] > 126 { return "" } + return url + } + + if cached_path, ok := download_path_cache[url]; ok { + return pool_clone(cached_path) + } + + if _, failed := download_failed_cache[url]; failed { + return "" + } + + base := url + if q := strings.index(base, "?"); q >= 0 { + base = base[:q] + } + filename := filepath.base(base) + if len(filename) == 0 { + filename = "cached_image.png" + } + + cache_dir := "./assets" + os.mkdir_all(cache_dir) + local_path := fmt.aprintf("%s/%s", cache_dir, filename) + + if os.exists(local_path) { + download_path_cache[url] = pool_clone(local_path) + return pool_clone(local_path) + } + + cmd := [6]string{"curl", "-L", "-sS", "-o", local_path, url} + desc := os.Process_Desc{command = cmd[:]} + state, _, _, exec_err := os.process_exec(desc, context.temp_allocator) + if exec_err != nil || !state.exited || state.exit_code != 0 { + download_failed_cache[url] = true + return "" + } + + download_path_cache[url] = pool_clone(local_path) + return pool_clone(local_path) } @(private) @@ -54,19 +155,25 @@ load_panel_texture :: proc(cache: ^map[string]rl.Texture2D, panel_id: string, ur if tex, ok := cache[panel_id]; ok { return tex, true } - filepath := file_url_to_path(url) + filepath := resolve_image_path(url) if len(filepath) == 0 { return {}, false } fpath_c := strings.clone_to_cstring(filepath) defer delete(fpath_c) - img := rl.LoadImage(fpath_c) - if img.data == nil { return {}, false } - defer rl.UnloadImage(img) - tex := rl.LoadTextureFromImage(img) + tex := rl.LoadTexture(fpath_c) + if tex.id == 0 { + // Fallback: try LoadImage + LoadTextureFromImage for broader format support + img := rl.LoadImage(fpath_c) + if img.data != nil { + defer rl.UnloadImage(img) + tex = rl.LoadTextureFromImage(img) + } + } if tex.id == 0 { return {}, false } + rl.SetTextureFilter(tex, .BILINEAR) cache[panel_id] = tex return tex, true -} +} @(private) unload_panel_textures :: proc(cache: ^map[string]rl.Texture2D) { for _, tex in cache { @@ -94,6 +201,9 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { app: GUI_App_State app.controller = controller + app.panel_textures = make(map[string]rl.Texture2D, 64) + reserve(&app.panel_textures, 256) + app.layout_selected_panel = -1 app.selected_field = 0 app.export_path = "./gui_export.pdf" app.project_path = "./gui_project.comic.json" @@ -108,7 +218,9 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { app.last_save_at = -1 app.last_export_at = -1 app.log_oldest_first = false - app.summary_opts = Summary_View_Options{} + app.summary_opts = Summary_View_Options{script_show_all = true, script_desc = true} + app.sidebar_anim = f32(shared.LAYOUT.sidebar_width) + app.prev_screen = app.controller.active_screen push_status(&app.status_msg, &app.action_log, app.status_msg) defer action_log_dispose(&app.action_log) defer delete(app.status_msg) @@ -117,7 +229,16 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { for !rl.WindowShouldClose() { screen_w := rl.GetScreenWidth() screen_h := rl.GetScreenHeight() + bp := shared.breakpoint(screen_w, screen_h) compact_mode := shared.is_compact(screen_h) + dt := rl.GetFrameTime() + + update_sidebar_anim(&app, bp, dt) + update_overlay_anim(&app, dt) + update_slide_anim(&app, dt) + + sidebar_w := sidebar_width(bp, app.sidebar_collapsed, app.sidebar_anim) + main_w := shared.compute_main_width(screen_w, sidebar_w) cfg := shared.load_config() has_deepseek_key := len(cfg.deepseek_api_key) > 0 has_fal_key := len(cfg.fal_api_key) > 0 @@ -131,7 +252,16 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { if rl.IsKeyPressed(.SLASH) { toggle_help_overlay(&app.show_help_overlay) } if rl.IsKeyPressed(.ESCAPE) { close_help_overlay_if_open(&app.show_help_overlay) } + // Compact mode forces the sidebar collapsed + if bp == .Compact { app.sidebar_collapsed = true } + if !interaction_locked { + if rl.IsKeyPressed(.B) && (rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)) { + app.sidebar_collapsed = !app.sidebar_collapsed + status := "Sidebar expanded" + if app.sidebar_collapsed { status = "Sidebar collapsed" } + push_status(&app.status_msg, &app.action_log, status) + } 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) } @@ -180,39 +310,8 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { } } - // ─── Text Input ──────────────────────────────────────────── - if !interaction_locked { - for { - ch := rl.GetCharPressed() - if ch == 0 { break } - if ch < 32 || ch > 126 { continue } - if app.selected_field == 6 && (ch < '0' || ch > '9') { continue } - switch app.selected_field { - case 0: append_char(&app.controller.state.story_idea, ch) - case 1: append_char(&app.controller.state.story_genre, ch) - case 2: append_char(&app.controller.state.target_audience, ch) - case 3: append_char(&app.export_path, ch) - case 4: append_char(&app.local_script_pages, ch) - case 5: append_char(&app.project_path, ch) - case 6: append_char(&app.autosave_interval_text, ch) - case 7: append_char(&app.bubble_edit_text, ch) - } - app.is_dirty = true - } - if rl.IsKeyPressed(.BACKSPACE) { - switch app.selected_field { - case 0: pop_char(&app.controller.state.story_idea) - case 1: pop_char(&app.controller.state.story_genre) - case 2: pop_char(&app.controller.state.target_audience) - case 3: pop_char(&app.export_path) - case 4: pop_char(&app.local_script_pages) - case 5: pop_char(&app.project_path) - case 6: pop_char(&app.autosave_interval_text) - case 7: pop_char(&app.bubble_edit_text) - } -app.is_dirty = true - } - } + // ─── Text Input (raygui GuiTextBox overlay) ──────────────── + // raygui GuiTextBox is called after Clay render below // ─── Keyboard Shortcuts ───────────────────────────────────── ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL) @@ -327,7 +426,8 @@ app.is_dirty = true 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 ────────────────────────────── - + fmt.eprintf("LAYOUT: screen=%dx%d sidebar_w=%.0f sidebar_anim=%.0f collapsed=%v bp=%v\n", + screen_w, screen_h, f32(sidebar_width(bp, app.sidebar_collapsed, 0)), app.sidebar_anim, app.sidebar_collapsed, bp) clay.BeginLayout() // Root: horizontal layout (sidebar + main) @@ -335,39 +435,54 @@ app.is_dirty = true layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .LeftToRight}, backgroundColor = CLAY_BG_BASE, }) { - // ─── Sidebar ─────────────────────────────────────── - declare_sidebar(&app) + // ─── Sidebar (responsive width) ─────────────────────── + if clay.UI(clay.ID("Sidebar"))({ + layout = {sizing = {width = clay.SizingFixed(f32(sidebar_w)), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 10, right = 10, bottom = 10, left = 10}, childGap = 4}, + backgroundColor = CLAY_BG_SIDEBAR, + }) { + declare_sidebar(&app, bp) + } // ─── Main Content ──────────────────────────────── if clay.UI(clay.ID("MainArea"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom}, }) { - declare_pipeline_bar(&app) - declare_workspace(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, pages_count) + declare_pipeline_bar(&app, bp) + declare_workspace(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, pages_count, bp, main_w) next_hint := gui_next_hint(app.controller) - declare_bottom_bar(&app, can_generate_panels, can_layout, can_export, next_hint, has_fal_key) + declare_bottom_bar(&app, can_generate_panels, can_layout, can_export, next_hint, has_fal_key, bp) } } - // ─── Floating Overlays (Clay) ────────────────────────────── - if app.show_help_overlay { - declare_help_overlay() + // ─── Floating Overlays (Clay, animated alpha) ───────────── + if app.show_help_overlay || app.overlay_alpha > 0.01 { + declare_help_overlay(bp, app.overlay_alpha) } - if app.show_confirm_overlay { - declare_confirm_overlay(app.pending_confirm) + if app.show_confirm_overlay || app.overlay_alpha > 0.01 { + declare_confirm_overlay(app.pending_confirm, bp, app.overlay_alpha) } 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, has_fal_key, pages_count, shift_down, autosave_secs, compact_mode) + process_clicks(&app, can_generate_panels, can_layout, can_export, project_path_ok, export_path_ok, has_deepseek_key, has_fal_key, pages_count, shift_down, autosave_secs, compact_mode, bp) // ─── Render ──────────────────────────────────────────────────── rl.BeginDrawing() - rl.ClearBackground(BG_BASE) + rl.EndScissorMode() + rl.ClearBackground(RL_BG_BASE) clay_raylib_render(&render_commands) + // ─── Layout wireframe overlay (raylib, after Clay) ────────────── + draw_layout_wireframe(&app) + + // ─── Raygui field overlay (draw after Clay, before end) ───────── + if !interaction_locked { + render_raygui_fields(&app) + rl.EndScissorMode() + } + rl.EndDrawing() } @@ -433,7 +548,81 @@ handle_action_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, ca 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_mode_casual")) { app.controller.state.user_mode = .Casual; push_status(&app.status_msg, &app.action_log, "Mode: Casual") } + if clicked(clay.ID("btn_mode_pro")) { app.controller.state.user_mode = .Professional; push_status(&app.status_msg, &app.action_log, "Mode: Professional") } + // Genre chips + genres := []string{"action", "comedy", "drama", "scifi", "fantasy", "horror", "romance", "mystery", "slice-of-life"} + for g in genres { + if clicked(clay.ID(fmt.tprintf("btn_genre_%s", g))) { + delete(app.controller.state.story_genre) + app.controller.state.story_genre = strings.clone(g) + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Genre: %s", g)) + app.is_dirty = true + } + } + // Art style chips + styles := []string{"manga", "western-comic", "pixel-art", "watercolor", "noir", "chibi", "sketch", "cyberpunk"} + for s in styles { + if clicked(clay.ID(fmt.tprintf("btn_style_%s", s))) { + delete(app.controller.state.art_style) + app.controller.state.art_style = strings.clone(s) + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Style: %s", s)) + app.is_dirty = true + } + } + // Generate All Refs button (Characters screen) + if clicked(clay.ID("btn_char_gen_all")) { + push_status(&app.status_msg, &app.action_log, action_generate_all_character_refs(&app.controller)) + app.is_dirty = true + } + // Generate All Panels button + if clicked(clay.ID("btn_panels_gen_all")) { + cfg := shared.load_config() + if len(cfg.fal_api_key) == 0 { + push_status(&app.status_msg, &app.action_log, "FAL key missing") + } else { + push_status(&app.status_msg, &app.action_log, run_panels_action(&app.controller, nil, &app.is_dirty)) + } + } + // Page size chips (Layout screen) + page_sizes := []core.Page_Size_Name{.A4, .Letter, .Manga, .Webtoon, .Square} + for sz, pi in page_sizes { + if clicked(clay.ID(fmt.tprintf("btn_pgsize_%d", pi))) { + app.controller.state.page_size = sz + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Page size: %v", sz)) + app.is_dirty = true + } + } + // Layout pattern cards + patterns := []string{"grid-2x2", "manga-3-tier", "action-dynamic", "splash-page", "webtoon-scroll", "western-3x3", "dialogue-heavy", "cinematic-widescreen"} + for pat in patterns { + if clicked(clay.ID(fmt.tprintf("btn_pattern_%s", pat))) { + if len(app.controller.state.panel_images) > 0 { + panels := collect_script_panels(app.controller.state.script) + core.dispose_page_layouts(&app.controller.state.page_layouts) + app.controller.state.page_layouts = core.auto_layout_pages(panels, app.controller.state.page_size, app.controller.state.story_genre, pat) + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Layout: %s", pat)) + app.is_dirty = true + } else { + push_status(&app.status_msg, &app.action_log, "Generate panels first") + } + } + } + // Audience chips + auds := []string{"general", "children", "teen", "mature"} + for a in auds { + if clicked(clay.ID(fmt.tprintf("btn_aud_%s", a))) { + delete(app.controller.state.target_audience) + app.controller.state.target_audience = strings.clone(a) + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Audience: %s", a)) + app.is_dirty = true + } + } if clicked(clay.ID("btn_help")) { toggle_help_overlay(&app.show_help_overlay) } + if clicked(clay.ID("btn_toggle_sidebar")) { + app.sidebar_collapsed = !app.sidebar_collapsed + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Sidebar: %s", "collapsed" if app.sidebar_collapsed else "expanded")) + } if clicked(clay.ID("btn_fal_panels")) { if !has_fal_key { push_status(&app.status_msg, &app.action_log, "FAL key missing (set FAL_API_KEY)") } else { app.use_fal_panels = !app.use_fal_panels; push_status(&app.status_msg, &app.action_log, fmt.tprintf("FAL panels: %s", "ON" if app.use_fal_panels else "OFF")) } @@ -453,6 +642,15 @@ handle_workspace_nav :: proc(app: ^GUI_App_State) { screen := app.controller.active_screen if screen == .Script { page_count := len(app.controller.state.script.pages) + // Collapsible toggles + if clicked(clay.ID("btn_toggle_chars")) { + app.summary_opts.script_show_all = !app.summary_opts.script_show_all + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Characters: %s", "shown" if app.summary_opts.script_show_all else "hidden")) + } + if clicked(clay.ID("btn_toggle_panels")) { + app.summary_opts.script_desc = !app.summary_opts.script_desc + push_status(&app.status_msg, &app.action_log, fmt.tprintf("Panels: %s", "shown" if app.summary_opts.script_desc else "hidden")) + } if clicked(clay.ID("btn_script_prev")) && page_count > 0 { app.summary_opts.script_page_cursor -= 1 if app.summary_opts.script_page_cursor < 0 { app.summary_opts.script_page_cursor = page_count - 1 } @@ -478,10 +676,12 @@ handle_workspace_nav :: proc(app: ^GUI_App_State) { if clicked(clay.ID("btn_layout_prev")) && layout_count > 0 { app.summary_opts.layout_page_cursor -= 1 if app.summary_opts.layout_page_cursor < 0 { app.summary_opts.layout_page_cursor = layout_count - 1 } + app.layout_selected_panel = -1 } if clicked(clay.ID("btn_layout_next")) && layout_count > 0 { app.summary_opts.layout_page_cursor += 1 if app.summary_opts.layout_page_cursor >= layout_count { app.summary_opts.layout_page_cursor = 0 } + app.layout_selected_panel = -1 } } if screen == .Bubbles { @@ -519,17 +719,81 @@ handle_workspace_nav :: proc(app: ^GUI_App_State) { handle_detail_clicks :: proc(app: ^GUI_App_State) { screen := app.controller.active_screen - if screen == .Panels { - if clicked(clay.ID("btn_panel_regenerate")) { - panel_count := count_script_panels(app.controller.state.script) - if panel_count > 0 { - idx := clamp_panel_cursor(panel_count, app.summary_opts.panel_cursor) - panel, _, ok := panel_by_flat_index(app.controller.state.script, idx) - if ok { push_status(&app.status_msg, &app.action_log, action_regenerate_panel(&app.controller, panel.panel_id)) } + if screen == .Characters { + char_count := len(app.controller.state.characters) + + // Parse Descriptions button + if clicked(clay.ID("btn_char_parse_all")) { + push_status(&app.status_msg, &app.action_log, action_parse_character_descriptions(&app.controller)) + app.is_dirty = true + } + + for i in 0.. 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 { + is_editing := app.editing_panel_id == panel.panel_id + + if is_editing { + if clicked(clay.ID("btn_panel_save")) { + push_status(&app.status_msg, &app.action_log, action_update_panel_prompt(&app.controller, panel.panel_id, strings.clone(app.panel_edit_text))) + app.editing_panel_id = "" + app.selected_field = 0 + app.is_dirty = true + } + } else { + if clicked(clay.ID("btn_panel_edit")) { + app.editing_panel_id = panel.panel_id + app.panel_edit_text = panel.description + app.selected_field = 9 + } + } + + if clicked(clay.ID("btn_panel_regenerate")) { + push_status(&app.status_msg, &app.action_log, action_regenerate_panel(&app.controller, panel.panel_id)) + } + + if clicked(clay.ID("field_panel_desc")) || clicked(clay.ID("field_panel_desc_missing")) || clicked(clay.ID("field_panel_desc_no_img")) { + if is_editing { + app.selected_field = 9 + } + } + } for i in 0.. 0 && rl.IsMouseButtonPressed(.LEFT) { + mouse := rl.GetMousePosition() + for i in 0..= r.x && mouse.x <= r.x + r.width && mouse.y >= r.y && mouse.y <= r.y + r.height { + if app.layout_selected_panel == i { + app.layout_selected_panel = -1 + } else { + app.layout_selected_panel = i + } + break + } + } + } } if screen == .Bubbles { @@ -634,12 +913,30 @@ handle_detail_clicks :: proc(app: ^GUI_App_State) { app.is_dirty = true } } + + b := app.controller.state.speech_bubbles[panel_id][app.summary_opts.bubble_edit_cursor] + if clicked(clay.ID("btn_bubble_left")) { + push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x - 0.05, b.position.y)) + app.is_dirty = true + } + if clicked(clay.ID("btn_bubble_right")) { + push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x + 0.05, b.position.y)) + app.is_dirty = true + } + if clicked(clay.ID("btn_bubble_up")) { + push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x, b.position.y - 0.05)) + app.is_dirty = true + } + if clicked(clay.ID("btn_bubble_down")) { + push_status(&app.status_msg, &app.action_log, action_reposition_bubble(&app.controller, b.id, b.position.x, b.position.y + 0.05)) + app.is_dirty = true + } } } } } } -process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek, has_fal_key: bool, pages_count: int, shift_down: bool, autosave_secs: int, compact_mode: bool) { +process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek, has_fal_key: bool, pages_count: int, shift_down: bool, autosave_secs: int, compact_mode: bool, bp: shared.Breakpoint) { handle_nav_clicks(app) handle_field_clicks(app) handle_format_clicks(app, has_deepseek) @@ -669,83 +966,86 @@ process_clicks :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_expo } } } -// ─── Help Overlay (Clay floating) ────────────────────────────────── -declare_help_overlay :: proc() { - // Backdrop +// ─── Help Overlay (Clay floating, animated alpha) ────────────────── +declare_help_overlay :: proc(bp: shared.Breakpoint, alpha: f32 = 1) { if clay.UI(clay.ID("HelpBackdrop"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, - backgroundColor = {0, 0, 0, 180}, + backgroundColor = {0, 0, 0, f32(200 * alpha)}, + floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 5}, }) {} - // Card + card_w_pct: f32 = 0.7 + card_h_pct: f32 = 0.8 + if bp == .Compact { card_w_pct = 0.92; card_h_pct = 0.92 } 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}, + layout = {sizing = {width = clay.SizingPercent(card_w_pct), height = clay.SizingPercent(card_h_pct)}, layoutDirection = .TopToBottom, padding = {top = 24, right = 28, bottom = 24, left = 28}, childGap = CLAY_SPACE_12}, backgroundColor = CLAY_BG_CARD, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), - border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(2)}, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XL), + border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(1)}, 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) + if clay.UI(clay.ID("HelpHeader"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}, + }) { + clay_title_text("Keyboard Shortcuts", color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_XL) + clay.UI(clay.ID("HelpHeaderSpacer"))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) + clay_body_text("Esc or /", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) + } - 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") + declare_divider("HelpDiv1") - 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_label_text("NAVIGATION") + clay_body_text("1..8 screens | TAB fields | click to focus | F11 pages | F12 project", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - 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_label_text("CORE ACTIONS") + clay_body_text("F5 script F6 panels F7 layout F8 export F9 next F10 auto-all", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + clay_body_text("Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - 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_label_text("CLIPBOARD + LOGS") + clay_body_text("Ctrl+L clear log | Ctrl+V paste | Ctrl+0 reset helpers | Ctrl+Backspace clear", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - 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_label_text("PATHS") + clay_body_text("Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - 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_label_text("AUTOSAVE") + clay_body_text("Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - clay_body_text("Close help: Esc or /", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_MD) + clay_label_text("SAFETY") + clay_body_text("Dirty guard: Shift-click New/Open | Ctrl+Shift+N / Ctrl+Shift+O", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) } } -// ─── Confirm Overlay (Clay floating) ──────────────────────────────── -declare_confirm_overlay :: proc(action: Pending_Confirm_Action) { - // Backdrop +// ─── Confirm Overlay (Clay floating, animated alpha) ──────────────── +declare_confirm_overlay :: proc(action: Pending_Confirm_Action, bp: shared.Breakpoint, alpha: f32 = 1) { if clay.UI(clay.ID("ConfirmBackdrop"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, - backgroundColor = {0, 0, 0, 180}, + backgroundColor = {0, 0, 0, f32(200 * alpha)}, + floating = {offset = {0, 0}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 15}, }) {} - // 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}, + layout = {sizing = {width = clay.SizingPercent(0.6), height = clay.SizingFit({min = 160, max = 380})}, layoutDirection = .TopToBottom, padding = {top = 24, right = 28, bottom = 24, left = 28}, childGap = CLAY_SPACE_12}, backgroundColor = CLAY_BG_CARD, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_LG), - border = {color = CLAY_BORDER_CARD, width = clay.BorderOutside(2)}, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XL), + border = {color = CLAY_ERROR_DIM, 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), + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(3)}}, + backgroundColor = CLAY_ERROR, + cornerRadius = clay.CornerRadiusAll(2), }) {} - clay_title_text("Confirm destructive action", color = CLAY_ERROR, size = CLAY_FONT_SIZE_XL) + clay_title_text("Confirm 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) + clay_body_text(fmt.tprintf("You have unsaved changes. Do you want to %s?", action_label), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) 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") + clay_body_text("Enter/Y confirm | Esc/N cancel", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } } @@ -763,13 +1063,130 @@ declare_toast :: proc(log: ^Action_Log) { 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}}, + layout = {sizing = {width = clay.SizingFit({min = 280, max = 0}), height = clay.SizingFixed(f32(CLAY_HEIGHT_SM))}, padding = {top = 4, right = 12, bottom = 4, 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}, + floating = {offset = {f32(228), 70}, attachTo = .Root, attachment = {element = .LeftTop, parent = .LeftTop}, zIndex = 30}, }) { - clay_body_text(msg, color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_SM) + clay_body_text(msg, color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_XS) + } +} + +// ─── Raygui Field Overlay ────────────────────────────────────────── +field_id_for_index :: proc(idx: int) -> string { + switch idx { + case 0: return "field_idea" + case 1: return "field_genre" + case 2: return "field_audience" + case 3: return "field_export" + case 4: return "field_pages" + case 5: return "field_project" + case 6: return "field_autosave" + case 7: return "field_bubble_text" + case 8: return "field_char_desc" + case 9: return "field_panel_prompt" + } + return "" +} + +field_ptr_for_index :: proc(app: ^GUI_App_State, idx: int) -> ^string { + switch idx { + case 0: return &app.controller.state.story_idea + case 1: return &app.controller.state.story_genre + case 2: return &app.controller.state.target_audience + case 3: return &app.export_path + case 4: return &app.local_script_pages + case 5: return &app.project_path + case 6: return &app.autosave_interval_text + case 7: return &app.bubble_edit_text + case 8: return &app.char_edit_text + case 9: return &app.panel_edit_text + } + return nil +} + +render_raygui_fields :: proc(app: ^GUI_App_State) { + sf := app.selected_field + fid := field_id_for_index(sf) + if len(fid) == 0 { return } + + eid := clay.GetElementId(clay.MakeString(fid)) + data := clay.GetElementData(eid) + if !data.found { return } + + bounds := rl.Rectangle { + x = f32(data.boundingBox.x), + y = f32(data.boundingBox.y), + width = f32(data.boundingBox.width), + height = f32(data.boundingBox.height), + } + + fp := field_ptr_for_index(app, sf) + if fp == nil { return } + + // Copy field string → buffer for GuiTextBox + src := fp^ + if len(src) >= FIELD_BUF_SIZE { src = src[:FIELD_BUF_SIZE-1] } + for i in 0 ..< len(src) { + app.field_buf[i] = src[i] + } + app.field_buf[len(src)] = 0 + + // Call raygui GuiTextBox — handles rendering + input, modifies buffer in-place + rl.GuiTextBox(bounds, cstring(&app.field_buf[0]), c.int(FIELD_BUF_SIZE), true) + + // Sync buffer → field string (pick up GuiTextBox edits) + new_len := 0 + for app.field_buf[new_len] != 0 && new_len < FIELD_BUF_SIZE-1 { + new_len += 1 + } + if new_len != len(src) { + fp^ = string(app.field_buf[:new_len]) + app.is_dirty = true + } +} + +// ─── Animation Updates ───────────────────────────────────────────── +lerp_f32 :: proc(a, b, t: f32) -> f32 { + return a + (b - a) * t +} + +smooth_towards :: proc(current, target, dt, duration: f32) -> f32 { + if abs(current - target) < 0.5 { return target } + k := 1 - math.pow_f32(0.01, dt / duration) + return lerp_f32(current, target, k) +} + +update_sidebar_anim :: proc(app: ^GUI_App_State, bp: shared.Breakpoint, dt: f32) { + target_w := f32(sidebar_width(bp, app.sidebar_collapsed, 0)) + app.sidebar_anim = smooth_towards(app.sidebar_anim, target_w, dt, ANIM_DURATION_SIDEBAR) +} + +update_overlay_anim :: proc(app: ^GUI_App_State, dt: f32) { + target_alpha: f32 = 0 + if app.show_help_overlay || app.show_confirm_overlay { + target_alpha = 1 + } + app.overlay_alpha = smooth_towards(app.overlay_alpha, target_alpha, dt, ANIM_DURATION_OVERLAY) +} + +update_slide_anim :: proc(app: ^GUI_App_State, dt: f32) { + current := app.controller.active_screen + if current != app.prev_screen && app.slide_progress >= 1 { + app.slide_progress = 0 + } + if app.slide_progress < 1 { + app.slide_progress += dt / ANIM_DURATION_SLIDE + if app.slide_progress > 1 { + app.slide_progress = 1 + app.slide_offset = 0 + app.prev_screen = current + } else { + // Ease-out cubic: offset goes from 60 → 0 + t := app.slide_progress + eased := 1 - (1 - t) * (1 - t) * (1 - t) + app.slide_offset = 60 * (1 - eased) + } } } diff --git a/odin/src/gui/theme.odin b/odin/src/gui/theme.odin deleted file mode 100644 index 25955eb..0000000 --- a/odin/src/gui/theme.odin +++ /dev/null @@ -1,5 +0,0 @@ -package gui - -import rl "vendor:raylib" - -BG_BASE :: rl.Color{13, 13, 18, 255} \ No newline at end of file diff --git a/odin/src/gui/types.odin b/odin/src/gui/types.odin index a73984f..c9fc748 100644 --- a/odin/src/gui/types.odin +++ b/odin/src/gui/types.odin @@ -1,5 +1,17 @@ package gui +import "../shared" + +// sidebar_width returns the effective sidebar width, using animated value when available. +sidebar_width :: proc(bp: shared.Breakpoint, collapsed: bool, anim: f32 = 0) -> i32 { + if collapsed { + if anim > 0 { return i32(anim) } + return shared.LAYOUT.sidebar_collapsed + } + if anim > 0 { return i32(anim) } + return shared.sidebar_width_for_breakpoint(bp) +} + Summary_View_Options :: struct { script_show_all: bool, script_desc: bool, diff --git a/odin/src/gui/workspaces.odin b/odin/src/gui/workspaces.odin index 06b9e20..cd7e740 100644 --- a/odin/src/gui/workspaces.odin +++ b/odin/src/gui/workspaces.odin @@ -3,29 +3,25 @@ package gui import clay "clay:." import "core:fmt" import "../core" - -// ─── Shared Helpers ───────────────────────────────────────────── +import "../shared" workspace_nav :: proc(id_prefix, pos_label: string) { if clay.UI(clay.ID(fmt.tprintf("%sNav", id_prefix)))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(36)}, layoutDirection = .LeftToRight, childGap = 8, childAlignment = {y = .Center}}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, layoutDirection = .LeftToRight, childGap = 8, childAlignment = {y = .Center}}, }) { - clay_body_text(pos_label, color = CLAY_ACCENT) + clay_body_text(pos_label, color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + clay.UI(clay.ID(fmt.tprintf("%sNavSpacer", id_prefix)))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) declare_button_small(fmt.tprintf("btn_%s_prev", id_prefix), "< Prev") declare_button_small(fmt.tprintf("btn_%s_next", id_prefix), "Next >") } } -// ─── Script Workspace ──────────────────────────────────────────── declare_script_workspace :: proc(app: ^GUI_App_State) { page_count := len(app.controller.state.script.pages) if page_count == 0 { - if clay.UI(clay.ID("ScriptEmpty"))(clay_card_style()) { - clay_title_text("Script") - clay_body_text("No script pages yet.", color = CLAY_TEXT_TERTIARY) - clay_body_text("1. Go to Story screen to set up your story idea", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - clay_body_text("2. Click 'Generate Script' or press F5", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - } + declare_empty_state("ScriptEmpty", "No Script Yet", + "Go to Story screen to set up your story idea", + "Generate Script (F5) to create pages") return } @@ -39,49 +35,184 @@ declare_script_workspace :: proc(app: ^GUI_App_State) { declare_script_detail(app) } -// ─── Panels Workspace ──────────────────────────────────────────── declare_panels_workspace :: proc(app: ^GUI_App_State) { panel_count := count_script_panels(app.controller.state.script) if panel_count == 0 { - if clay.UI(clay.ID("PanelsEmpty"))(clay_card_style()) { - clay_title_text("Panels") - clay_body_text("No panels generated yet.", color = CLAY_TEXT_TERTIARY) - clay_body_text("Generate a script first, then click 'Generate Panels' or press F6", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - } + declare_empty_state("PanelsEmpty", "No Panels Yet", + "Generate a script first, then create panel images", + "Generate Panels (F6) after having a script") return } - idx := clamp_panel_cursor(panel_count, app.summary_opts.panel_cursor) - workspace_nav("panels", fmt.tprintf("Panel %d/%d", idx+1, panel_count)) - declare_panels_detail(app) + generated_count := 0 + for _, _ in app.controller.state.panel_images { generated_count += 1 } + + if clay.UI(clay.ID("PanelsHeader"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 8, childAlignment = {y = .Center}}, + }) { + clay_body_text(fmt.tprintf("%d of %d panels generated", generated_count, panel_count), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + clay.UI(clay.ID("PanelsHeaderSpacer"))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) + declare_button_primary("btn_panels_gen_all", "Generate All") + } + + idx_cur := clamp_panel_cursor(panel_count, app.summary_opts.panel_cursor) + if clay.UI(clay.ID("PanelGrid"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, childGap = CLAY_SPACE_12}, + }) { + if clay.UI(clay.ID("PanelGridLeft"))({ + layout = {sizing = {width = clay.SizingPercent(0.55), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 4}, + }) { + workspace_nav("panels", fmt.tprintf("Panel %d/%d", idx_cur+1, panel_count)) + panel, page_num, rok := panel_by_flat_index(app.controller.state.script, idx_cur) + if rok { + declare_panel_card(app, panel, page_num, idx_cur, panel_count) + } + } + + if clay.UI(clay.ID("PanelGridRight"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, + clip = {vertical = true, childOffset = clay.GetScrollOffset()}, + }) { + clay_label_text("ALL PANELS") + for ri := 0; ri < panel_count; ri += 1 { + row_panel, row_page, row_ok := panel_by_flat_index(app.controller.state.script, ri) + if !row_ok { continue } + is_cur := ri == idx_cur + is_hov := clay.Hovered() + row_color := CLAY_TEXT_TERTIARY + if is_cur { row_color = CLAY_TEXT_BRIGHT } + else if is_hov { row_color = CLAY_TEXT_PRIMARY } + ready_mark := "✗" + ready_color := CLAY_TEXT_TERTIARY + if _, ex := app.controller.state.panel_images[row_panel.panel_id]; ex { ready_mark = "✓"; ready_color = CLAY_SUCCESS } + if _, er := app.controller.state.panel_errors[row_panel.panel_id]; er { ready_mark = "!"; ready_color = CLAY_ERROR } + + row_bg := clay.Color{0, 0, 0, 0} + if is_cur { row_bg = CLAY_BG_SELECTED } + else if is_hov { row_bg = CLAY_BG_HOVER } + + if clay.UI(clay.ID(fmt.tprintf("panel_row_%d", ri)))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(24)}, padding = {top = 2, right = 6, bottom = 2, left = 6}, childGap = 6, childAlignment = {y = .Center}}, + backgroundColor = row_bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XS), + }) { + clay.Text(ready_mark, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = ready_color}) + clay.Text(fmt.tprintf("%02d p%d#%d", ri+1, row_page, row_panel.panel_number), {fontId = CLAY_FONT_MONO, fontSize = CLAY_FONT_SIZE_XS, textColor = row_color}) + } + } + } + } +} + +declare_panel_card :: proc(app: ^GUI_App_State, panel: core.Panel, page_num, panel_idx, total: int) { + if clay.UI(clay.ID(fmt.tprintf("panel_card_%s", panel.panel_id)))(clay_card_style()) { + if clay.UI(clay.ID(fmt.tprintf("panel_card_top_%s", panel.panel_id)))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}, + }) { + clay_body_text(fmt.tprintf("Panel %d/%d", panel_idx+1, total), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + clay_muted_text(fmt.tprintf("Page %d", page_num)) + } + + status := "missing" + status_color := CLAY_WARNING + if _, has := app.controller.state.panel_images[panel.panel_id]; has { status = "ready"; status_color = CLAY_SUCCESS } + if _, er := app.controller.state.panel_errors[panel.panel_id]; er { status = "error"; status_color = CLAY_ERROR } + if clay.UI(clay.ID(fmt.tprintf("panel_card_badges_%s", panel.panel_id)))({layout = clay_row_layout()}) { + declare_status_badge(fmt.tprintf("panel_status_%s", panel.panel_id), status, status == "ready") + declare_status_badge(fmt.tprintf("panel_shot_%s", panel.panel_id), shot_type_label(panel.shot_type), true) + } + + desc := panel.description + if len(desc) == 0 { desc = "(no description)" } + clay_body_text(desc, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + + if len(panel.characters_present) > 0 { + if clay.UI(clay.ID(fmt.tprintf("panel_card_chars_%s", panel.panel_id)))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}}) { + for cid in panel.characters_present { + declare_nav_chip(fmt.tprintf("char_tag_%s", cid), cid, false) + } + } + } + + if clay.UI(clay.ID(fmt.tprintf("panel_card_actions_%s", panel.panel_id)))({layout = clay_row_layout()}) { + if _, has := app.controller.state.panel_images[panel.panel_id]; has { + declare_button_small("btn_panel_regenerate", "Regen") + } else { + declare_button_small("btn_panel_regenerate", "Generate") + } + if app.editing_panel_id == panel.panel_id { + declare_button_small("btn_panel_save", "Save Prompt") + } else { + declare_button_small("btn_panel_edit", "Edit Prompt") + } + } + + if panel_img, has_img := app.controller.state.panel_images[panel.panel_id]; has_img { + img_url := pool_clone(panel_img.url) + _, loaded := load_panel_texture(&app.panel_textures, panel.panel_id, img_url) + if loaded { + tex_ptr := &app.panel_textures[panel.panel_id] + if tex_ptr.id != 0 { + if clay.UI(clay.ID(fmt.tprintf("panel_card_img_%s", panel.panel_id)))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(160)}}, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + image = {imageData = rawptr(tex_ptr)}, + }) {} + } + } + } + } } -// ─── Layout Workspace ──────────────────────────────────────────── declare_layout_workspace :: proc(app: ^GUI_App_State) { layout_count := len(app.controller.state.page_layouts) if layout_count == 0 { if clay.UI(clay.ID("LayoutEmpty"))(clay_card_style()) { - clay_title_text("Layout") - clay_body_text("No layouts yet.", color = CLAY_TEXT_TERTIARY) - clay_body_text("Generate panels first, then click 'Layout Pages' or press F7", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - } - return - } + declare_section_header("LayoutHeader", "Layout", "Arrange panels into comic pages") - idx := clamp_layout_cursor(layout_count, app.summary_opts.layout_page_cursor) - workspace_nav("layout", fmt.tprintf("Page %d/%d", idx+1, layout_count)) - declare_layout_detail(app) + clay_label_text("PAGE SIZE") + if clay.UI(clay.ID("PageSizeChips"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}}) { + sizes := []core.Page_Size_Name{.A4, .Letter, .Manga, .Webtoon, .Square} + names := []string{"A4", "Letter", "Manga", "Webtoon", "Square"} + for s, i in sizes { + is_sel := app.controller.state.page_size == s + declare_nav_chip(fmt.tprintf("btn_pgsize_%d", i), names[i], is_sel) + } + } + + clay_label_text("LAYOUT PATTERN") + if clay.UI(clay.ID("PatternCards"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 6}}) { + patterns := []string{"grid-2x2", "manga-3-tier", "action-dynamic", "splash-page", "webtoon-scroll", "western-3x3", "dialogue-heavy", "cinematic-widescreen"} + pnames := []string{"Grid", "Manga", "Action", "Splash", "Webtoon", "Western", "Dialogue", "Cinematic"} + for p, j in patterns { + is_hov := clay.Hovered() + bg := CLAY_BG_STRIP + if is_hov { bg = CLAY_BG_HOVER } + if clay.UI(clay.ID(fmt.tprintf("btn_pattern_%s", p)))({ + layout = {layoutDirection = .TopToBottom, sizing = {width = clay.SizingFixed(88), height = clay.SizingFixed(52)}, padding = {top = 6, right = 6, bottom = 6, left = 6}, childAlignment = {x = .Center}, childGap = 2}, + backgroundColor = bg, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = CLAY_BORDER_SUBTLE, width = clay.BorderOutside(1)}, + }) { + clay_body_text(pnames[j], color = CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) + clay.Text(fmt.tprintf("%d max", core.pattern_max_panels(p)), {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = CLAY_TEXT_TERTIARY}) + } + } + } + } + } else { + idx := clamp_layout_cursor(layout_count, app.summary_opts.layout_page_cursor) + workspace_nav("layout", fmt.tprintf("Page %d/%d", idx+1, layout_count)) + declare_layout_detail(app) + } } -// ─── Bubbles Workspace ─────────────────────────────────────────── declare_bubbles_workspace :: proc(app: ^GUI_App_State) { layout_count := len(app.controller.state.page_layouts) if layout_count == 0 { - if clay.UI(clay.ID("BubblesEmpty"))(clay_card_style()) { - clay_title_text("Bubbles") - clay_body_text("No layouts yet.", color = CLAY_TEXT_TERTIARY) - clay_body_text("Run Layout Auto first, then use this screen to edit speech bubbles", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - } + declare_empty_state("BubblesEmpty", "No Bubbles Yet", + "Run Layout Auto first to create page layouts", + "Then use this screen to edit speech bubbles") return } @@ -89,11 +220,12 @@ declare_bubbles_workspace :: proc(app: ^GUI_App_State) { layout_val := app.controller.state.page_layouts[page_idx] panel_count := len(layout_val.panels) - // Bubbles needs dual nav (page + panel) if clay.UI(clay.ID("BubblesNav"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(36)}, layoutDirection = .LeftToRight, childGap = 8, childAlignment = {y = .Center}}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, layoutDirection = .LeftToRight, childGap = 6, childAlignment = {y = .Center}}, }) { - clay_body_text(fmt.tprintf("Page %d/%d • %d panels", page_idx+1, layout_count, panel_count), color = CLAY_ACCENT) + clay_body_text(fmt.tprintf("Page %d/%d", page_idx+1, layout_count), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM) + clay_muted_text(fmt.tprintf("%d panels", panel_count)) + clay.UI(clay.ID("BubblesNavSpacer"))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) declare_button_small("btn_bubbles_prev_page", "< Page") declare_button_small("btn_bubbles_next_page", "Page >") declare_button_small("btn_bubbles_prev_panel", "< Panel") @@ -103,89 +235,108 @@ declare_bubbles_workspace :: proc(app: ^GUI_App_State) { declare_bubbles_detail(app) } -// ─── Story Workspace ────────────────────────────────────────────── - -// ─── Story Workspace ────────────────────────────────────────────── -declare_story_workspace :: proc(app: ^GUI_App_State) { +declare_story_workspace :: proc(app: ^GUI_App_State, bp: shared.Breakpoint) { if clay.UI(clay.ID("StoryCard"))(clay_card_style()) { - clay_title_text("Story Setup") + declare_section_header("StoryHeader", "Story Setup", "Define your comic's premise") - clay_muted_text("Story Idea") + clay_label_text("STORY IDEA") if clay.UI(clay.ID("field_idea"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 80, max = 0})}, padding = {top = 8, right = 12, bottom = 8, left = 12}}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 80, max = 0})}, padding = {top = 10, right = 12, bottom = 10, left = 12}}, backgroundColor = CLAY_BG_INPUT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = CLAY_INPUT_BORDER if app.selected_field != 0 else CLAY_BORDER_FOCUS, width = clay.BorderOutside(1)}, }) { - clay_body_text(app.controller.state.story_idea if len(app.controller.state.story_idea) > 0 else "Enter story idea...") + placeholder := len(app.controller.state.story_idea) == 0 + clay_body_text( + app.controller.state.story_idea if !placeholder else "Enter your story idea...", + color = CLAY_TEXT_TERTIARY if placeholder else CLAY_TEXT_PRIMARY, + size = CLAY_FONT_SIZE_SM, + ) } - if clay.UI(clay.ID("StoryMetaRow"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 12}}) { - if clay.UI(clay.ID("StoryMetaLeft"))({layout = {layoutDirection = .TopToBottom, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 2}}) { - 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...") - } + clay_label_text("GENRE") + if clay.UI(clay.ID("GenreChips"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}, + clip = {horizontal = true, childOffset = clay.GetScrollOffset()}, + }) { + genres := []string{"action", "comedy", "drama", "scifi", "fantasy", "horror", "romance", "mystery", "slice-of-life"} + for g in genres { + is_sel := app.controller.state.story_genre == g + declare_nav_chip(fmt.tprintf("btn_genre_%s", g), g, is_sel) } - if clay.UI(clay.ID("StoryMetaRight"))({layout = {layoutDirection = .TopToBottom, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 2}}) { - 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...") - } + } + + clay_label_text("ART STYLE") + if clay.UI(clay.ID("ArtStyleChips"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}, + clip = {horizontal = true, childOffset = clay.GetScrollOffset()}, + }) { + styles := []string{"manga", "western-comic", "pixel-art", "watercolor", "noir", "chibi", "sketch", "cyberpunk"} + for s in styles { + is_sel := app.controller.state.art_style == s + declare_nav_chip(fmt.tprintf("btn_style_%s", s), s, is_sel) + } + } + + clay_label_text("AUDIENCE") + if clay.UI(clay.ID("AudienceChips"))({ + layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4}, + clip = {horizontal = true, childOffset = clay.GetScrollOffset()}, + }) { + auds := []string{"general", "children", "teen", "mature"} + for a in auds { + is_sel := app.controller.state.target_audience == a + declare_nav_chip(fmt.tprintf("btn_aud_%s", a), a, is_sel) } } } if clay.UI(clay.ID("PathCard"))(clay_card_style()) { - clay_title_text("Project Paths") + declare_section_header("PathHeader", "Project Paths", "Configure output locations") - // Export Path with Browse button - clay_muted_text("Export Path") + clay_label_text("EXPORT PATH") if clay.UI(clay.ID("ExportPathRow"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4, childAlignment = {y = .Center}}}) { if clay.UI(clay.ID("field_export"))({ layout = clay_input_layout(), backgroundColor = CLAY_BG_INPUT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = CLAY_INPUT_BORDER if app.selected_field != 3 else CLAY_BORDER_FOCUS, width = clay.BorderOutside(1)}, }) { display := app.export_path if len(display) == 0 { display = "(not set)" } - clay_body_text(display) + clay_body_text(display, color = CLAY_TEXT_SECONDARY if len(app.export_path) > 0 else CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) } declare_button_small("btn_browse_export", "Browse") } - // Project Path with Browse button - clay_muted_text("Project Path") + clay_label_text("PROJECT PATH") if clay.UI(clay.ID("ProjectPathRow"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 4, childAlignment = {y = .Center}}}) { if clay.UI(clay.ID("field_project"))({ layout = clay_input_layout(), backgroundColor = CLAY_BG_INPUT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = CLAY_INPUT_BORDER if app.selected_field != 5 else CLAY_BORDER_FOCUS, width = clay.BorderOutside(1)}, }) { display := app.project_path if len(display) == 0 { display = "(not set)" } - clay_body_text(display) + clay_body_text(display, color = CLAY_TEXT_SECONDARY if len(app.project_path) > 0 else CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) } declare_button_small("btn_browse_project", "Browse") } + declare_divider("PathDivider") + if clay.UI(clay.ID("StoryConfigRow"))({layout = clay_row_layout()}) { - clay_muted_text("Pages") + clay_label_text("PAGES") if clay.UI(clay.ID("field_pages"))({ - layout = {sizing = {width = clay.SizingFixed(60), height = clay.SizingFixed(34)}}, + layout = {sizing = {width = clay.SizingFixed(60), height = clay.SizingFixed(f32(CLAY_HEIGHT_SM))}}, backgroundColor = CLAY_BG_INPUT, cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = CLAY_INPUT_BORDER if app.selected_field != 4 else CLAY_BORDER_FOCUS, width = clay.BorderOutside(1)}, }) { - clay_body_text(app.local_script_pages) + clay_body_text(app.local_script_pages, size = CLAY_FONT_SIZE_SM) } - clay_muted_text("Format") + clay_label_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) @@ -193,31 +344,87 @@ declare_story_workspace :: proc(app: ^GUI_App_State) { } } -// ─── Characters Workspace ───────────────────────────────────────── - -// ─── Characters Workspace ───────────────────────────────────────── declare_characters_workspace :: proc(app: ^GUI_App_State) { + char_count := len(app.controller.state.characters) + script_char_count := len(app.controller.state.script.characters) + if clay.UI(clay.ID("CharsCard"))(clay_card_style()) { - clay_title_text("Characters") - char_count := len(app.controller.state.characters) - script_char_count := len(app.controller.state.script.characters) - // Show both state.characters and script.characters for debugging + declare_section_header("CharsHeader", "Characters", fmt.tprintf("%d characters defined", char_count)) + if char_count == 0 { - clay_body_text(fmt.tprintf("State chars: %d, Script chars: %d", char_count, script_char_count), color = CLAY_TEXT_PRIMARY) - clay_body_text("No characters yet.", color = CLAY_TEXT_TERTIARY) - clay_body_text("Characters are extracted when you generate a script.", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) - clay_body_text("Generate Script (F5) to populate characters from your story.", color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + clay_body_text("No characters yet.", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) + clay_body_text("Characters are extracted when you generate a script.", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } else { - clay_body_text(fmt.tprintf("%d characters found", char_count), color = CLAY_ACCENT) + with_refs := 0 + with_templates := 0 + for c in app.controller.state.characters { + if len(c.reference_image_url) > 0 { with_refs += 1 } + if len(c.prompt_template.age) > 0 || len(c.prompt_template.gender) > 0 { with_templates += 1 } + } + + if clay.UI(clay.ID("CharStats"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 12}}) { + declare_stat_chip("stat_chars", "Total", char_count) + declare_stat_chip("stat_refs", "Refs", with_refs) + declare_stat_chip("stat_tmpl", "Parsed", with_templates) + } + + if clay.UI(clay.ID("CharActionsRow"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 8}}) { + declare_button_small("btn_char_parse_all", "Parse Descriptions") + declare_button_primary("btn_char_gen_all", "Generate All Refs") + } + } + + if clay.UI(clay.ID("CharsScroll"))({ + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 6}, + clip = {vertical = true, childOffset = clay.GetScrollOffset()}, + }) { for i in 0.. 0 { - clay_body_text(char.description, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + if clay.UI(clay.ID(fmt.tprintf("char_head_%s", char.id)))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 8}}) { + clay_body_text(char.name, color = CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) + declare_nav_chip(fmt.tprintf("char_role_%s", char.id), role_label, false) + clay.UI(clay.ID(fmt.tprintf("char_head_spacer_%s", char.id)))({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) + if is_editing { + declare_button_small(fmt.tprintf("btn_char_save_%s", char.id), "Save") + } else { + declare_button_small(fmt.tprintf("btn_char_edit_%s", char.id), "Edit") + } + } + + if is_editing { + if clay.UI(clay.ID("field_char_desc"))({ + layout = clay_input_layout(), + backgroundColor = CLAY_BG_INPUT, + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), + border = {color = CLAY_BORDER_FOCUS, width = clay.BorderOutside(1)}, + }) { + clay_body_text(app.char_edit_text if len(app.char_edit_text) > 0 else "Enter description...", color = CLAY_TEXT_PRIMARY if len(app.char_edit_text) > 0 else CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) + } + } else { + if len(char.description) > 0 { + clay_body_text(char.description, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) + } else { + clay_body_text("No description", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) + } + } + + if clay.UI(clay.ID(fmt.tprintf("char_actions_%s", char.id)))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 8}}) { + declare_button_small(fmt.tprintf("btn_char_ref_%s", char.id), "Generate Ref") + declare_button_small(fmt.tprintf("btn_char_sheet_%s", char.id), "Generate Sheet") } } } @@ -225,8 +432,6 @@ declare_characters_workspace :: proc(app: ^GUI_App_State) { } } - -// ─── Export Workspace ───────────────────────────────────────────── character_role_label :: proc(role: core.Character_Role) -> string { switch role { case .Protagonist: return "Protagon." @@ -237,10 +442,22 @@ character_role_label :: proc(role: core.Character_Role) -> string { return "Unknown" } -// ─── Export Workspace ───────────────────────────────────────────── +shot_type_label :: proc(st: core.Shot_Type) -> string { + switch st { + case .Establishing: return "Establishing" + case .Wide: return "Wide" + case .Medium: return "Medium" + case .Close_Up: return "Close Up" + case .Extreme_Close_Up: return "Extreme CU" + case .Over_Shoulder: return "O/S" + case .Aerial: return "Aerial" + } + return "Unknown" +} + declare_export_workspace :: proc(app: ^GUI_App_State) { if clay.UI(clay.ID("ExportCard"))(clay_card_style()) { - clay_title_text("Export") + declare_section_header("ExportHeader", "Export", "Package your comic for distribution") ready, total := ready_stage_count(app.controller) all_ready := ready == total @@ -248,35 +465,24 @@ declare_export_workspace :: proc(app: ^GUI_App_State) { panel_count := len(app.controller.state.panel_images) page_count := len(app.controller.state.page_layouts) - if clay.UI(clay.ID("ExportStats"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 16}}) { + if clay.UI(clay.ID("ExportStats"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 12}}) { declare_stat_chip("exp_pages", "Pages", page_count) declare_stat_chip("exp_panels", "Panels", panel_count) declare_stat_chip("exp_ready", "Ready", ready) } - clay_muted_text(fmt.tprintf("Format: %v • Target: %s", app.controller.state.export_format, app.export_path)) + clay_body_text(fmt.tprintf("Format: %v", app.controller.state.export_format), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) reason := export_block_reason(app.controller.state) if len(reason) > 0 { - if clay.UI(clay.ID("ExportBlocked"))({ - layout = {layoutDirection = .TopToBottom, padding = {top = 8, right = 12, bottom = 8, left = 12}, childGap = 4}, - backgroundColor = CLAY_ERROR_DIM, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), - }) { - clay_body_text(fmt.tprintf("Blocked: %s", reason), color = CLAY_ERROR) - clay_body_text("Complete all pipeline stages before exporting.", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) - } + declare_info_callout("ExportBlocked", fmt.tprintf("Blocked: %s", reason), false) + clay_body_text("Complete all pipeline stages before exporting.", color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS) } else { - if clay.UI(clay.ID("ExportReady"))({ - layout = {layoutDirection = .TopToBottom, padding = {top = 8, right = 12, bottom = 8, left = 12}, childGap = 4}, - backgroundColor = CLAY_SUCCESS_DIM, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), - }) { - clay_body_text("All pipeline stages complete. Ready!", color = CLAY_SUCCESS) - } + declare_info_callout("ExportReady", "All pipeline stages complete. Ready to export!", true) } - // Export action buttons + declare_divider("ExportDivider") + if clay.UI(clay.ID("ExportActions"))({layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childGap = 8}}) { format := app.controller.state.export_format declare_button_state_export("btn_export_now", "Export as PDF", format == .PDF) @@ -286,10 +492,11 @@ declare_export_workspace :: proc(app: ^GUI_App_State) { if page_count > 0 { if clay.UI(clay.ID("ExportPagesList"))({ - layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 1}, + layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, childGap = 2}, }) { + clay_label_text("PAGES") for l in app.controller.state.page_layouts { - clay_muted_text(fmt.tprintf("Page %d • %s • %d panels", l.page_number, l.pattern_id, len(l.panels))) + clay_body_text(fmt.tprintf("Page %d - %s - %d panels", l.page_number, l.pattern_id, len(l.panels)), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_SM) } } } @@ -297,25 +504,25 @@ declare_export_workspace :: proc(app: ^GUI_App_State) { } declare_button_state_export :: proc(id: string, label: string, is_current_format: bool) { + is_hov := clay.Hovered() bg := CLAY_BTN_DEFAULT if is_current_format { bg = CLAY_BTN_SOFT } + if is_hov { + if is_current_format { bg = CLAY_BTN_SOFT_HOVER } + else { bg = CLAY_BTN_DEFAULT_HOVER } + } if clay.UI(clay.ID(id))({ layout = clay_button_layout(), backgroundColor = bg, - cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_FRAC_BTN), + cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_MD), + border = {color = CLAY_ACCENT if is_current_format else clay.Color{0, 0, 0, 0}, width = clay.BorderOutside(1)}, }) { - clay_body_text(label) + clay_body_text(label, color = CLAY_TEXT_BRIGHT if is_current_format else CLAY_TEXT_PRIMARY, size = CLAY_FONT_SIZE_SM) } } -// ─── Community Workspace ────────────────────────────────────────── - -// ─── Community Workspace ────────────────────────────────────────── declare_community_workspace :: proc(app: ^GUI_App_State) { - if clay.UI(clay.ID("CommunityCard"))(clay_card_style()) { - clay_title_text("Community") - clay_body_text("Community features coming soon", color = CLAY_TEXT_TERTIARY) - } + declare_empty_state("CommunityCard", "Community", + "Community features coming soon", + "Share and discover comics made with comic-odin") } - -// ─── Bottom Bar ─────────────────────────────────────────────────── diff --git a/odin/src/shared/config.odin b/odin/src/shared/config.odin index 67f6e67..ff976f3 100644 --- a/odin/src/shared/config.odin +++ b/odin/src/shared/config.odin @@ -1,6 +1,8 @@ package shared import "core:os" +import "core:strings" +import filepath "core:path/filepath" Config :: struct { deepseek_api_key: string, @@ -9,11 +11,87 @@ Config :: struct { project_root: string, } +@(private) +dotenv_cache: map[string]string + +@(private) +load_dotenv_file :: proc(path: string) { + if dotenv_cache == nil { + dotenv_cache = make(map[string]string) + } + + data, read_err := os.read_entire_file(path, context.temp_allocator) + if read_err != nil { + return + } + + lines := strings.split(string(data), "\n", context.temp_allocator) + for line in lines { + trimmed := strings.trim_space(line) + if len(trimmed) == 0 || trimmed[0] == '#' { + continue + } + eq := strings.index(trimmed, "=") + if eq < 0 { + continue + } + key := strings.trim_space(trimmed[:eq]) + value := strings.trim_space(trimmed[eq+1:]) + if len(key) > 0 && len(value) > 0 { + dotenv_cache[key] = value + } + } +} + +@(private) +try_dotenv_paths :: proc() { + // Try common locations relative to the executable and cwd + candidates := [3]string{".env", "../odin/.env", "odin/.env"} + for path in candidates { + if os.exists(path) { + load_dotenv_file(path) + return + } + } + // Also try relative to the executable + if exec_path_raw := os.get_env("_", context.temp_allocator); len(exec_path_raw) > 0 { + dir, _ := filepath.split(exec_path_raw) + if len(dir) > 0 { + env_path, _ := filepath.join([]string{dir, ".env"}, context.temp_allocator) + if os.exists(env_path) { + load_dotenv_file(env_path) + return + } + } + } +} + +@(private) +get_env_or_dotenv :: proc(key: string) -> string { + // 1. Try actual environment variable first + val := os.get_env(key, context.temp_allocator) + if len(val) > 0 { + return val + } + + // 2. Fall back to loaded .env file + if dotenv_cache == nil { + try_dotenv_paths() + } + if dotenv_cache != nil { + if dotenv_val, has := dotenv_cache[key]; has { + return dotenv_val + } + } + + return "" +} + load_config :: proc() -> Config { cfg := Config{ - deepseek_api_key = os.get_env("DEEPSEEK_API_KEY", context.temp_allocator), + deepseek_api_key = get_env_or_dotenv("DEEPSEEK_API_KEY"), deepseek_base_url = os.get_env("DEEPSEEK_BASE_URL", context.temp_allocator), - fal_api_key = os.get_env("FAL_API_KEY", context.temp_allocator), + fal_api_key = get_env_or_dotenv("FAL_API_KEY"), project_root = ".", } diff --git a/odin/src/shared/layout.odin b/odin/src/shared/layout.odin index a08c0a7..559ec43 100644 --- a/odin/src/shared/layout.odin +++ b/odin/src/shared/layout.odin @@ -1,32 +1,48 @@ package shared +// ══════════════════════════════════════════════════════════════════════ +// Layout Constants & Breakpoint System +// +// Organized by semantic role. All pixel values. The breakpoint system +// lets the UI branch on screen size for responsive layouts. +// ══════════════════════════════════════════════════════════════════════ + +Breakpoint :: enum { + Compact, // < 860px height — hide non‑essentials, collapse sidebar + Standard, // 860–1919px width — full layout + Wide, // ≥ 1920px width — multi‑column, expanded panels +} + Layout_Constants :: struct { sidebar_width: i32, + sidebar_collapsed: i32, right_margin: i32, min_main_width: i32, top_reserved_height: i32, min_lower_y: i32, compact_height: i32, + standard_width: i32, wide_width: i32, + max_card_width: i32, } LAYOUT :: Layout_Constants{ - sidebar_width = 282, - right_margin = 20, + sidebar_width = 260, + sidebar_collapsed = 52, + right_margin = 16, min_main_width = 960, top_reserved_height = 252, min_lower_y = 450, - compact_height = 860, + compact_height = 720, + standard_width = 1280, wide_width = 1920, + max_card_width = 520, } -Screen_Profile :: enum { - Compact, - Standard, - Wide, -} +// ─── Breakpoint Helpers ─────────────────────────────────────────── -screen_profile :: proc(screen_w, screen_h: i32) -> Screen_Profile { +// breakpoint returns the active breakpoint for the given window dimensions. +breakpoint :: proc(screen_w, screen_h: i32) -> Breakpoint { if screen_h < LAYOUT.compact_height { return .Compact } @@ -36,14 +52,28 @@ screen_profile :: proc(screen_w, screen_h: i32) -> Screen_Profile { return .Standard } -compute_main_width :: proc(screen_w: i32) -> i32 { - w := screen_w - LAYOUT.sidebar_width - LAYOUT.right_margin +// is_compact returns true when the height is below the compact threshold. +is_compact :: proc(screen_h: i32) -> bool { + return screen_h < LAYOUT.compact_height +} + +// is_wide returns true when the width is at or above the wide threshold. +is_wide :: proc(screen_w: i32) -> bool { + return screen_w >= LAYOUT.wide_width +} + +// ─── Dimension Helpers ─────────────────────────────────────────── + +// compute_main_width calculates the available content width after sidebar + margins. +compute_main_width :: proc(screen_w: i32, sidebar_w: i32 = LAYOUT.sidebar_width) -> i32 { + w := screen_w - sidebar_w - LAYOUT.right_margin if w < LAYOUT.min_main_width { return LAYOUT.min_main_width } return w } +// compute_lower_y calculates the Y offset for the bottom dashboard region. compute_lower_y :: proc(screen_h: i32) -> i32 { y := screen_h - LAYOUT.top_reserved_height if y < LAYOUT.min_lower_y { @@ -52,6 +82,25 @@ compute_lower_y :: proc(screen_h: i32) -> i32 { return y } -is_compact :: proc(screen_h: i32) -> bool { - return screen_h < LAYOUT.compact_height +// ─── Sidebar Helpers ───────────────────────────────────────────── + +// sidebar_width_for_breakpoint returns the sidebar width for the given breakpoint. +sidebar_width_for_breakpoint :: proc(bp: Breakpoint) -> i32 { + switch bp { + case .Compact: + return LAYOUT.sidebar_collapsed + case .Standard, .Wide: + return LAYOUT.sidebar_width + } + return LAYOUT.sidebar_width +} + +// ─── Card Layout Helpers ───────────────────────────────────────── + +// cards_per_row returns how many card columns fit at the given main width. +cards_per_row :: proc(main_width: i32) -> int { + if main_width >= 1600 { return 4 } + if main_width >= 1200 { return 3 } + if main_width >= 800 { return 2 } + return 1 } diff --git a/odin/tests/adapters_phase2.odin b/odin/tests/adapters_phase2.odin index 8371c72..8a04e71 100644 --- a/odin/tests/adapters_phase2.odin +++ b/odin/tests/adapters_phase2.odin @@ -88,7 +88,7 @@ fal_panel_generation_retries_network_error :: proc(t: ^testing.T) { client.max_retries = 3 panel := core.Panel{panel_id = "panel_1", panel_number = 1, description = "Hero jumps over a gap"} - img, err := adapters.generate_panel_image(client, cfg, panel, nil, "manga", "proj_1") + img, err := adapters.generate_panel_image(client, cfg, panel, nil, "manga", "proj_1", "", "") defer delete(img.prompt) testing.expect(t, shared.is_ok(err), "fal panel generation should eventually succeed") diff --git a/odin/tests/gui_integration_phase39.odin b/odin/tests/gui_integration_phase39.odin index 978b1ea..06fb7a4 100644 --- a/odin/tests/gui_integration_phase39.odin +++ b/odin/tests/gui_integration_phase39.odin @@ -16,29 +16,25 @@ import "../src/ui" @test layout_constants_match_hardcoded_values :: proc(t: ^testing.T) { - testing.expect(t, shared.LAYOUT.sidebar_width == 282, "sidebar_width should match historical 282") - testing.expect(t, shared.LAYOUT.right_margin == 20, "right_margin should match historical 20") + testing.expect(t, shared.LAYOUT.sidebar_width == 260, "sidebar_width should match 260") + testing.expect(t, shared.LAYOUT.right_margin == 16, "right_margin should match 16") testing.expect(t, shared.LAYOUT.min_main_width == 960, "min_main_width should match historical 960") testing.expect(t, shared.LAYOUT.top_reserved_height == 252, "top_reserved_height should match historical 252") testing.expect(t, shared.LAYOUT.min_lower_y == 450, "min_lower_y should match historical 450") - testing.expect(t, shared.LAYOUT.compact_height == 860, "compact_height should match historical 860") + testing.expect(t, shared.LAYOUT.compact_height == 720, "compact_height should match 720") } @test compute_main_width_formula :: proc(t: ^testing.T) { - // 1920x1080 standard w := shared.compute_main_width(1920) - testing.expect(t, w == 1618, fmt.tprintf("1920px screen: expected 1618, got %d", w)) + testing.expect(t, w == 1644, fmt.tprintf("1920px screen: expected 1644, got %d", w)) - // 1366x768 compact w2 := shared.compute_main_width(1366) - testing.expect(t, w2 == 1064, fmt.tprintf("1366px screen: expected 1064, got %d", w2)) + testing.expect(t, w2 == 1090, fmt.tprintf("1366px screen: expected 1090, got %d", w2)) - // Ultrawide 2560x1440 w3 := shared.compute_main_width(2560) - testing.expect(t, w3 == 2258, fmt.tprintf("2560px screen: expected 2258, got %d", w3)) + testing.expect(t, w3 == 2284, fmt.tprintf("2560px screen: expected 2284, got %d", w3)) - // Minimum floor w4 := shared.compute_main_width(1200) testing.expect(t, w4 == 960, fmt.tprintf("narrow screen: expected 960 floor, got %d", w4)) } @@ -60,28 +56,27 @@ compute_lower_y_formula :: proc(t: ^testing.T) { @test screen_profile_classification :: proc(t: ^testing.T) { - // Compact: height < 860 - p1 := shared.screen_profile(1366, 768) - testing.expect(t, p1 == .Compact, "1366x768 should be Compact") + p1 := shared.breakpoint(1366, 768) + testing.expect(t, p1 == .Standard, "1366x768 should be Standard") - // Standard: height >= 860, width < 1920 - p2 := shared.screen_profile(1440, 900) + p2 := shared.breakpoint(1440, 900) testing.expect(t, p2 == .Standard, "1440x900 should be Standard") - // Wide: width >= 1920 - p3 := shared.screen_profile(1920, 1080) + p3 := shared.breakpoint(1920, 1080) testing.expect(t, p3 == .Wide, "1920x1080 should be Wide") - // Ultrawide - p4 := shared.screen_profile(2560, 1440) + p4 := shared.breakpoint(2560, 1440) testing.expect(t, p4 == .Wide, "2560x1440 should be Wide") + + p5 := shared.breakpoint(1280, 600) + testing.expect(t, p5 == .Compact, "1280x600 should be Compact") } @test is_compact_helper :: proc(t: ^testing.T) { - testing.expect(t, shared.is_compact(768), "768px should be compact") - testing.expect(t, shared.is_compact(859), "859px should be compact") - testing.expect(t, !shared.is_compact(860), "860px should not be compact") + testing.expect(t, shared.is_compact(600), "600px should be compact") + testing.expect(t, shared.is_compact(719), "719px should be compact") + testing.expect(t, !shared.is_compact(720), "720px should not be compact") testing.expect(t, !shared.is_compact(1080), "1080px should not be compact") } diff --git a/odin/wa.pdf b/odin/wa.pdf new file mode 100644 index 0000000..6375a7d Binary files /dev/null and b/odin/wa.pdf differ diff --git a/tests b/tests new file mode 100755 index 0000000..c983473 Binary files /dev/null and b/tests differ