diff --git a/.github/workflows/odin-ci.yml b/.github/workflows/odin-ci.yml new file mode 100644 index 0000000..2882e03 --- /dev/null +++ b/.github/workflows/odin-ci.yml @@ -0,0 +1,44 @@ +name: odin-ci + +on: + push: + paths: + - 'odin/**' + - '.github/workflows/odin-ci.yml' + pull_request: + paths: + - 'odin/**' + - '.github/workflows/odin-ci.yml' + workflow_dispatch: + +jobs: + build-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: odin + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Odin + uses: laytan/setup-odin@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + run: ./build.sh + + - name: Test + run: odin test tests + + - name: Package + run: ./scripts/package.sh + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: comic-odin-package + path: | + odin/dist/*.tar.gz + odin/dist/*.sha256 diff --git a/odin/.env b/odin/.env new file mode 100644 index 0000000..3ecd987 --- /dev/null +++ b/odin/.env @@ -0,0 +1,2 @@ +DEEPSEEK_API_KEY=sk-c6e67b9d125448f593f202a5891eb123 +FAL_API_KEY=d6eda9df-62ca-4934-8a61-4e7e659411e2:731fc05a520e6aeb1f3b68d74d0515aa diff --git a/odin/.gitignore b/odin/.gitignore new file mode 100644 index 0000000..32f1727 --- /dev/null +++ b/odin/.gitignore @@ -0,0 +1,5 @@ +bin/ +*.ll +*.o +*.obj +*.pdb diff --git a/odin/README.md b/odin/README.md new file mode 100644 index 0000000..195f99a --- /dev/null +++ b/odin/README.md @@ -0,0 +1,33 @@ +# comic-odin (port skeleton) + +This is the Odin-native skeleton for porting the current React/TypeScript comic app. + +## Goals + +- Keep domain logic in `src/core` (types, workflow, layout, bubble logic) +- Keep external integrations in `src/adapters` (DeepSeek, fal.ai, storage, export) +- Keep app entry in `src/app` +- Add tests as domain logic is ported + +## Proposed layout + +- `src/app` - app entrypoint and composition root +- `src/core` - pure domain logic and state machine +- `src/adapters` - IO + external services +- `src/shared` - common errors/config +- `tests` - unit/integration tests +- `docs` - migration and implementation notes +- `schemas` - JSON schemas for project/script persistence + +## Quick start + +```bash +# from repository root +cd odin +./build.sh +./bin/comic_odin +``` + +## Status + +Scaffold only (interfaces + placeholders). No full functionality yet. diff --git a/odin/build.sh b/odin/build.sh new file mode 100755 index 0000000..815eb3a --- /dev/null +++ b/odin/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p bin +odin build src/app -out:bin/comic_odin -debug diff --git a/odin/comic-odin b/odin/comic-odin new file mode 100755 index 0000000..400c6d0 Binary files /dev/null and b/odin/comic-odin differ diff --git a/odin/comic.pdf b/odin/comic.pdf new file mode 100644 index 0000000..0fbf063 --- /dev/null +++ b/odin/comic.pdf @@ -0,0 +1,27 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 12 Tf 50 780 Td (Comic Export - Panels: 2) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/odin/docs/PORT_BACKLOG.md b/odin/docs/PORT_BACKLOG.md new file mode 100644 index 0000000..24d94ca --- /dev/null +++ b/odin/docs/PORT_BACKLOG.md @@ -0,0 +1,1027 @@ +# Odin Port Backlog + +## Milestone 1: Core parity +- [x] Port all TS domain enums + structs (baseline) +- [x] Port script normalization (validation is minimal) +- [x] Port layout pattern selection and page packing +- [x] Port bubble placement + sizing +- [x] Port deterministic seed generation (TS-style hash/parity logic) + +## Milestone 2: Service integration +- [x] DeepSeek adapter with retry/backoff (transport-injected) +- [x] fal.ai adapter with queue and concurrency caps (transport-injected) +- [x] Error taxonomy + retry classification +- [x] Real HTTP integrations via curl transport (DeepSeek + fal) +- [x] Full DeepSeek envelope/content JSON decoding into typed domain script +- [x] Replace fal response string extraction with typed JSON decoding + +## Milestone 3: Persistence + export +- [x] Project save/load format (`.comic.json`) with schema wrapper +- [x] Asset cache folder strategy (`/assets`) +- [x] Exporters (PNG/CBZ/PDF) baseline implementation +- [x] PNG/CBZ hardening: staged image files + zip packaging (+ ComicInfo.xml for CBZ) +- [x] Migration scaffold (`schemaVersion` + migration switch) + +## Milestone 4: Desktop UI +- [x] Story/script/character/panel/layout/bubble/export screen routing scaffold (`src/ui/screens.odin`) +- [x] Workflow navigation guards (`src/ui/navigation.odin`) +- [x] Background jobs + cancellation scaffold (`src/ui/jobs.odin`, `src/ui/controller.odin`) +- [x] Rendering/runtime layer scaffold (`src/ui/views.odin`, `src/ui/runtime.odin`) + +## Milestone 5: Hardening +- [x] Lifecycle cleanup scaffold for state/controller (`src/core/dispose.odin`, `src/ui/dispose.odin`) +- [x] Hardening tests for dispose/cleanup behavior (`tests/hardening_phase5.odin`) +- [x] Initial ownership cleanup pass in tests/controllers (defer dispose on controllers, scripts, fixtures) +- [x] Reduce remaining allocator leak warnings across adapters/views/tests via deeper allocation policy + explicit string/slice ownership rules + +## Milestone 6: CLI Runtime +- [x] Command parser + executor (`src/app/cli.odin`) +- [x] CLI-integrated app boot path (`src/app/main.odin`) +- [x] CLI tests for parse/save/load workflow (`tests/app_cli_phase6.odin`) + +## Milestone 7: Packaging + CI +- [x] Packaging script for release artifacts (`scripts/package.sh`) +- [x] CI workflow for build/test/package (`.github/workflows/odin-ci.yml`) +- [x] Artifact upload in CI (tarball + sha256) + +## Milestone 8: Interactive TUI +- [x] Added `tui` CLI mode with interactive command loop (`src/app/cli.odin`) +- [x] TUI commands for navigation/workflow/jobs/save/load/status +- [x] Parser coverage update for `tui` command (`tests/app_cli_phase6.odin`) +- [x] TUI polish: ANSI full-screen redraw + single-key aliases (`h/s/d/c/q`, `1..8` screens) + +## Milestone 9: TUI Story Editing +- [x] Added `new` command to reset project state +- [x] Added `set idea `, `set genre `, `set audience ` commands +- [x] Verified interactive behavior with scripted TUI smoke run + +## Milestone 10: TUI Generation Commands +- [x] Added `generate script` command wired to DeepSeek adapter path +- [x] Added `generate panels` command wired to fal batch adapter path +- [x] Added `generate script ` override parsing +- [x] Added `generate panels page ` scoped generation parsing +- [x] Added regression tests for missing-key/empty-script generation paths (`tests/app_cli_phase6.odin`) + +## Milestone 11: TUI Layout + Export Commands +- [x] Added `layout auto` command to generate `page_layouts` from script panels +- [x] Added `export ` command wired to export adapter +- [x] Added parser/test coverage for export command and layout/export precondition failures (`tests/app_cli_phase6.odin`) + +## Milestone 12: Offline TUI Script Mode +- [x] Added `generate script local [pages]` for keyless local script generation +- [x] Added parser/test coverage for local script command and new command forms (`tests/app_cli_phase6.odin`) +- [x] Fixed `set_workflow_step` historical-steps reallocation leak in `src/core/state.odin` + +## Milestone 13: Offline TUI Panels + Export Flow +- [x] Added `generate panels local [page ]` for keyless local panel image generation +- [x] Added parser/test coverage for local panel command and offline local flow (`tests/app_cli_phase6.odin`) +- [x] Synced export UI state format after export command execution + +## Milestone 14: One-Command Offline Pipeline +- [x] Added `quick local ` TUI command (local script + local panels + layout + export) +- [x] Added parser/test coverage for quick-local command (`tests/app_cli_phase6.odin`) +- [x] Validated end-to-end quick-local PDF export in TUI smoke run + +## Milestone 15: Quick-Local Parameterization +- [x] Added optional page-count argument: `quick local [pages]` +- [x] Added parser/test coverage for quick-local page-count parsing and resulting script page count (`tests/app_cli_phase6.odin`) +- [x] Validated quick-local CBZ export with page-count override in TUI smoke run + +## Milestone 16: One-Command Save+Export Pipeline +- [x] Added `quick local all [pages]` +- [x] Refactored quick-local flow into reusable `run_quick_local_pipeline` helper +- [x] Added parser/integration tests for quick-local-all save+export behavior (`tests/app_cli_phase6.odin`) + +## Milestone 17: TUI Doctor Diagnostics +- [x] Added `doctor` command for environment/tool diagnostics (DeepSeek key, fal key, curl, python3) +- [x] Added alias `?` -> `doctor` +- [x] Added parser/command tests and TUI smoke validation (`tests/app_cli_phase6.odin`) + +## Milestone 18: TUI Alias & Flow Ergonomics +- [x] Added `saveas ` alias for `save ` and `open ` alias for `load ` +- [x] Added tests for `open`/`saveas` alias workflow (`tests/app_cli_phase6.odin`) +- [x] Hardened owned cleanup in alias load/save test path to avoid unmarshal-token leak leftovers + +## Milestone 19: TUI Readiness Diagnostics +- [x] Added `ready` command for workflow readiness checks (script/panels/layout/export) +- [x] Added alias `r` -> `ready` +- [x] Added parser/command tests and TUI smoke validation (`tests/app_cli_phase6.odin`) + +## Milestone 20: TUI Guided Next-Step Hints +- [x] Added `next` command to recommend the next actionable workflow command +- [x] Added alias `n` -> `next` +- [x] Added parser/command tests and TUI smoke validation for hint progression (`tests/app_cli_phase6.odin`) + +## Milestone 21: TUI Plan/Checklist View +- [x] Added `plan` command showing stage completion checklist + current next hint +- [x] Added alias `p` -> `plan` +- [x] Added parser/command tests and TUI smoke validation for checklist progression (`tests/app_cli_phase6.odin`) + +## Milestone 22: TUI Auto-Advance Command +- [x] Added `auto` command to execute the current `next` recommended action +- [x] Added alias `x` -> `auto` +- [x] Added parser/command tests and TUI smoke validation for auto progression (`tests/app_cli_phase6.odin`) + +## Milestone 23: TUI Auto-All Pipeline +- [x] Added `auto all ` command to auto-run next steps until export +- [x] Added parser/command tests for `auto all` parsing and export completion (`tests/app_cli_phase6.odin`) +- [x] Validated end-to-end `auto all` TUI smoke export run + +## Milestone 24: Forced-Local Auto-All Mode +- [x] Added `auto all local [pages]` command to force fully-local auto pipeline +- [x] Added parser/command tests for `auto all local` parsing and export completion (`tests/app_cli_phase6.odin`) +- [x] Validated end-to-end `auto all local` TUI smoke export run + +## Milestone 25A: Native GUI Bring-up +- [x] Added Raylib GUI runtime scaffold (`src/gui/runtime.odin`) +- [x] Added CLI entrypoint `gui` and command parsing support (`src/app/cli.odin`) +- [x] Implemented native window + screen router hotkeys + Story field editing + local action hotkeys (F5/F6/F7/F8) +- [x] Added parse coverage for `gui` command (`tests/app_cli_phase6.odin`) + +## Milestone 25B: GUI Interaction Pass +- [x] Added clickable GUI action buttons (New, Generate Script Local, Generate Panels Local, Layout, Export PDF) +- [x] Added editable export path field in GUI and wired export button to field value +- [x] Added on-screen command panel/checklist copy and retained hotkey fallbacks + +## Milestone 25C: GUI Navigation + Activity Visibility +- [x] Added clickable screen navigation tiles in sidebar (in addition to 1..8 hotkeys) +- [x] Added right-side action log panel with recent GUI actions/status messages +- [x] Preserved workflow-guard behavior for blocked screen transitions with explicit status feedback + +## Milestone 25D: GUI Guided Controls +- [x] Added GUI `Next` action (button + F9) based on current guided workflow hint +- [x] Added GUI `Auto-All` action (button + F10) to run local flow through export +- [x] Added on-screen current `Next:` hint display and integrated action-log updates + +## Milestone 25E: GUI Export/Script Controls +- [x] Added GUI export format selector (PDF/PNG/CBZ) used by export and guided actions +- [x] Added GUI local-script pages input field, used by script generation/Next/Auto-All +- [x] Kept hotkey parity and updated on-screen hints for new controls + +## Milestone 25F: GUI Project Path & File Actions +- [x] Added editable GUI project path field (`./gui_project.comic.json` default) +- [x] Added clickable GUI Save/Open project buttons wired to storage adapter +- [x] Integrated selected export format into GUI export action and refreshed command panel/status layout + +## Milestone 25G: GUI One-Click Session Workflow +- [x] Added `Auto-All + Save` GUI button to run local full flow then persist project +- [x] Wired button to reuse guided local pipeline + storage adapter save path +- [x] Updated command panel copy and action-log integration for one-click workflow + +## Milestone 25H: GUI Productivity Hotkeys +- [x] Added GUI productivity shortcuts: `Ctrl+S` (save), `Ctrl+O` (open), `Ctrl+E` (export) +- [x] Reused existing adapter actions for consistent behavior with button-driven flows +- [x] Updated on-screen hotkey hints to expose productivity shortcuts + +## Milestone 25I: GUI Session Management UX +- [x] Added GUI project path editing focus (`F12`) + Save/Open button flow integration +- [x] Added GUI dirty-state indicator (`Dirty: yes/no`) driven by edits/actions/save-open reset +- [x] Expanded command panel/action area to include Save/Open/Auto-All+Save session workflow + +## Milestone 25J: GUI Destructive-Action Safeguards +- [x] Added unsaved-change guard for `New` and `Open` button actions +- [x] Require `Shift` modifier to confirm destructive New/Open when dirty +- [x] Added inline UI tip/status feedback and action-log entries for guarded actions + +## Milestone 25K: GUI Keyboard Safety Parity +- [x] Added guarded keyboard reset shortcut `Ctrl+N` with `Ctrl+Shift+N` confirm when dirty +- [x] Added guarded keyboard open shortcut `Ctrl+O` with `Ctrl+Shift+O` confirm when dirty +- [x] Updated GUI hotkey hints/tooltips to document destructive keyboard confirmations + +## Milestone 25L: GUI Autosave Controls +- [x] Added GUI autosave toggle button/state with interval-based autosave attempts +- [x] Added autosave status visibility in HUD and action log entries for autosave outcomes +- [x] Added keyboard toggle `Ctrl+Shift+A` and updated hotkey hints + +## Milestone 25M: GUI Visual Polish Pass +- [x] Upgraded visual theme to modern card-based layout (header bar, rounded controls, soft borders) +- [x] Added selected-state input styling and modernized nav/button hover/active visuals +- [x] Reworked status/command/action-log sections into clear dashboard surfaces + +## Milestone 26A: GUI Screen-Specific Summaries +- [x] Added dedicated screen summary card that changes content by active screen +- [x] Surfaced screen-relevant state details (Story/Script/Panels/Layout/Export/Community) +- [x] Replaced static command-only card with dynamic per-screen contextual summary + +## Milestone 26B: GUI Per-Screen Mini-Lists +- [x] Added script-page mini-list in Script summary (first pages + overflow hint) +- [x] Added layout-page mini-list in Layout summary (page/pattern/panel count) +- [x] Added richer export summary details (last layout pattern + action hint) + +## Milestone 26C: GUI Mini-List View Toggles +- [x] Added summary controls for Script/Layout mini-lists: show `Top` vs `All` +- [x] Added summary controls for Script/Layout ordering: `Asc` vs `Desc` +- [x] Wired toggle actions to status + action-log feedback + +## Milestone 26D: GUI Summary Clarity + Empty States +- [x] Added compact KPI chips in summary header (pages/panels/layout counts) +- [x] Added explicit empty-state guidance for Script/Panels/Layout screens +- [x] Added export precondition messaging in summary when panels/layout are missing + +## Milestone 26E: GUI Summary Controls Ergonomics +- [x] Added reusable summary-toggle helpers to unify Script/Layout show/sort behavior +- [x] Added keyboard shortcuts for summary controls (`Ctrl+H` show, `Ctrl+J` sort) +- [x] Updated summary control labels + sidebar hotkey hints for discoverability + +## Milestone 26F: GUI Status Telemetry +- [x] Added status-card telemetry for last save time and last export time +- [x] Wired telemetry updates across save/export/manual-auto flows and autosave success +- [x] Expanded status card layout to surface action recency at-a-glance + +## Milestone 26G: GUI Toast Feedback +- [x] Added transient toast notifications sourced from latest action-log event +- [x] Added basic severity coloring for failure/blocked vs success/info messages +- [x] Positioned toast in main workspace for immediate action feedback + +## Milestone 26H: GUI Readiness Chips +- [x] Added compact readiness chips in status card (Script/Panels/Layout/Export) +- [x] Wired readiness states to live project data for at-a-glance pipeline progress +- [x] Integrated readiness row into status layout without regressing existing telemetry + +## Milestone 26I: GUI Shortcuts Help Overlay +- [x] Added in-app keyboard shortcuts overlay (modal-style surface) with grouped guidance +- [x] Added help toggle via `/` key and Help button, plus `Esc` close behavior +- [x] Prevented text-field edits while help overlay is open to reduce accidental input + +## Milestone 26J: GUI Destructive Confirmation Modal +- [x] Added explicit dirty-state confirmation modal for New/Open destructive flows +- [x] Added confirm/cancel interactions (buttons + Enter/Y confirm, Esc/N cancel) +- [x] Gated action handlers while confirm modal is open to avoid accidental overlap + +## Milestone 26K: GUI Modal Input Isolation +- [x] Added unified interaction lock when help/confirm overlays are visible +- [x] Prevented background navigation/field-focus/action hotkeys while overlays are active +- [x] Forced help overlay closed when opening destructive confirm modal to avoid stacked modals + +## Milestone 26L: GUI Pipeline Progress Meter +- [x] Added readiness stage counting helper for Script/Panels/Layout/Export pipeline +- [x] Added visual pipeline progress bar to status card (`ready/total` stages) +- [x] Integrated progress summary alongside next-step hint and existing status telemetry + +## Milestone 26M: GUI Action Readiness Gating +- [x] Added visual disabled-state styling for dependent actions (Panels/Layout/Export) +- [x] Added explicit precondition messages for blocked action attempts (button + hotkey paths) +- [x] Added export precondition guards for `Ctrl+E`/`F8` parity with button behavior + +## Milestone 26N: GUI Hover Guidance for Disabled Actions +- [x] Added contextual hover hints explaining why Panels/Layout/Export are disabled +- [x] Surfaced readiness guidance inline near action controls for faster recovery +- [x] Kept guidance aligned with existing readiness guards and status messaging + +## Milestone 26O: GUI Recommended Action Highlighting +- [x] Added recommended-action resolver from workflow next-hint to primary action labels +- [x] Added visual emphasis styling for the currently recommended action button +- [x] Added inline `Recommended:` guidance when no blocking hover hint is active + +## Milestone 26P: GUI Export Format Path Sync +- [x] Added format-to-suffix mapping and export-path normalization helpers +- [x] Auto-updated export path extension on format toggle (PDF/PNG/CBZ) +- [x] Added inline UI cue that format selection auto-fixes export extension + +## Milestone 26Q: GUI Status Severity Styling +- [x] Added reusable message severity helpers (error/warning/info) +- [x] Applied severity-aware status text coloring in status card +- [x] Unified toast background coloring with shared severity classification + +## Milestone 26R: GUI Action-Log Utilities +- [x] Added action-log utility controls in GUI (Clear + Copy status) +- [x] Added keyboard shortcuts `Ctrl+L` (clear log) and `Ctrl+Shift+C` (copy status) +- [x] Updated sidebar/help-overlay shortcut documentation for new utilities + +## Milestone 26S: GUI Export Path Normalize Utility +- [x] Added `Fix Ext` action button beside export path input to normalize extension +- [x] Added keyboard shortcut `Ctrl+Shift+F` for extension normalization +- [x] Updated sidebar/help-overlay shortcut hints for export-path normalization + +## Milestone 26T: GUI Export Path Presets +- [x] Added format-aware default export path helper (`./comic_export.`) +- [x] Added `Preset` action button beside export path input +- [x] Added keyboard shortcut `Ctrl+Shift+P` and updated in-app shortcut docs + +## Milestone 26U: GUI Format-Specific Preset Filenames +- [x] Updated export presets to format-specific filenames (`comic.pdf`, `comic_png.zip`, `comic.cbz`) +- [x] Kept preset hotkey/button flows aligned with existing format selection state +- [x] Added inline export-path helper text documenting preset naming conventions + +## Milestone 26V: GUI Export Summary Detail Pass +- [x] Extended Export screen summary with explicit target export path +- [x] Added shared export-block reason helper for concise precondition messaging +- [x] Upgraded Export summary guidance to show exact missing prerequisite(s) + +## Milestone 26W: GUI Export Path Copy Utility +- [x] Added `Copy` action button beside export path controls +- [x] Added keyboard shortcut `Ctrl+Shift+X` to copy export path quickly +- [x] Updated sidebar/help shortcut docs to include export-path copy action + +## Milestone 26X: GUI Export Path from Project Directory +- [x] Added helper to derive export output path from current project directory + format filename +- [x] Added `Use Dir` action button to set export path relative to project location +- [x] Added keyboard shortcut `Ctrl+Shift+D` and updated shortcut docs + +## Milestone 26Y: GUI Field Clipboard Ergonomics +- [x] Added `Ctrl+V` paste into currently selected input field +- [x] Added `Ctrl+Shift+I` copy currently selected field content to clipboard +- [x] Updated sidebar/help overlay shortcut references for field clipboard actions + +## Milestone 26Z: GUI Selected-Field Clear Actions +- [x] Added `Clear Field` action button to clear currently focused input quickly +- [x] Added keyboard shortcut `Ctrl+Backspace` for selected-field clearing +- [x] Added clear-action status feedback + updated shortcut docs + +## Milestone 27A: GUI Autosave Interval Controls +- [x] Added editable autosave-interval field in GUI controls (`seconds`) +- [x] Added keyboard adjustments for autosave interval (`Ctrl+-` / `Ctrl+=`) with bounds +- [x] Integrated interval value into status telemetry and shortcut/help copy + +## Milestone 27B: Autosave Interval Presets + Input Guardrails +- [x] Added autosave preset buttons (`15/30/60`) beside interval input +- [x] Added keyboard presets (`Ctrl+7/8/9`) for fast interval switching +- [x] Added numeric-only typing guard for interval field and bounded parse normalization + +## Milestone 27C: GUI Quick Helper Reset +- [x] Added `Reset Helpers` action button for export/pages/autosave helper fields +- [x] Added keyboard shortcut `Ctrl+0` for one-shot helper-field reset +- [x] Updated sidebar/help shortcut docs for helper reset workflow + +## Milestone 27D: Project-Aware Export Path Sync on Open +- [x] Added helper to sync export target to project directory after successful project open +- [x] Applied sync behavior across modal-confirm, button, and keyboard open flows +- [x] Updated open status feedback to include resolved export target path + +## Milestone 27E: Project Path from Export Directory Utility +- [x] Added helper to derive project path from current export directory +- [x] Added `From Exp` project-path button and `Ctrl+Shift+G` shortcut +- [x] Updated shortcut docs in sidebar/help overlay for path sync utilities + +## Milestone 27F: Project Path Extension Normalization +- [x] Added project-path normalization helper to enforce `.comic.json` suffix +- [x] Added `Fix Ext` button for project path and `Ctrl+Shift+J` shortcut +- [x] Updated sidebar/help shortcut docs for project-path normalization + +## Milestone 27G: Path Normalization on I/O Actions +- [x] Added export/project path field normalization helpers for runtime actions +- [x] Applied normalization before save/open/export and guided export hotkeys/actions +- [x] Applied normalization in autosave + modal-confirm open flow to reduce path-related failures + +## Milestone 27H: Path Health Indicators +- [x] Added helpers to validate project-path suffix and export-path/format consistency +- [x] Surfaced live path-health status (`P/E`) in status telemetry line +- [x] Kept indicators aligned with existing normalization + save/export workflows + +## Milestone 27I: Path Health Quick-Fix Controls +- [x] Added compact status-card quick-fix buttons for invalid path indicators (`P`/`E`) +- [x] Added keyboard quick-fix shortcuts (`Ctrl+Shift+K` project, `Ctrl+Shift+M` export) +- [x] Updated sidebar/help shortcut docs for path quick-fix workflow + +## Milestone 27J: One-Shot Path Health Repair +- [x] Added combined path-fix action to normalize both project and export paths in one step +- [x] Added compact status control (`PE`) for one-click all-path repair +- [x] Added keyboard shortcut `Ctrl+Shift+U` + updated shortcut docs for all-path fix + +## Milestone 27K: Path Health Guidance Copy +- [x] Added path-health hint helper for invalid project/export path states +- [x] Surfaced actionable inline guidance with specific quick-fix shortcuts/buttons +- [x] Kept guidance synchronized with `P/E/PE` status controls and path checks + +## Milestone 27L: Diagnostics Snapshot Utility +- [x] Added diagnostics snapshot builder for clipboard-friendly runtime state summary +- [x] Added GUI diagnostics copy actions (`Diag` button, `Ctrl+Shift+Y`) +- [x] Updated sidebar/help shortcut docs for diagnostics export workflow + +## Milestone 27M: Diagnostics File + Help Overlay Compaction +- [x] Added diagnostics file export action (`DiagFile` button, `Ctrl+Shift+R`) using project-directory target +- [x] Added diagnostics-path helper and write-status feedback for file generation +- [x] Refactored help overlay into compact grouped sections to prevent overflow and keep shortcuts readable + +## Milestone 27N: Action Log Snapshot Copy +- [x] Added action-log snapshot serializer for clipboard export +- [x] Added `LogCp` control and keyboard shortcut `Ctrl+Shift+L` to copy recent log lines +- [x] Updated help/sidebar shortcut guidance to document log snapshot workflow + +## Milestone 27O: Session Report Export +- [x] Added combined session-report builder (diagnostics + action-log snapshot) +- [x] Added `Report` action button and `Ctrl+Shift+W` shortcut to write report file +- [x] Updated help/sidebar shortcut guidance for session-report export + +## Milestone 27P: Enriched Diagnostics Payload +- [x] Expanded diagnostics snapshot with next-step hint and content counts (pages/panels/layouts/chars) +- [x] Added runtime uptime field to diagnostics for temporal context +- [x] Added report metadata section (`generated_uptime`) in session-report output + +## Milestone 27Q: Time-Aware Action Log Entries +- [x] Added per-entry timestamp tracking in circular action-log state +- [x] Rendered action-log rows with relative age markers (`[Xs]`) +- [x] Included age-prefixed entries in action-log snapshot exports + +## Milestone 27R: Action Log View Controls +- [x] Added runtime controls for action-log line count (4/6) and order (newest/oldest) +- [x] Added keyboard toggles (`Ctrl+Shift+T` lines, `Ctrl+Shift+B` order) +- [x] Added log-view HUD label and updated help/sidebar shortcut guidance + +## Milestone 27S: Log View Reset + Diagnostics Context +- [x] Added `LogDef` control and `Ctrl+Shift+Z` shortcut to reset log view defaults +- [x] Included current log-view settings in diagnostics/session-report payloads +- [x] Updated help/sidebar guidance for expanded log tooling shortcuts + +## Milestone 28A: GUI Diagnostics/Report Write Helper Extraction +- [x] Added shared helpers for diagnostics/report file writing status handling +- [x] Replaced duplicated button/hotkey write blocks with helper calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28B: GUI Clipboard Helper Extraction +- [x] Added shared clipboard helper for text-copy status flows +- [x] Replaced duplicated clipboard button/hotkey handlers with helper calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28C: GUI Status/Log Push Helper Extraction +- [x] Added shared `push_status` helper for status assignment + action-log append +- [x] Replaced repeated status/log blocks across diagnostics, log-view, and clipboard hotkeys/actions +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28D: Autosave Interval Update Helper Extraction +- [x] Added shared autosave-interval setter helper with bounds enforcement + status message formatting +- [x] Replaced duplicated button/hotkey autosave-interval update blocks with helper calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28E: Path Action Helper Extraction +- [x] Added shared path-action helpers returning status messages (preset/sync/fix/all-fix) +- [x] Replaced duplicated button/hotkey path-update blocks with helper + `push_status` usage +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28F: Project Reset/Open Action Helper Extraction +- [x] Added shared helpers for reset/open project session flows (state replace, screen sync, dirty/autosave updates) +- [x] Replaced duplicated confirm/button/hotkey reset-open blocks with helper calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28G: Project Save Action Helper Extraction +- [x] Added shared helper for project save flows (path normalization, save call, dirty/autosave/save timestamps) +- [x] Replaced duplicated Save button and `Ctrl+S` save blocks with helper calls +- [x] Reused save helper in Auto-All+Save success path while preserving status semantics + +## Milestone 28H: Workflow Action Helper Extraction +- [x] Added shared helpers for script/panels/layout/export/next/auto action execution with consistent dirty/export-timestamp updates +- [x] Replaced duplicated button and F5–F10/`Ctrl+E` action blocks with helper + `push_status` calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28I: Navigation Status Helper Extraction +- [x] Added shared screen-navigation status helpers for button-driven sidebar navigation +- [x] Replaced duplicated per-screen button navigation blocks with helper + `push_status` calls +- [x] Preserved navigation guard/error behavior while reducing runtime event-loop duplication + +## Milestone 28J: Confirmation/Autosave/Helper-Reset Action Extraction +- [x] Added shared helpers for destructive-action confirmation requests, autosave toggle messaging, and helper-field resets +- [x] Replaced duplicated button/hotkey blocks (`New`/`Open` dirty prompts, autosave toggles, helper reset) with helper + `push_status` calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28K: Summary/Paste/Clear Interaction Helper Extraction +- [x] Added shared helpers for summary toggles (supported-screen guard), selected-field clear status, and clipboard paste-to-selected-field +- [x] Replaced duplicated button/hotkey blocks (`summary show/sort`, `clear field`, `Ctrl+V`, `Ctrl+Backspace`) with helper + `push_status` calls +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28L: Export-Format + Autosave-Tick Helper Extraction +- [x] Added shared helper for export-format switching (format update + path sync + dirty/status handling) +- [x] Added shared autosave-tick helper (interval gating, path normalization, save attempt, dirty/save timestamp updates) +- [x] Replaced duplicated export-format button and autosave-loop blocks with helper + `push_status` usage + +## Milestone 28M: Confirm-Resolve + Auto-All-Save Helper Extraction +- [x] Added shared helper to resolve pending destructive confirm actions (`Reset/Open/None`) into a single status path +- [x] Added shared helper for `Auto-All + Save` flow (auto pipeline + export timestamp + save) +- [x] Replaced duplicated confirm-yes switch and auto-save button block with helper + `push_status` usage + +## Milestone 28N: Help/Log-Clear Micro-Helper Extraction +- [x] Added shared helpers for help-overlay toggle/close and action-log clear messaging +- [x] Replaced duplicated help toggle/close and log-clear button/hotkey blocks with helper usage +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28O: Optional-Status Push Helper Extraction +- [x] Added shared `push_status_if_nonempty` helper for optional-message action paths +- [x] Replaced duplicated non-empty checks in summary toggles, clipboard paste, and autosave-tick status flow +- [x] Standardized initial GUI status-log seed via shared status push helper + +## Milestone 28P: Log-View Toggle Helper Extraction +- [x] Added shared helpers for log-view reset/toggle messaging (reset lines/order) +- [x] Replaced duplicated log-view button/hotkey blocks (`LogDef`, `Ctrl+Shift+Z/T/B`) with helper + `push_status` usage +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28Q: Diagnostics/Report Action Helper Consolidation +- [x] Added shared helpers for diagnostics/report write/copy actions and action-log snapshot copy +- [x] Replaced duplicated button/hotkey diagnostics/report/log-copy blocks with helper + `push_status` usage +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28R: Diagnostics Action Context Struct Introduction +- [x] Added `Diagnostics_Action_Context` + builder helper to consolidate wide diagnostics/report argument sets +- [x] Refactored diagnostics/report/log-copy button and hotkey handlers to pass context objects into helpers +- [x] Preserved behavior while improving readability and reducing call-site parameter noise + +## Milestone 28S: Dirty+Status Push Helper Extraction +- [x] Added shared `push_dirty_status` helper for common "mark dirty + push status" action paths +- [x] Replaced duplicated dirty/status blocks across autosave interval presets/hotkeys and path-fix hotkeys/buttons +- [x] Preserved behavior while reducing runtime event-loop duplication + +## Milestone 28T: GUI Helper Test Coverage Expansion +- [x] Added focused tests for extracted non-render GUI helpers (`parse_autosave_interval`, autosave text setter, log-view toggles, export-format switch helper) +- [x] Added focused test for diagnostics action-context builder mapping +- [x] Kept test allocator hygiene by explicitly deleting helper-produced strings in tests + +## Milestone 28U: GUI Helper Test Coverage Expansion II +- [x] Added focused tests for path normalization/fix helpers (`normalize_project_path`, `fix_all_paths`) +- [x] Added focused tests for summary-toggle supported-screen guards and dirty+status push helper behavior +- [x] Resolved test ownership edge case (constant-string bad free) and kept allocator hygiene clean + +## Milestone 28V: GUI Helper Test Coverage Expansion III +- [x] Added focused tests for optional-status push behavior (`push_status_if_nonempty`) and confirmation-request state updates +- [x] Added focused autosave no-op test coverage for disabled/clean-state guard paths (`autosave_tick_with_message`) +- [x] Expanded helper coverage without introducing render/runtime coupling + +## Milestone 28W: GUI Helper Test Coverage Expansion IV +- [x] Added focused tests for log maintenance helpers (`clear_action_log_with_message`, `reset_log_view_with_message`) +- [x] Added focused test for confirm-action resolver `.None` branch behavior +- [x] Increased non-render GUI helper confidence while keeping tests isolated from rendering loop + +## Milestone 28X: GUI Helper Test Coverage Expansion V +- [x] Added focused tests for help-overlay toggle/close helpers and export-path preset/derivation helper flows +- [x] Added focused test for project reset-session helper state effects (dirty reset + screen sync) +- [x] Maintained allocator hygiene in new helper tests (including overwrite/delete ownership handling) + +## Milestone 28Y: GUI Helper Test Coverage Expansion VI +- [x] Added focused tests for autosave toggle messaging and helper-field reset defaults +- [x] Added focused tests for selected-field clear helper behavior (changed vs already-empty paths) +- [x] Expanded non-render helper coverage while keeping full build/test gate green + +## Milestone 28Z: GUI Helper Test Coverage Expansion VII +- [x] Added focused tests for path-health hints/predicates and screen-label helper mapping +- [x] Added focused test for navigation-status helper successful path +- [x] Continued non-render helper coverage growth with allocator-safe test cleanup + +## Milestone 29A: Action-Log Snapshot Memory Hardening + Tests +- [x] Added focused action-log behavior tests (ring-buffer retention and clear/snapshot behavior) +- [x] Fixed `build_action_log_snapshot` temporary-string cleanup to eliminate builder leaks under repeated concatenation +- [x] Kept full build/test gate green with expanded suite + +## Milestone 29B: Diagnostics/Report String Lifecycle Hardening +- [x] Made empty action-log snapshots consistently heap-owned strings for predictable cleanup +- [x] Added explicit cleanup for intermediate diagnostics/report/log-snapshot strings in write/copy helper flows +- [x] Updated helper tests for new snapshot ownership semantics and kept full suite green + +## Milestone 29C: Status Message Ownership Hardening +- [x] Added `set_status` helper and routed `push_status` through owned string replacement semantics +- [x] Standardized GUI runtime status initialization/disposal and converted remaining direct status assignments to owned-status updates +- [x] Updated helper tests to use owned status strings under new lifecycle semantics and kept full suite green + +## Milestone 29D: Action-Log Timestamp Reset Hardening +- [x] Hardened `action_log_dispose` to reset `last_push_at` alongside entries/count state +- [x] Added focused tests for status replacement helper and action-log clear timestamp-reset behavior +- [x] Kept full build/test gate green with expanded helper suite + +## Milestone 29E: Confirm/Open Helper Branch Test Expansion +- [x] Added focused test for `open_project_session` missing-file branch (error propagation + state/timestamp/dirty preservation) +- [x] Added focused test for `resolve_confirm_action_with_message` reset branch dispatch behavior +- [x] Expanded non-render helper branch coverage while keeping full build/test gate green + +## Milestone 29F: Open/Confirm Success-Path Test Expansion +- [x] Added focused success-path tests for `open_project_session` and confirm resolver open-branch dispatch +- [x] Verified loaded-state replacement, dirty reset, export-path sync, and autosave timestamp refresh behavior +- [x] Applied owned-controller disposal in new load-path tests to keep allocator hygiene clean + +## Milestone 29G: Diagnostics/Report Write-Path Test + Leak Hardening +- [x] Added focused tests for diagnostics/report write helpers creating expected output files in temp project dirs +- [x] Hardened diagnostics/report path helper call-sites with explicit path-string cleanup after writes +- [x] Aligned diagnostics write-status assertion to current runtime wording and kept full suite green + +## Milestone 29H: Diagnostics/Open Failure-Path Test Expansion +- [x] Added focused failure-path tests for diagnostics/report write helpers when project directory is missing +- [x] Extended missing-file open-session test to assert project-path normalization side effect +- [x] Kept full build/test gate green with expanded non-render helper branch coverage + +## Milestone 29I: Confirm/Open Path-Behavior Test Tightening +- [x] Strengthened open-session success-path assertions to verify export-path project-directory sync (not just suffix) +- [x] Strengthened confirm-resolver open-branch success assertions with project-directory sync checks +- [x] Added focused confirm-resolver open-branch missing-file test for state/dirty/timestamp preservation + +## Milestone 29J: Autosave Success-Path Helper Test Expansion +- [x] Added focused autosave-tick success-path test validating project write, dirty-clear behavior, and save timestamp update +- [x] Verified autosave project-path normalization behavior under successful save flow +- [x] Kept full build/test gate green with expanded non-render helper coverage + +## Milestone 30A: GUI Aesthetic Foundation Refresh +- [x] Added reusable `draw_card` surface helper with subtle shadow + consistent border treatment +- [x] Applied refreshed scene styling (soft gradient background, tuned sidebar/header separation, unified card surfaces) +- [x] Switched key dashboard/summary/log surfaces to shared card rendering for more consistent visual hierarchy + +## Milestone 30B: GUI Aesthetic Hierarchy Pass +- [x] Added `draw_button_primary` and applied primary styling to high-priority actions (`Next`, `Auto-All`, `Auto-All + Save`) +- [x] Refined base component theming (button/nav/input color tuning, selected input fill treatment) +- [x] Tuned sidebar/headline/recommendation hint color hierarchy for clearer visual emphasis + +## Milestone 30C: GUI Spacing + Sidebar Information Architecture Pass +- [x] Replaced dense sidebar hotkey wall with grouped shortcut cards via `draw_sidebar_shortcuts` +- [x] Improved shortcut readability through sectioned labels (`Workflow`, `Tools`) and tighter typography rhythm +- [x] Preserved full help-overlay detail while reducing main-view visual noise + +## Milestone 30D: Main-Canvas Spacing + Section Framing Pass +- [x] Added framed card surfaces for `Project Inputs` and `Actions` zones to improve visual grouping on the main canvas +- [x] Tuned field-label typography scale and hierarchy (18px labels, section headings, less noisy tip/editing treatments) +- [x] Repositioned inline editing indicator and tightened supporting copy emphasis for cleaner rhythm + +## Milestone 30E: Overlay + Log/Status Readability Polish +- [x] Refined help and confirm overlays with stronger contrast layering, shared card surfaces, and calmer section typography +- [x] Improved action-log readability with subtle alternating row backgrounds and tuned log text colors +- [x] Tuned status/log metadata typography sizing and color balance for cleaner dashboard scanning + +## Milestone 30F: Action-Zone Micro-Hierarchy Polish +- [x] Added reusable `draw_hint_pill` helper for compact contextual guidance chips +- [x] Replaced noisy inline editing text with structured hint pills (`field focus`, safety hint, next/auto hint) +- [x] Added subtle status-header divider line to improve status card scan rhythm + +## Milestone 30G: Confirm/Toast Emphasis Polish +- [x] Added `draw_button_danger` style and applied it to destructive confirm CTA in modal overlay +- [x] Enhanced toast visual treatment with subtle shadow and border for clearer transient message legibility +- [x] Preserved behavior while improving visual emphasis on high-risk actions and transient feedback + +## Milestone 30H: Status Card Badge + Micro-Readability Pass +- [x] Added reusable `draw_status_badge` helper for compact status chips +- [x] Replaced plain dirty/autosave status text with badge-style indicators for faster visual parsing +- [x] Fine-tuned status-card spacing/metadata alignment to maintain clean scan rhythm + +## Milestone 30I: Utility Control Density + Visual Weight Pass +- [x] Added reusable `draw_small_button` helper for compact utility controls +- [x] Shifted low-priority utility controls (path tools, autosave presets, summary toggles, log toolbar) to compact button treatment +- [x] Updated log-toolbar labels for improved clarity while reducing visual heaviness + +## Milestone 30J: Header Context Chip Polish +- [x] Replaced plain top-header active-screen text with contextual hint-chip treatment for clearer glanceability +- [x] Added compact project-pages context chip in header row to reinforce current workflow context +- [x] Tuned header typography weight/size balance while preserving existing field-edit behavior + +## Milestone 30K: Sidebar/Nav Framing + Tip Presentation Polish +- [x] Added framed card surface around the screen-navigation cluster to improve left-rail structure +- [x] Replaced bottom plain-text caution tip with accented hint-pill treatment for visual consistency +- [x] Preserved behavior while reducing loose text noise in the primary canvas + +## Milestone 30L: Section Title Rhythm + Log Header Consolidation +- [x] Added reusable `draw_section_title` helper for consistent section-heading styling +- [x] Applied section-title treatment to key canvas regions (`Project Inputs`, `Actions`, `Action Log`) +- [x] Simplified action-log renderer by removing duplicate internal title drawing for cleaner hierarchy + +## Milestone 30M: Secondary Action Visual Tiering +- [x] Added `draw_button_soft_accent` helper for medium-priority actions +- [x] Applied soft-accent treatment to Save/Open/Help controls to better separate them from neutral and primary CTAs +- [x] Preserved behavior while improving action hierarchy legibility + +## Milestone 30N: Status/Log Metadata Pill Polish +- [x] Replaced dense status metadata lines with compact save/export/path pill treatments for cleaner glanceability +- [x] Added path-health badge stack (`P`/`E`) and accented inline path-fix hint pill in status card +- [x] Added compact log-toolbar shortcut hint pill to improve discoverability without visual clutter + +## Milestone 30O: Topbar Container + Chip Refinement +- [x] Added framed topbar container card to visually anchor header context controls +- [x] Added dedicated `draw_topbar_chip` helper for refined topbar context pills +- [x] Updated header chip placements/styles for cleaner alignment and improved glance readability + +## Milestone 30P: Sidebar Brand/Footer Cohesion Pass +- [x] Added framed sidebar brand card and section-title divider rhythm for a cleaner left-rail header +- [x] Added compact sidebar footer support card (`/` help reminder + overlay close hint) +- [x] Preserved behavior while improving left-rail visual cohesion and onboarding clarity + +## Milestone 30Q: Action/Log Toolbar Surface Unification +- [x] Added reusable `draw_subtle_strip` helper for lightweight grouped-control backgrounds +- [x] Applied subtle strip treatment to action-row and action-log toolbar regions for stronger grouping without heavy chrome +- [x] Preserved behavior while improving scan rhythm across high-density control zones + +## Milestone 30R: Destructive-Action Visual Cue Pass +- [x] Added `draw_button_warning` helper for low-intensity destructive/irreversible action emphasis +- [x] Applied warning-style treatment to `New` action in the primary action row to better signal reset risk +- [x] Preserved behavior while improving safety affordance visibility + +## Milestone 30S: Active Navigation Marker Polish +- [x] Enhanced active sidebar nav item styling with a subtle marker dot and adjusted label offset +- [x] Preserved existing hover/active behavior while improving active-screen discoverability +- [x] Kept full build/test gate green + +## Milestone 30T: Micro-Copy Pill Consistency Pass +- [x] Replaced plain helper micro-copy near export format/path controls with compact hint-pill treatments +- [x] Replaced summary toggle hotkey plain text with compact hint-pill treatment for visual consistency +- [x] Preserved behavior while reducing scattered plain-text noise + +## Milestone 30U: Log Toolbar Layout Alignment Pass +- [x] Re-aligned log toolbar button geometry to fit cleanly within the action-log card bounds +- [x] Tightened toolbar label lengths and shortcut-hint placement to reduce overlap risk in dense log controls +- [x] Preserved behavior while improving right-panel visual alignment + +## Milestone 30V: Status Card Density Rebalance +- [x] Consolidated save/export/path metadata into a single compact status line inside the status card bounds +- [x] Removed overflow-prone stacked metadata pills from the status region to reduce visual crowding +- [x] Preserved behavior while improving status-card containment and scan clarity + +## Milestone 30W: Input-Row Rhythm Surface Pass +- [x] Applied subtle strip surfaces behind each primary input row in the `Project Inputs` region +- [x] Extended strip treatment to path row + action row for stronger vertical rhythm continuity +- [x] Preserved behavior while improving form scanability and visual cadence + +## Milestone 30X: Utility/Modal Secondary Emphasis Pass +- [x] Applied soft-accent visual tier to helper controls (`Reset Helpers`, autosave toggle) for clearer control grouping +- [x] Applied soft-accent styling to confirm modal `Cancel` action for stronger destructive/non-destructive contrast +- [x] Preserved behavior while improving action hierarchy readability + +## Milestone 30Y: Focus/Affordance Refinement Pass +- [x] Enhanced selected-input affordance with subtle outer halo + focus border treatment for clearer field focus +- [x] Extended active navigation cueing with a slim active rail marker inside selected sidebar items +- [x] Preserved behavior while improving interaction-state discoverability + +## Milestone 30Z: Primary Action Guidance Chip Pass +- [x] Replaced plain recommended/hint text near Next/Auto controls with consistent hint-pill treatment +- [x] Added compact key-hint pills (`F9 Next`, `F10 Auto`) under primary action buttons for faster keyboard discoverability +- [x] Preserved behavior while improving action-guidance visual consistency + +## Milestone 31A: UI Text-Overflow Resilience Pass +- [x] Added shared `fit_text_for_width` helper for width-aware truncation with ellipsis +- [x] Applied overflow-safe text rendering to hint/topbar/status badges and toast surface text +- [x] Applied status-line overflow-safe rendering in status card to prevent long-message spillover + +## Milestone 31B: Button Label Overflow Resilience Pass +- [x] Applied width-aware label fitting across button styles (default/primary/danger/warning/soft-accent + disabled states) +- [x] Applied width-aware label fitting for compact utility buttons in enabled/disabled states +- [x] Preserved behavior while preventing long labels from overflowing high-density control regions + +## Milestone 31C: Nav/Input Overflow Resilience Pass +- [x] Applied width-aware label fitting to sidebar nav items (including active-state offset handling) +- [x] Applied non-editing input-field text fitting to prevent long values from spilling outside input surfaces +- [x] Preserved editing ergonomics by keeping full-value rendering while an input is actively selected + +## Milestone 31D: Sidebar Shortcut Overflow Resilience Pass +- [x] Added `draw_sidebar_shortcut_line` helper with width-aware text fitting for left-rail shortcut copy +- [x] Replaced direct sidebar shortcut text draws with overflow-safe helper usage +- [x] Preserved shortcut content while improving resilience to long copy and future wording changes + +## Milestone 31E: Help Overlay Overflow Resilience Pass +- [x] Added `draw_help_line` helper with width-aware text fitting for help-overlay body copy +- [x] Replaced direct long shortcut lines in help overlay with overflow-safe helper usage +- [x] Preserved content/structure while improving resilience to future shortcut-copy growth + +## Milestone 31F: Action Log Line Overflow Resilience Pass +- [x] Added width-aware fitting for rendered action-log lines (including timestamp prefix) +- [x] Prevented long log entries from spilling past right-panel bounds in compact log view +- [x] Preserved log semantics while improving dense-session readability + +## Milestone 31G: Summary/Status Metadata Overflow Resilience Pass +- [x] Added `draw_summary_line` helper and applied overflow-safe rendering to long dynamic summary lines (genre/audience/title/export target/block reason) +- [x] Applied overflow-safe rendering to status-card pipeline/next and metadata footer lines +- [x] Preserved behavior while improving resilience under long project names/paths/messages + +## Milestone 31H: Overlay/Layout-Row Overflow Resilience Pass +- [x] Applied overflow-safe fitting to confirm-overlay action sentence to guard long action labels/future copy changes +- [x] Applied overflow-safe summary-line rendering for layout pattern rows (`- P#: pattern (n)`) in both asc/desc views +- [x] Applied overflow-safe fitting to log-view metadata line (`View: n lines, order first`) in utility bar + +## Milestone 31I: Chip Micro-Overflow Resilience Pass +- [x] Applied width-aware fitting to compact stat-chip label/value rendering (`draw_stat_chip`) for narrow chip bounds +- [x] Applied width-aware fitting to readiness-chip line rendering (`draw_readiness_chip`) using chip width argument +- [x] Preserved chip semantics while preventing crowding/spill in compact telemetry surfaces + +## Milestone 31J: Screen Summary Uniform Overflow Pass +- [x] Expanded overflow-safe summary rendering across remaining dynamic Screen Summary lines (story/script/panels/layout/bubbles/export) +- [x] Replaced direct dynamic DrawText formatting with `draw_summary_line` for consistent truncation behavior +- [x] Preserved summary semantics while improving resilience under high-count data and long formatted values + +## Milestone 31K: Summary Helper Consistency Pass +- [x] Applied width-aware fitting to section-title labels for tighter heading-bound resilience +- [x] Added `draw_summary_subline` helper for 16px secondary summary text with consistent width fitting +- [x] Replaced export "Last layout pattern" subline rendering with helper usage to reduce one-off formatting logic + +## Milestone 31L: Fitted-Text Helper Consolidation Pass +- [x] Added shared `draw_text_fitted` helper to unify fit+draw behavior and reduce repeated truncation boilerplate +- [x] Refactored section-title/summary/help/sidebar shortcut helpers to use the consolidated text-fit renderer +- [x] Preserved behavior while reducing one-off fitting/draw patterns and tightening future maintenance seams + +## Milestone 31M: Button Text Renderer Consolidation Pass +- [x] Refactored all primary button draw variants to use shared `draw_text_fitted` (default/primary/danger/warning/soft-accent) +- [x] Refactored enabled/disabled compact button text rendering and disabled-state standard button rendering to use shared helper +- [x] Preserved behavior while reducing repeated fit+draw branches in high-touch control rendering paths + +## Milestone 31N: Surface/Chip Text Renderer Consolidation Pass +- [x] Refactored nav-item labels and non-selected input text rendering to use shared `draw_text_fitted` +- [x] Refactored hint/topbar/status chips plus stat/readiness chip text rendering to use shared helper +- [x] Preserved behavior while reducing repeated fit+draw fragments across non-button UI surfaces + +## Milestone 31O: Status/Log Fitted-Text Consolidation Pass +- [x] Refactored action-log row rendering and toast text rendering to use shared `draw_text_fitted` +- [x] Refactored confirm-overlay action sentence and status-card text rows (status/pipeline/next/meta) to use shared helper +- [x] Refactored log utility metadata line (`View: n lines, order first`) to use shared helper while preserving existing copy/behavior + +## Milestone 31P: Text-Fit Helper Test Coverage Pass +- [x] Added direct helper tests for `fit_text_for_width` truncation behavior (ellipsis + minimum-width rule) +- [x] Added helper passthrough tests for no-truncation and `px_per_char <= 0` paths +- [x] Revalidated full suite after test additions (76 passing) + +## Milestone 32A: GUI File Split Kickoff (Text Helpers) +- [x] Started structural decomposition of `src/gui/runtime.odin` by extracting text-fit helpers into `src/gui/text_helpers.odin` +- [x] Moved `fit_text_for_width` and `draw_text_fitted` into the new module without behavior change +- [x] Revalidated full build/test gate after file split (76 passing) + +## Milestone 32B: GUI File Split (Widget Primitives) +- [x] Extracted shared widget primitives from `src/gui/runtime.odin` into `src/gui/widgets.odin` +- [x] Moved card/strip/chip/section-summary draw helpers (`draw_card`, `draw_subtle_strip`, `draw_hint_pill`, `draw_topbar_chip`, `draw_status_badge`, `draw_section_title`, `draw_summary_line`, `draw_summary_subline`) +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32C: GUI File Split (Overlays + Log Views) +- [x] Extracted overlay/log rendering helpers from `src/gui/runtime.odin` into `src/gui/overlays.odin` +- [x] Moved action-log/toast/status-color + help/sidebar/confirm overlay draw helpers without behavior changes +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32D: GUI File Split (Summary + Readiness Views) +- [x] Extracted summary/readiness rendering helpers from `src/gui/runtime.odin` into `src/gui/summary_views.odin` +- [x] Moved status progress/readiness helpers and screen summary rendering (`draw_stat_chip`, `draw_readiness_chip`, `ready_stage_count`, `draw_progress_bar`, `draw_readiness_row`, `export_block_reason`, `draw_screen_summary`) +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32E: GUI File Split (Controls) +- [x] Extracted control rendering helpers from `src/gui/runtime.odin` into `src/gui/controls.odin` +- [x] Moved button/nav/input primitives and control helpers (`button_clicked`, button variants, `draw_small_button*`, `button_readiness_hint`, `draw_button_recommended`, `draw_nav_item`, `draw_input_field`) +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32F: GUI File Split (Path + Export Helper Layer) +- [x] Extracted path/export normalization and sync helpers from `src/gui/runtime.odin` into `src/gui/path_helpers.odin` +- [x] Moved format/path utility + path-message helpers (`format_suffix`, normalization helpers, preset/sync/fix helpers, path-health checks) +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32G: GUI File Split (Diagnostics + Report Helpers) +- [x] Extracted diagnostics/report helper layer from `src/gui/runtime.odin` into `src/gui/diagnostics.odin` +- [x] Moved diagnostics snapshot/report builders, context struct/builder, diagnostics/report file writers, and clipboard snapshot helpers +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32H: GUI File Split (Session + Status Helpers) +- [x] Extracted session/status/helper-state layer from `src/gui/runtime.odin` into `src/gui/session_helpers.odin` +- [x] Moved status/log push helpers, navigation/confirmation toggles, selected-field clear/paste helpers, and `Action_Log` lifecycle helpers +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32I: GUI File Split (Workflow + Action Helpers) +- [x] Extracted workflow/action helper layer from `src/gui/runtime.odin` into `src/gui/actions.odin` +- [x] Moved generation/layout/export runners, next/auto-all helpers, summary toggle helpers, interval parsers, and autosave/session action helpers +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32J: GUI File Split (Local Generation + Utility Helpers) +- [x] Extracted local generation and utility helper layer from `src/gui/runtime.odin` into `src/gui/local_helpers.odin` +- [x] Moved local script/panel builders, panel collection/count helpers, text append/pop helpers, recommended-label helper, and pending-action label helper +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32K: GUI File Split (Shared Types) +- [x] Extracted shared GUI types from `src/gui/runtime.odin` into `src/gui/types.odin` +- [x] Moved `Summary_View_Options` and `Pending_Confirm_Action` declarations to the dedicated types module +- [x] Revalidated full build/test gate after split (76 passing) + +## Milestone 32L: Runtime Import Cleanup Checkpoint +- [x] Removed stale `runtime.odin` imports left behind after module extraction (`os`, `filepath`, `strconv`, `adapters`) +- [x] Kept runtime import surface aligned to active dependencies only (`fmt`, `strings`, `raylib`, `core`, `shared`, `ui`) +- [x] Revalidated full build/test gate after cleanup (76 passing) + +## Milestone 32M: Fullscreen Startup Mode +- [x] Updated GUI runtime startup to size window to current monitor and toggle fullscreen on launch +- [x] Verified build + GUI launch path with fullscreen initialization +- [x] Revalidated build/test gate (`./build.sh`, `odin test tests`) + +## Milestone 33A: Visual Redesign (Modern Theme Pass) +- [x] Refreshed global shell styling with richer gradients, soft ambient highlights, and cleaner sidebar/topbar contrast +- [x] Modernized core surfaces (cards/strips/chips) and controls (buttons/nav/input focus) with updated radii, shadows, and contemporary palette tuning +- [x] Refined UI microcopy labels in primary form regions for cleaner modern presentation while preserving behavior + +## Milestone 33B: Layout Rhythm + Typography Pass +- [x] Refined section heading typography/underline treatment for stronger hierarchy and cleaner modern framing +- [x] Tuned topbar/input/action/status/log panel vertical rhythm (card heights, heading baselines, content offsets) for more consistent spacing cadence +- [x] Preserved behavior while improving visual balance and readability in dense dashboard regions + +## Milestone 33C: Visual Course-Correction (Clean Minimal Pass) +- [x] Rebalanced redesign styling toward a cleaner minimal look (reduced visual noise, flatter shell treatment, calmer surface/controls palette) +- [x] Reverted over-aggressive spacing/offset shifts in topbar/status/log regions to restore stable layout rhythm while keeping modern polish +- [x] Revalidated full build/test gate after visual corrections (76 passing) + +## Milestone 33D: Clutter Reduction + Readability Pass +- [x] Reduced dashboard visual clutter by removing non-essential row-strip treatments and duplicate key-hint pills in the action region +- [x] Consolidated top helper guidance into a single compact hint rail and simplified field-focus copy +- [x] Improved form label readability with lighter typography weight/size and calmer color tone while preserving behavior + +## Milestone 33E: Minimalist Visual Reset Pass +- [x] Flattened shell styling to a cleaner neutral canvas (removed decorative gradients/highlights and reduced ornamental contrast) +- [x] Simplified card/strip component treatment to low-noise borders with restrained depth for a more professional utility-tool look +- [x] Rebalanced primary/nav palette saturation and corner radii for calmer modern controls without changing behavior + +## Milestone 33F: Linear-like Dark Crisp Theme Pass +- [x] Shifted shell/surfaces/controls to a dark neutral palette with crisp blue accents for primary/active affordances +- [x] Updated summary/overlay/log text and chip/readiness/status color treatment for dark-background readability and contrast +- [x] Revalidated full build/test gate after dark-theme pass (76 passing) + +## Milestone 33G: Fullscreen Reliability Follow-up +- [x] Updated GUI window startup flow to maximize on launch and enforce borderless-windowed state for better full-screen coverage behavior +- [x] Preserved existing rendering/input behavior while improving startup window mode handling on desktop +- [x] Revalidated full build/test gate after window-mode adjustments (76 passing) + +## Milestone 33H: Fullscreen Utilization + Dark Theme Refinement +- [x] Added runtime full-screen setup verification and adaptive width usage in the main dashboard layout (`main_w_loop`/status-log split) to better use high-resolution displays +- [x] Centered/anchored overlays and confirm modal to dynamic screen dimensions (no fixed 1240x820 dimming bounds) +- [x] Revalidated build/test gate and launch path (`GUI window size after setup: 1920x1080`, 76 tests passing) + +## Milestone 33I: Vertical Responsiveness + Overlay Anchoring Pass +- [x] Added adaptive vertical anchoring for lower dashboard regions (status/summary/action-log blocks now track `screen_h` via `lower_y_loop` baseline) +- [x] Updated sidebar shortcut stacks and overlay/modal placement to derive positions from runtime screen height instead of fixed 820-based coordinates +- [x] Revalidated build/test gate and runtime launch diagnostics (`GUI window size after setup: 1920x1080`, 76 tests passing) + +## Milestone 33J: Overlap Cleanup + Debug Noise Removal +- [x] Removed temporary runtime window-size debug print after fullscreen verification +- [x] Repositioned summary controls/hint rail and bottom tip pill to dynamic lower-region anchors to avoid fixed-position overlap on tall screens +- [x] Revalidated full build/test gate after overlap cleanup (76 passing) + +## Milestone 33K: Overlap Guard Sweep +- [x] Added lower-region baseline clamp (`lower_y_loop >= 568`) to prevent top/lower panel collisions when runtime height shrinks +- [x] Revalidated full build/test gate and GUI launch path after overlap guard adjustment +- [x] Confirmed no regressions in automated suite (76 passing) + +## Milestone 33L: Export Path Robustness Fix +- [x] Added export output parent-directory creation in adapter layer (`ensure_export_output_parent_dir`) before file write/export execution +- [x] Eliminated `write pdf: Not_Exist` failures for nested/non-existent export directories during GUI/TUI quick-local flows +- [x] Revalidated gate and manual nested-path quick-local run (76 tests passing) + +## Milestone 33M: 1366x768 Layout Guard Pass +- [x] Relaxed lower-dashboard baseline clamp (`lower_y_loop >= 430`) to avoid bottom-edge clipping on shorter displays while preserving separation from top region +- [x] Updated sidebar shortcut stack minimum anchor (`base_y >= 120`) to keep left-rail cards inside viewport on low-height screens +- [x] Revalidated full build/test gate after low-height guard adjustments (76 passing) + +## Milestone 33N: Compact Mode Hint Suppression +- [x] Added compact-mode switch (`screen_h < 860`) in GUI runtime layout loop +- [x] Suppressed non-essential hint rails/pills in compact mode (field-focus rail, extension helper pills, summary/log shortcut hint pills, bottom tip rail) +- [x] Revalidated full build/test gate after compact-mode behavior update (76 passing) + +## Milestone 34: Script Results Visibility (GUI) +- [x] 34A: Script summary card promoted to page inspector with real per-page panel content previews +- [x] 34B: Add script page navigation controls (prev/next buttons + keyboard) +- [x] 34C: Add dedicated script detail pane (full-page text/dialogue surface) +- [x] 34D: Add script utility actions (copy visible page, copy full script) +- [x] 34F: Add GUI script-source toggle (Local/DeepSeek) with key-presence guard and next/auto-all integration +- [ ] 34E: Validation pass for script view at 1366x768 / 1920x1080 / ultrawide + +## Milestone 35: Panels Results Visibility (GUI) +- [x] 35A: Add panel gallery/list with panel metadata/status in dedicated Panels detail surface +- [x] 35B: Show panel generation health states (ready/missing/error) with retry actions +- [x] 35C: Add panel detail inspector (prompt, seed, dimensions, source URL/path) +- [x] 35D: Add panel pagination/virtualized list behavior for large projects + +## Milestone 36: Layout Results Visibility (GUI) +- [ ] 36A: Add page layout visual preview cards with panel cell geometry +- [ ] 36B: Add per-page selector and pattern metadata side rail +- [ ] 36C: Add layout validation badges (coverage, missing panel bindings, bounds) + +## Milestone 37: Bubble Editing MVP (GUI) +- [ ] 37A: Add bubble list per selected panel/page +- [ ] 37B: Add create/edit/delete bubble text/type/speaker controls +- [ ] 37C: Persist bubble edits to project save/load/export path + +## Milestone 38: Responsive System + Theme Tokens +- [x] 38A: Extract screen-size profiles (compact/standard/wide) into shared layout constants +- [x] 38B: Remove remaining hardcoded geometry hotspots from runtime orchestrator +- [x] 38C: Normalize semantic color tokens across widgets/controls/overlays +- [x] 38D: Final contrast/readability QA for dark crisp theme + +## Milestone 39: Reliability + Test Hardening +- [ ] 39A: Add GUI integration smoke tests for full local flow + save/open/export +- [ ] 39B: Add error-path tests (missing assets, invalid paths, blocked export) +- [ ] 39C: Expand ownership/lifecycle audits for new GUI state surfaces + +## Milestone 40: Release Packaging + Production Docs +- [ ] 40A: Ship-ready package artifacts + checksums + version stamping +- [ ] 40B: GUI user guide (workflow, shortcuts, troubleshooting) +- [ ] 40C: Production release checklist and known-issues policy + +## Milestone 34A: Script Inspector Upgrade (initial pass) +- [x] Upgraded Script screen summary into a real page inspector view (selected page, panel previews, first-dialogue snippets) +- [x] Added script page cursor state to GUI summary options with runtime clamping +- [x] Revalidated build/test gate after Script inspector upgrade (76 passing) + +## Milestone 34B: Script Page Navigation Controls (initial pass) +- [x] Added Script summary prev/next page buttons (`< Pg`, `Pg >`) in lower control rail +- [x] Added keyboard navigation (`Ctrl+[`, `Ctrl+]`) with wrap-around behavior and status/log feedback +- [x] Revalidated full build/test gate after navigation control wiring (76 passing) + +## Milestone 34C: Script Detail Pane (initial pass) +- [x] Added dedicated Script detail panel in lower-right workspace showing selected page panel descriptions and dialogue snippets +- [x] Kept action-log surface on non-Script screens while using Script-specific detail rendering on Script screen +- [x] Revalidated full build/test gate after detail-pane integration (76 passing) + +## Milestone 34D: Script Copy Utilities (initial pass) +- [x] Added Script-only copy actions (`Copy Page`, `Copy All`) in detail panel header +- [x] Added text builders for selected-page detail and full-script snapshots +- [x] Revalidated full build/test gate after copy-action wiring (76 passing) + +## Milestone 35A: Panels Detail Surface (initial pass) +- [x] Added Panels-focused detail panel in lower-right workspace with selected-panel metadata and readiness state +- [x] Added panel navigation controls (`< Pn`, `Pn >`) and keyboard navigation (`Ctrl+[`, `Ctrl+]`) with wrap-around + status feedback +- [x] Added compact panel list rows (selected marker, page/panel index, ready/missing state) for quick inspection +- [x] Revalidated full build/test gate after panels visibility integration (76 passing) + +## Milestone 34F: GUI Script Source Toggle (Local/DeepSeek) +- [x] Added script-source selector controls in GUI (`Local`, `DeepSeek`) plus keyboard toggle (`Ctrl+G`) +- [x] Added DeepSeek key guard on toggle/action (`DEEPSEEK_API_KEY` required) with explicit status feedback +- [x] Wired source mode into Script action and guided Next/Auto-All/Auto-All+Save flow decisions +- [x] Added topbar script-source status badge with live key availability (`Script: (key:yes|missing)`) and inline key-missing helper text +- [x] Improved DeepSeek HTTP error surfacing to include provider message text in GUI/CLI failures +- [x] Hardened DeepSeek request-body serialization using typed JSON marshal (instead of manual string assembly) to prevent malformed request payloads +- [x] Expanded DeepSeek response parser compatibility for multiple JSON shapes (camelCase, snake_case, and wrapped `script` payloads) +- [x] Added DeepSeek normalization resilience: if provider content parses but fails minimal schema, auto-fallback to deterministic script instead of hard-fail +- [x] Fixed normalization-owned string safety for default title/synopsis values to avoid invalid frees and downstream autosave crashes +- [x] Revalidated full build/test gate and GUI launch smoke after script-source toggle integration (76 passing) diff --git a/odin/generated/demo_comic.pdf b/odin/generated/demo_comic.pdf new file mode 100644 index 0000000..26560bb --- /dev/null +++ b/odin/generated/demo_comic.pdf @@ -0,0 +1,27 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 12 Tf 50 780 Td (Comic Export - Panels: 4) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/odin/generated/demo_project.comic.json b/odin/generated/demo_project.comic.json new file mode 100644 index 0000000..b3f212e --- /dev/null +++ b/odin/generated/demo_project.comic.json @@ -0,0 +1,296 @@ +{ + "schemaVersion": 1, + "assetCacheDir": "generated/assets", + "state": { + "project": { + "project_id": "proj_todo", + "project_name": "Untitled Comic", + "created_at_iso": "", + "last_modified_iso": "" + }, + "user_mode": 0, + "story_idea": "A neon detective cat investigates a data-heist in a rain-soaked cyberpunk city", + "story_genre": "Cyberpunk Noir", + "target_audience": "Teens and Adults", + "art_style": "manga", + "script": { + "title": "Local Script", + "synopsis": "A neon detective cat investigates a data-heist in a rain-soaked cyberpunk city", + "characters": [ + { + "id": "char_001", + "name": "Protagonist", + "role": 0, + "description": "Main character", + "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": 0, + "color_palette": { + "hair": "", + "eyes": "", + "skin": "", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "" + } + ], + "pages": [ + { + "page_number": 1, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_local_001", + "panel_number": 1, + "shot_type": 2, + "description": "A neon detective cat investigates a data-heist in a rain-soaked cyberpunk city", + "characters_present": [ + "char_001" + ], + "dialogue": [ + { + "speaker_id": "char_001", + "text": "Let's do this.", + "bubble_type": 0, + "emotion": "neutral" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + }, + { + "page_number": 2, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_local_002", + "panel_number": 1, + "shot_type": 2, + "description": "A neon detective cat investigates a data-heist in a rain-soaked cyberpunk city", + "characters_present": [ + "char_001" + ], + "dialogue": [ + { + "speaker_id": "char_001", + "text": "Let's do this.", + "bubble_type": 0, + "emotion": "neutral" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + }, + { + "page_number": 3, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_local_003", + "panel_number": 1, + "shot_type": 2, + "description": "A neon detective cat investigates a data-heist in a rain-soaked cyberpunk city", + "characters_present": [ + "char_001" + ], + "dialogue": [ + { + "speaker_id": "char_001", + "text": "Let's do this.", + "bubble_type": 0, + "emotion": "neutral" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + }, + { + "page_number": 4, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_local_004", + "panel_number": 1, + "shot_type": 2, + "description": "A neon detective cat investigates a data-heist in a rain-soaked cyberpunk city", + "characters_present": [ + "char_001" + ], + "dialogue": [ + { + "speaker_id": "char_001", + "text": "Let's do this.", + "bubble_type": 0, + "emotion": "neutral" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + } + ] + }, + "characters": [ + { + "id": "char_001", + "name": "Protagonist", + "role": 0, + "description": "Main character", + "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": 0, + "color_palette": { + "hair": "", + "eyes": "", + "skin": "", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "" + } + ], + "panel_images": { + "panel_local_004": { + "url": "file:///tmp/comic-local-panels-9713917116/panel_004_panel_local_004.png", + "width": 1024, + "height": 1024, + "seed": 4, + "prompt": "local" + }, + "panel_local_001": { + "url": "file:///tmp/comic-local-panels-9713917116/panel_001_panel_local_001.png", + "width": 1024, + "height": 1024, + "seed": 1, + "prompt": "local" + }, + "panel_local_003": { + "url": "file:///tmp/comic-local-panels-9713917116/panel_003_panel_local_003.png", + "width": 1024, + "height": 1024, + "seed": 3, + "prompt": "local" + }, + "panel_local_002": { + "url": "file:///tmp/comic-local-panels-9713917116/panel_002_panel_local_002.png", + "width": 1024, + "height": 1024, + "seed": 2, + "prompt": "local" + } + }, + "page_layouts": [ + { + "page_number": 1, + "pattern_id": "grid-2x2", + "panels": [ + { + "panel_id": "panel_local_001", + "panel_number": 1, + "layout_cell": { + "x": 0.02000000, + "y": 0.02000000, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_local_002", + "panel_number": 1, + "layout_cell": { + "x": 0.50999999, + "y": 0.02000000, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_local_003", + "panel_number": 1, + "layout_cell": { + "x": 0.02000000, + "y": 0.50999999, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_local_004", + "panel_number": 1, + "layout_cell": { + "x": 0.50999999, + "y": 0.50999999, + "w": 0.47000000, + "h": 0.47000000 + } + } + ], + "width": 2480, + "height": 3508 + } + ], + "speech_bubbles": { + + }, + "export_format": 0, + "page_size": 0, + "color_profile": 0, + "workflow": { + "current_step": 7, + "completed_steps": [ + + ], + "is_generating": false, + "generation_progress": 0.00000000, + "error_message": "" + } + } +} \ No newline at end of file diff --git a/odin/generated/nested/demo.pdf b/odin/generated/nested/demo.pdf new file mode 100644 index 0000000..0fbf063 --- /dev/null +++ b/odin/generated/nested/demo.pdf @@ -0,0 +1,27 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 12 Tf 50 780 Td (Comic Export - Panels: 2) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/odin/generated/nested/demo_project.comic.json b/odin/generated/nested/demo_project.comic.json new file mode 100644 index 0000000..afa87ef --- /dev/null +++ b/odin/generated/nested/demo_project.comic.json @@ -0,0 +1,206 @@ +{ + "schemaVersion": 1, + "assetCacheDir": "generated/nested/assets", + "state": { + "project": { + "project_id": "proj_todo", + "project_name": "Untitled Comic", + "created_at_iso": "", + "last_modified_iso": "" + }, + "user_mode": 0, + "story_idea": "", + "story_genre": "action", + "target_audience": "general", + "art_style": "manga", + "script": { + "title": "Local Script", + "synopsis": "A local adventure", + "characters": [ + { + "id": "char_001", + "name": "Protagonist", + "role": 0, + "description": "Main character", + "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": 0, + "color_palette": { + "hair": "", + "eyes": "", + "skin": "", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "" + } + ], + "pages": [ + { + "page_number": 1, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_local_001", + "panel_number": 1, + "shot_type": 2, + "description": "A local adventure", + "characters_present": [ + "char_001" + ], + "dialogue": [ + { + "speaker_id": "char_001", + "text": "Let's do this.", + "bubble_type": 0, + "emotion": "neutral" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + }, + { + "page_number": 2, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_local_002", + "panel_number": 1, + "shot_type": 2, + "description": "A local adventure", + "characters_present": [ + "char_001" + ], + "dialogue": [ + { + "speaker_id": "char_001", + "text": "Let's do this.", + "bubble_type": 0, + "emotion": "neutral" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + } + ] + }, + "characters": [ + { + "id": "char_001", + "name": "Protagonist", + "role": 0, + "description": "Main character", + "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": 0, + "color_palette": { + "hair": "", + "eyes": "", + "skin": "", + "outfit": "" + }, + "appearance_count": 0, + "first_appearance_panel": "" + } + ], + "panel_images": { + "panel_local_001": { + "url": "file:///tmp/comic-local-panels-0459413800/panel_001_panel_local_001.png", + "width": 1024, + "height": 1024, + "seed": 1, + "prompt": "local" + }, + "panel_local_002": { + "url": "file:///tmp/comic-local-panels-0459413800/panel_002_panel_local_002.png", + "width": 1024, + "height": 1024, + "seed": 2, + "prompt": "local" + } + }, + "page_layouts": [ + { + "page_number": 1, + "pattern_id": "grid-2x2", + "panels": [ + { + "panel_id": "panel_local_001", + "panel_number": 1, + "layout_cell": { + "x": 0.02000000, + "y": 0.02000000, + "w": 0.47000000, + "h": 0.47000000 + } + }, + { + "panel_id": "panel_local_002", + "panel_number": 1, + "layout_cell": { + "x": 0.50999999, + "y": 0.02000000, + "w": 0.47000000, + "h": 0.47000000 + } + } + ], + "width": 2480, + "height": 3508 + } + ], + "speech_bubbles": { + + }, + "export_format": 0, + "page_size": 0, + "color_profile": 0, + "workflow": { + "current_step": 7, + "completed_steps": [ + + ], + "is_generating": false, + "generation_progress": 0.00000000, + "error_message": "" + } + } +} \ No newline at end of file diff --git a/odin/gui_export.pdf b/odin/gui_export.pdf new file mode 100644 index 0000000..0fbf063 --- /dev/null +++ b/odin/gui_export.pdf @@ -0,0 +1,27 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 12 Tf 50 780 Td (Comic Export - Panels: 2) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/odin/gui_project.comic.json b/odin/gui_project.comic.json new file mode 100644 index 0000000..f706e82 --- /dev/null +++ b/odin/gui_project.comic.json @@ -0,0 +1,387 @@ +{ + "schemaVersion": 1, + "assetCacheDir": "assets", + "state": { + "project": { + "project_id": "proj_todo", + "project_name": "Untitled Comic", + "created_at_iso": "", + "last_modified_iso": "" + }, + "user_mode": 0, + "story_idea": "two balls rolling under the sun", + "story_genre": "action", + "target_audience": "general", + "art_style": "manga", + "script": { + "title": "Rolling Duel", + "synopsis": "Generated comic synopsis", + "characters": [ + + ], + "pages": [ + { + "page_number": 1, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_001_001", + "panel_number": 1, + "shot_type": 2, + "description": "A blazing sun dominates the sky, casting harsh light on a vast, empty desert. Two small dots in the distance kick up dust.", + "characters_present": [ + + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_001_002", + "panel_number": 2, + "shot_type": 2, + "description": "Close-up on two balls: one red with a fiery pattern, one blue with a water-like swirl. They are rolling fast, side by side. Cracks form in the ground beneath them.", + "characters_present": [ + + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_001_003", + "panel_number": 3, + "shot_type": 2, + "description": "The red ball veers sharply left, kicking up a spray of sand. The blue ball mirrors the move, sparks flying from its surface.", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "VROOM!", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_001_004", + "panel_number": 4, + "shot_type": 2, + "description": "Red ball takes a ramp-like dune and launches into the air, spinning. Blue ball follows, but slightly lower.", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "WHOOSH!", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_001_005", + "panel_number": 5, + "shot_type": 2, + "description": "Aerial view: both balls are airborne, shadows on the sand below. Red ball is slightly ahead.", + "characters_present": [ + + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_001_006", + "panel_number": 6, + "shot_type": 2, + "description": "They land simultaneously, creating twin craters. Dust clouds obscure them. The sun glints off their surfaces.", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "BOOM!", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + }, + { + "page_number": 2, + "layout_type": 0, + "panels": [ + { + "panel_id": "panel_002_001", + "panel_number": 1, + "shot_type": 2, + "description": "From the dust, the red ball emerges first, rolling faster. The blue ball is close behind, leaving a trail of steam.", + "characters_present": [ + + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_002_002", + "panel_number": 2, + "shot_type": 2, + "description": "Close-up on the red ball: its surface is glowing hot, with tiny flames licking the edges.", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "HISS", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_002_003", + "panel_number": 3, + "shot_type": 2, + "description": "The blue ball rams into the red ball from the side. They lock, spinning together in a whirlwind of sand.", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "CLANG!", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_002_004", + "panel_number": 4, + "shot_type": 2, + "description": "They separate, skidding to a halt. Both balls are facing each other, a few meters apart. The sun is directly overhead.", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "SCREECH", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_002_005", + "panel_number": 5, + "shot_type": 2, + "description": "Silence. A single bead of sweat (or condensation) drips from the blue ball. The red ball's glow intensifies.", + "characters_present": [ + + ], + "dialogue": [ + + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + }, + { + "panel_id": "panel_002_006", + "panel_number": 6, + "shot_type": 2, + "description": "Both balls lunge forward at the same time. The panel is a blur of motion lines and dust. The final word:", + "characters_present": [ + + ], + "dialogue": [ + { + "speaker_id": "", + "text": "CRASH!!!", + "bubble_type": 0, + "emotion": "" + } + ], + "caption": "", + "sound_effects": [ + + ], + "transition_from_previous": 0 + } + ] + } + ] + }, + "characters": [ + + ], + "panel_images": { + "panel_001_001": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_001_panel_001_001.png", + "width": 1024, + "height": 1024, + "seed": 1, + "prompt": "local" + }, + "panel_002_006": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_012_panel_002_006.png", + "width": 1024, + "height": 1024, + "seed": 12, + "prompt": "local" + }, + "panel_001_006": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_006_panel_001_006.png", + "width": 1024, + "height": 1024, + "seed": 6, + "prompt": "local" + }, + "panel_002_001": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_007_panel_002_001.png", + "width": 1024, + "height": 1024, + "seed": 7, + "prompt": "local" + }, + "panel_001_002": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_002_panel_001_002.png", + "width": 1024, + "height": 1024, + "seed": 2, + "prompt": "local" + }, + "panel_002_005": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_011_panel_002_005.png", + "width": 1024, + "height": 1024, + "seed": 11, + "prompt": "local" + }, + "panel_001_004": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_004_panel_001_004.png", + "width": 1024, + "height": 1024, + "seed": 4, + "prompt": "local" + }, + "panel_002_003": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_009_panel_002_003.png", + "width": 1024, + "height": 1024, + "seed": 9, + "prompt": "local" + }, + "panel_001_003": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_003_panel_001_003.png", + "width": 1024, + "height": 1024, + "seed": 3, + "prompt": "local" + }, + "panel_002_004": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_010_panel_002_004.png", + "width": 1024, + "height": 1024, + "seed": 10, + "prompt": "local" + }, + "panel_002_002": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_008_panel_002_002.png", + "width": 1024, + "height": 1024, + "seed": 8, + "prompt": "local" + }, + "panel_001_005": { + "url": "file:///tmp/comic-gui-local-panels-1597088181/panel_005_panel_001_005.png", + "width": 1024, + "height": 1024, + "seed": 5, + "prompt": "local" + } + }, + "panel_errors": { + + }, + "page_layouts": [ + + ], + "speech_bubbles": { + + }, + "export_format": 0, + "page_size": 0, + "color_profile": 0, + "workflow": { + "current_step": 2, + "completed_steps": [ + + ], + "is_generating": false, + "generation_progress": 0.00000000, + "error_message": "" + } + } +} \ No newline at end of file diff --git a/odin/gui_session_report.txt b/odin/gui_session_report.txt new file mode 100644 index 0000000..ee55f3e Binary files /dev/null and b/odin/gui_session_report.txt differ diff --git a/odin/local.cbz b/odin/local.cbz new file mode 100644 index 0000000..ac25d9e Binary files /dev/null and b/odin/local.cbz differ diff --git a/odin/local.pdf b/odin/local.pdf new file mode 100644 index 0000000..0fbf063 --- /dev/null +++ b/odin/local.pdf @@ -0,0 +1,27 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 12 Tf 50 780 Td (Comic Export - Panels: 2) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/odin/missing-dir-for-autosave/project.comic.json b/odin/missing-dir-for-autosave/project.comic.json new file mode 100644 index 0000000..49c8c78 --- /dev/null +++ b/odin/missing-dir-for-autosave/project.comic.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "assetCacheDir": "missing-dir-for-autosave/assets", + "state": { + "project": { + "project_id": "proj_todo", + "project_name": "Untitled Comic", + "created_at_iso": "", + "last_modified_iso": "" + }, + "user_mode": 0, + "story_idea": "", + "story_genre": "action", + "target_audience": "general", + "art_style": "manga", + "script": { + "title": "", + "synopsis": "", + "characters": [ + + ], + "pages": [ + + ] + }, + "characters": [ + + ], + "panel_images": { + + }, + "page_layouts": [ + + ], + "speech_bubbles": { + + }, + "export_format": 0, + "page_size": 0, + "color_profile": 0, + "workflow": { + "current_step": 0, + "completed_steps": [ + + ], + "is_generating": false, + "generation_progress": 0.00000000, + "error_message": "" + } + } +} \ No newline at end of file diff --git a/odin/quick_3.cbz b/odin/quick_3.cbz new file mode 100644 index 0000000..2628c54 Binary files /dev/null and b/odin/quick_3.cbz differ diff --git a/odin/quick_local.pdf b/odin/quick_local.pdf new file mode 100644 index 0000000..0fbf063 --- /dev/null +++ b/odin/quick_local.pdf @@ -0,0 +1,27 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 12 Tf 50 780 Td (Comic Export - Panels: 2) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/odin/schemas/comic-project.schema.json b/odin/schemas/comic-project.schema.json new file mode 100644 index 0000000..9aad2c8 --- /dev/null +++ b/odin/schemas/comic-project.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://comic-odin/schemas/comic-project.schema.json", + "title": "Comic Project", + "type": "object", + "required": ["schemaVersion", "project", "workflow"], + "properties": { + "schemaVersion": { "type": "integer", "minimum": 1 }, + "project": { + "type": "object", + "required": ["projectId", "projectName", "createdAt", "lastModified"], + "properties": { + "projectId": { "type": "string" }, + "projectName": { "type": "string" }, + "createdAt": { "type": "string" }, + "lastModified": { "type": "string" } + } + }, + "workflow": { + "type": "object", + "required": ["currentStep"], + "properties": { + "currentStep": { "type": "string" }, + "completedSteps": { "type": "array", "items": { "type": "string" } }, + "error": { "type": ["string", "null"] } + } + } + } +} diff --git a/odin/scratch.odin b/odin/scratch.odin new file mode 100644 index 0000000..0f8384c --- /dev/null +++ b/odin/scratch.odin @@ -0,0 +1,11 @@ +package main + +import "core:fmt" + +main :: proc() { + m: map[string]string = nil + v, ok := m["test"] + fmt.printf("v: %s, ok: %v\n", v, ok) + delete_key(&m, "test") + fmt.println("did not crash") +} diff --git a/odin/screenshot000.png b/odin/screenshot000.png new file mode 100644 index 0000000..584c028 Binary files /dev/null and b/odin/screenshot000.png differ diff --git a/odin/screenshot001.png b/odin/screenshot001.png new file mode 100644 index 0000000..a46e5e3 Binary files /dev/null and b/odin/screenshot001.png differ diff --git a/odin/scripts/package.sh b/odin/scripts/package.sh new file mode 100755 index 0000000..642d746 --- /dev/null +++ b/odin/scripts/package.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +./build.sh + +mkdir -p dist +VERSION="${VERSION:-0.1.0}" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +PKG_NAME="comic-odin-${VERSION}-${OS}-${ARCH}" +PKG_DIR="dist/${PKG_NAME}" + +rm -rf "$PKG_DIR" +mkdir -p "$PKG_DIR" + +cp bin/comic_odin "$PKG_DIR/" +cp README.md "$PKG_DIR/" +cp -r schemas "$PKG_DIR/" + +TAR_PATH="dist/${PKG_NAME}.tar.gz" +rm -f "$TAR_PATH" + +tar -czf "$TAR_PATH" -C dist "$PKG_NAME" + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$TAR_PATH" > "${TAR_PATH}.sha256" +fi + +echo "Packaged: $TAR_PATH" diff --git a/odin/src/adapters/deepseek.odin b/odin/src/adapters/deepseek.odin new file mode 100644 index 0000000..967e30e --- /dev/null +++ b/odin/src/adapters/deepseek.odin @@ -0,0 +1,789 @@ +package adapters + +import json "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:strings" +import "../core" +import "../shared" + +Generate_Script_Options :: struct { + story_idea: string, + genre: string, + art_style: string, + num_pages: int, + audience: string, +} + +Deepseek_Request_Message :: struct { + role: string, + content: string, +} + +Deepseek_Request_Response_Format :: struct { + type: string, +} + +Deepseek_Request_Body :: struct { + model: string, + messages: []Deepseek_Request_Message, + response_format: Deepseek_Request_Response_Format, + temperature: f32, +} + +Deepseek_Transport :: #type proc(cfg: shared.Config, request_json: string) -> (response_json: string, status_code: int, err: shared.App_Error) + +Deepseek_Client :: struct { + transport: Deepseek_Transport, + max_retries: int, + initial_backoff_ms: int, +} + +Deepseek_Response_Message :: struct { + content: string, +} + +Deepseek_Response_Choice :: struct { + message: Deepseek_Response_Message, +} + +Deepseek_Response :: struct { + choices: []Deepseek_Response_Choice, +} + +Raw_Dialogue :: struct { + speakerId: string, + text: string, + bubbleType: string, + emotion: string, +} + +Raw_Panel :: struct { + panelId: string, + panelNumber: int, + shotType: string, + description: string, + charactersPresent: []string, + dialogue: []Raw_Dialogue, + caption: string, + soundEffects: []string, + transitionFromPrevious: string, +} + +Raw_Page :: struct { + pageNumber: int, + layoutType: string, + panels: []Raw_Panel, +} + +Raw_Character :: struct { + id: string, + name: string, + role: string, + description: string, + firstAppearancePanel: string, +} + +Raw_Script :: struct { + title: string, + synopsis: string, + characters: []Raw_Character, + pages: []Raw_Page, +} + +Raw_Dialogue_Alt :: struct { + speaker_id: string, + text: string, + bubble_type: string, + emotion: string, +} + +Raw_Panel_Alt :: struct { + panel_id: string, + panel_number: int, + shot_type: string, + description: string, + characters_present: []string, + dialogue: []Raw_Dialogue_Alt, + caption: string, + sound_effects: []string, + transition_from_previous: string, +} + +Raw_Page_Alt :: struct { + page_number: int, + layout_type: string, + panels: []Raw_Panel_Alt, +} + +Raw_Character_Alt :: struct { + id: string, + name: string, + role: string, + description: string, + first_appearance_panel: string, +} + +Raw_Script_Alt :: struct { + title: string, + synopsis: string, + characters: []Raw_Character_Alt, + pages: []Raw_Page_Alt, +} + +Raw_Script_Wrapper :: struct { + script: Raw_Script, +} + +Raw_Script_Alt_Wrapper :: struct { + script: Raw_Script_Alt, +} + +deepseek_parse_curl_output :: proc(output: string) -> (body: string, status_code: int, ok: bool) { + marker := "__STATUS__:" + idx := strings.last_index(output, marker) + if idx < 0 { + return output, 0, false + } + + body = output[:idx] + status_str := output[idx+len(marker):] + status_code = 0 + for c in status_str { + if c < '0' || c > '9' { + break + } + status_code = status_code*10 + int(c-'0') + } + return body, status_code, true +} + +default_deepseek_transport :: proc(cfg: shared.Config, request_json: string) -> (string, int, shared.App_Error) { + url := fmt.aprintf("%s/chat/completions", cfg.deepseek_base_url) + auth := fmt.aprintf("Authorization: Bearer %s", cfg.deepseek_api_key) + + cmd := [13]string{ + "curl", "-sS", "-X", "POST", url, + "-H", "Content-Type: application/json", + "-H", auth, + "-d", request_json, + "-w", "\\n__STATUS__:%{http_code}", + } + desc := os.Process_Desc{command = cmd[:]} + state, stdout, stderr, exec_err := os.process_exec(desc, context.temp_allocator) + if exec_err != nil { + return "", 0, shared.network_error(fmt.aprintf("curl execution failed: %v", exec_err)) + } + if !state.exited || state.exit_code != 0 { + return "", 0, shared.network_error(fmt.aprintf("curl failed: %s", string(stderr))) + } + + body, status_code, ok := deepseek_parse_curl_output(string(stdout)) + if !ok { + return string(stdout), 0, shared.network_error("unable to parse curl status output") + } + return body, status_code, shared.ok() +} + +new_deepseek_client :: proc() -> Deepseek_Client { + return Deepseek_Client{ + transport = default_deepseek_transport, + max_retries = 3, + initial_backoff_ms = 500, + } +} + +backoff_ms :: proc(initial_ms, attempt: int) -> int { + if attempt <= 0 { + return 0 + } + mul := 1 << u32(attempt-1) + return initial_ms * mul +} + +extract_deepseek_error_message :: proc(body: string) -> string { + trimmed := strings.trim_space(body) + if len(trimmed) == 0 { + return "" + } + marker := "\"message\":\"" + idx := strings.index(trimmed, marker) + if idx < 0 { + if len(trimmed) > 180 { + return fmt.aprintf("%s…", trimmed[:179]) + } + return trimmed + } + start := idx + len(marker) + end := start + for end < len(trimmed) { + if trimmed[end] == '"' && (end == start || trimmed[end-1] != '\\') { + break + } + end += 1 + } + msg := trimmed[start:end] + if len(msg) == 0 { + if len(trimmed) > 180 { + return fmt.aprintf("%s…", trimmed[:179]) + } + return trimmed + } + return msg +} + +map_http_error :: proc(status_code: int, response_body: string) -> shared.App_Error { + detail := extract_deepseek_error_message(response_body) + if status_code == 429 { + if len(detail) > 0 { + return shared.rate_limit_error(fmt.aprintf("deepseek rate-limited (429): %s", detail)) + } + return shared.rate_limit_error("deepseek rate-limited (429)") + } + if status_code >= 500 { + if len(detail) > 0 { + return shared.network_error(fmt.aprintf("deepseek server error (%d): %s", status_code, detail)) + } + return shared.network_error(fmt.aprintf("deepseek server error (%d)", status_code)) + } + if status_code >= 400 { + if len(detail) > 0 { + return shared.validation_error(fmt.aprintf("deepseek request failed (%d): %s", status_code, detail)) + } + return shared.validation_error(fmt.aprintf("deepseek request failed (%d)", status_code)) + } + return shared.ok() +} + +deepseek_json_escape :: proc(s: string) -> string { + out: [dynamic]u8 + for c in s { + switch c { + case '"': + append(&out, '\\') + append(&out, '"') + case '\\': + append(&out, '\\') + append(&out, '\\') + case '\n': + append(&out, '\\') + append(&out, 'n') + case '\r': + append(&out, '\\') + append(&out, 'r') + case '\t': + append(&out, '\\') + append(&out, 't') + case: + append(&out, u8(c)) + } + } + return string(out[:]) +} + +build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string { + user_content := fmt.aprintf( + "Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.", + opts.num_pages, + opts.story_idea, + opts.genre, + opts.art_style, + opts.audience, + ) + defer delete(user_content) + + messages := [2]Deepseek_Request_Message{ + {role = "system", content = "You are an expert comic writer. Return JSON only."}, + {role = "user", content = user_content}, + } + body := Deepseek_Request_Body{ + model = "deepseek-chat", + messages = messages[:], + response_format = Deepseek_Request_Response_Format{type = "json_object"}, + temperature = 0.8, + } + request_json, merr := json.marshal(body, {}, context.allocator) + if merr == nil { + return string(request_json) + } + + escaped := deepseek_json_escape(user_content) + defer delete(escaped) + return fmt.aprintf( + "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8}", + escaped, + ) +} + +validate_generate_script_options :: proc(opts: Generate_Script_Options) -> shared.App_Error { + if len(opts.story_idea) == 0 { + return shared.validation_error("story_idea is required") + } + if opts.num_pages <= 0 { + return shared.validation_error("num_pages must be > 0") + } + return shared.ok() +} + +build_fallback_script :: proc(opts: Generate_Script_Options) -> core.Comic_Script { + dialogues: [1]core.Dialogue = [1]core.Dialogue{ + {speaker_id = "char_001", text = "Let's begin.", bubble_type = .Normal, emotion = "neutral"}, + } + chars_present: [1]string = [1]string{"char_001"} + panels: [1]core.Panel = [1]core.Panel{ + { + panel_id = "panel_001_001", + panel_number = 1, + shot_type = .Medium, + description = opts.story_idea, + characters_present = chars_present[:], + dialogue = dialogues[:], + caption = "", + sound_effects = nil, + transition_from_previous = .None, + }, + } + pages: [1]core.Page = [1]core.Page{ + {page_number = 1, layout_type = .Grid, panels = panels[:]}, + } + chars: [1]core.Character = [1]core.Character{ + {name = "Protagonist", description = "A determined lead character"}, + } + + raw := core.Comic_Script{ + title = "Generated Comic", + synopsis = opts.story_idea, + characters = chars[:], + pages = pages[:], + } + return core.normalize_script(raw) +} + +extract_json_block :: proc(s: string) -> string { + trimmed := strings.trim(s, " \n\r\t") + start := strings.index(trimmed, "{") + finish := strings.last_index(trimmed, "}") + if start >= 0 && finish >= start { + return trimmed[start : finish+1] + } + return trimmed +} + +character_role_from_string :: proc(role: string) -> core.Character_Role { + switch role { + case "protagonist": + return .Protagonist + case "antagonist": + return .Antagonist + case "supporting": + return .Supporting + case "extra": + return .Extra + } + return .Supporting +} + +layout_type_from_string :: proc(layout: string) -> core.Layout_Type { + switch layout { + case "grid": + return .Grid + case "manga": + return .Manga + case "western": + return .Western + case "action": + return .Action + case "dialogue": + return .Dialogue + case "splash": + return .Splash + } + return .Grid +} + +shot_type_from_string :: proc(shot: string) -> core.Shot_Type { + switch shot { + case "establishing": + return .Establishing + case "wide": + return .Wide + case "medium": + return .Medium + case "close-up": + return .Close_Up + case "extreme-close-up": + return .Extreme_Close_Up + case "over-shoulder": + return .Over_Shoulder + case "aerial": + return .Aerial + } + return .Medium +} + +bubble_type_from_string :: proc(bt: string) -> core.Bubble_Type { + switch bt { + case "normal": + return .Normal + case "thought": + return .Thought + case "shout": + return .Shout + case "whisper": + return .Whisper + case "narration": + return .Narration + case "sound-effect": + return .Sound_Effect + } + return .Normal +} + +transition_from_string :: proc(t: string) -> core.Transition_Type { + switch t { + case "none": + return .None + case "fade": + return .Fade + case "wipe": + return .Wipe + case "dissolve": + return .Dissolve + case "action-lines": + return .Action_Lines + } + return .None +} + +clone_string_array :: proc(items: []string) -> []string { + out: [dynamic]string + for s in items { + append(&out, strings.clone(s, context.allocator)) + } + return out[:] +} + +dispose_deepseek_response :: proc(resp: ^Deepseek_Response) { + for c in resp.choices { + delete(c.message.content) + } + delete(resp.choices) +} + +dispose_raw_script :: proc(raw: ^Raw_Script) { + delete(raw.title) + delete(raw.synopsis) + + for c in raw.characters { + delete(c.id) + delete(c.name) + delete(c.role) + delete(c.description) + delete(c.firstAppearancePanel) + } + delete(raw.characters) + + for p in raw.pages { + delete(p.layoutType) + for pan in p.panels { + delete(pan.panelId) + delete(pan.shotType) + delete(pan.description) + delete(pan.caption) + delete(pan.transitionFromPrevious) + for d in pan.dialogue { + delete(d.speakerId) + delete(d.text) + delete(d.bubbleType) + delete(d.emotion) + } + delete(pan.dialogue) + for s in pan.charactersPresent { + delete(s) + } + delete(pan.charactersPresent) + for s in pan.soundEffects { + delete(s) + } + delete(pan.soundEffects) + } + delete(p.panels) + } + delete(raw.pages) +} + +dispose_raw_script_alt :: proc(raw: ^Raw_Script_Alt) { + delete(raw.title) + delete(raw.synopsis) + + for c in raw.characters { + delete(c.id) + delete(c.name) + delete(c.role) + delete(c.description) + delete(c.first_appearance_panel) + } + delete(raw.characters) + + for p in raw.pages { + delete(p.layout_type) + for pan in p.panels { + delete(pan.panel_id) + delete(pan.shot_type) + delete(pan.description) + delete(pan.caption) + delete(pan.transition_from_previous) + for d in pan.dialogue { + delete(d.speaker_id) + delete(d.text) + delete(d.bubble_type) + delete(d.emotion) + } + delete(pan.dialogue) + for s in pan.characters_present { + delete(s) + } + delete(pan.characters_present) + for s in pan.sound_effects { + delete(s) + } + delete(pan.sound_effects) + } + delete(p.panels) + } + delete(raw.pages) +} + +convert_raw_script :: proc(raw: Raw_Script) -> core.Comic_Script { + chars: [dynamic]core.Character + for c in raw.characters { + append(&chars, core.Character{ + id = strings.clone(c.id, context.allocator), + name = strings.clone(c.name, context.allocator), + role = character_role_from_string(c.role), + description = strings.clone(c.description, context.allocator), + first_appearance_panel = strings.clone(c.firstAppearancePanel, context.allocator), + }) + } + + pages: [dynamic]core.Page + for p in raw.pages { + panels: [dynamic]core.Panel + for pan in p.panels { + dialogues: [dynamic]core.Dialogue + for d in pan.dialogue { + append(&dialogues, core.Dialogue{ + speaker_id = strings.clone(d.speakerId, context.allocator), + text = strings.clone(d.text, context.allocator), + bubble_type = bubble_type_from_string(d.bubbleType), + emotion = strings.clone(d.emotion, context.allocator), + }) + } + + append(&panels, core.Panel{ + panel_id = strings.clone(pan.panelId, context.allocator), + panel_number = pan.panelNumber, + shot_type = shot_type_from_string(pan.shotType), + description = strings.clone(pan.description, context.allocator), + characters_present = clone_string_array(pan.charactersPresent), + dialogue = dialogues[:], + caption = strings.clone(pan.caption, context.allocator), + sound_effects = clone_string_array(pan.soundEffects), + transition_from_previous = transition_from_string(pan.transitionFromPrevious), + }) + } + + append(&pages, core.Page{ + page_number = p.pageNumber, + layout_type = layout_type_from_string(p.layoutType), + panels = panels[:], + }) + } + + return core.Comic_Script{ + title = strings.clone(raw.title, context.allocator), + synopsis = strings.clone(raw.synopsis, context.allocator), + characters = chars[:], + pages = pages[:], + } +} + +convert_raw_script_alt :: proc(raw: Raw_Script_Alt) -> core.Comic_Script { + chars: [dynamic]core.Character + for c in raw.characters { + append(&chars, core.Character{ + id = strings.clone(c.id, context.allocator), + name = strings.clone(c.name, context.allocator), + role = character_role_from_string(c.role), + description = strings.clone(c.description, context.allocator), + first_appearance_panel = strings.clone(c.first_appearance_panel, context.allocator), + }) + } + + pages: [dynamic]core.Page + for p in raw.pages { + panels: [dynamic]core.Panel + for pan in p.panels { + dialogues: [dynamic]core.Dialogue + for d in pan.dialogue { + append(&dialogues, core.Dialogue{ + speaker_id = strings.clone(d.speaker_id, context.allocator), + text = strings.clone(d.text, context.allocator), + bubble_type = bubble_type_from_string(d.bubble_type), + emotion = strings.clone(d.emotion, context.allocator), + }) + } + + append(&panels, core.Panel{ + panel_id = strings.clone(pan.panel_id, context.allocator), + panel_number = pan.panel_number, + shot_type = shot_type_from_string(pan.shot_type), + description = strings.clone(pan.description, context.allocator), + characters_present = clone_string_array(pan.characters_present), + dialogue = dialogues[:], + caption = strings.clone(pan.caption, context.allocator), + sound_effects = clone_string_array(pan.sound_effects), + transition_from_previous = transition_from_string(pan.transition_from_previous), + }) + } + + append(&pages, core.Page{ + page_number = p.page_number, + layout_type = layout_type_from_string(p.layout_type), + panels = panels[:], + }) + } + + return core.Comic_Script{ + title = strings.clone(raw.title, context.allocator), + synopsis = strings.clone(raw.synopsis, context.allocator), + characters = chars[:], + pages = pages[:], + } +} + +invalid_normalized_script_err :: proc(script: core.Comic_Script) -> shared.App_Error { + return shared.generation_error(fmt.aprintf("normalized script failed minimal validation (title:%d synopsis:%d pages:%d)", len(script.title), len(script.synopsis), len(script.pages))) +} + +parse_deepseek_script_response :: proc(response_json: string) -> (core.Comic_Script, shared.App_Error) { + outer: Deepseek_Response + if err := json.unmarshal_string(response_json, &outer); err != nil { + return core.Comic_Script{}, shared.generation_error(fmt.aprintf("failed to parse deepseek envelope: %v", err)) + } + defer dispose_deepseek_response(&outer) + if len(outer.choices) == 0 { + return core.Comic_Script{}, shared.generation_error("deepseek response has no choices") + } + + content := outer.choices[0].message.content + if len(content) == 0 { + return core.Comic_Script{}, shared.generation_error("deepseek content is empty") + } + + raw_json := extract_json_block(content) + + raw_script: Raw_Script + if err := json.unmarshal_string(raw_json, &raw_script); err == nil { + defer dispose_raw_script(&raw_script) + script := convert_raw_script(raw_script) + norm := core.normalize_script(script) + if !core.script_is_valid_minimal(norm) { + core.dispose_script(&norm) + return core.Comic_Script{}, invalid_normalized_script_err(script) + } + return norm, shared.ok() + } + + wrapped: Raw_Script_Wrapper + if err := json.unmarshal_string(raw_json, &wrapped); err == nil { + defer dispose_raw_script(&wrapped.script) + script := convert_raw_script(wrapped.script) + norm := core.normalize_script(script) + if !core.script_is_valid_minimal(norm) { + core.dispose_script(&norm) + return core.Comic_Script{}, invalid_normalized_script_err(script) + } + return norm, shared.ok() + } + + raw_alt: Raw_Script_Alt + if err := json.unmarshal_string(raw_json, &raw_alt); err == nil { + defer dispose_raw_script_alt(&raw_alt) + script := convert_raw_script_alt(raw_alt) + norm := core.normalize_script(script) + if !core.script_is_valid_minimal(norm) { + core.dispose_script(&norm) + return core.Comic_Script{}, invalid_normalized_script_err(script) + } + return norm, shared.ok() + } + + wrapped_alt: Raw_Script_Alt_Wrapper + if err := json.unmarshal_string(raw_json, &wrapped_alt); err == nil { + defer dispose_raw_script_alt(&wrapped_alt.script) + script := convert_raw_script_alt(wrapped_alt.script) + norm := core.normalize_script(script) + if !core.script_is_valid_minimal(norm) { + core.dispose_script(&norm) + return core.Comic_Script{}, invalid_normalized_script_err(script) + } + return norm, shared.ok() + } + + return core.Comic_Script{}, shared.generation_error("failed to parse deepseek script content (unsupported JSON shape)") +} + +generate_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: Generate_Script_Options) -> (core.Comic_Script, shared.App_Error) { + if len(cfg.deepseek_api_key) == 0 { + return core.Comic_Script{}, shared.config_error("DEEPSEEK_API_KEY is missing") + } + + if verr := validate_generate_script_options(opts); !shared.is_ok(verr) { + return core.Comic_Script{}, verr + } + + request_json := build_deepseek_request_json(opts) + defer delete(request_json) + attempts := client.max_retries + if attempts < 1 { + attempts = 1 + } + + last_err := shared.generation_error("unknown deepseek error") + for attempt in 1..=attempts { + response_json, status_code, transport_err := client.transport(cfg, request_json) + if !shared.is_ok(transport_err) { + last_err = transport_err + } else if status_code >= 400 { + last_err = map_http_error(status_code, response_json) + } else { + if len(response_json) == 0 { + last_err = shared.generation_error("deepseek returned empty response") + } else { + script, parse_err := parse_deepseek_script_response(response_json) + if shared.is_ok(parse_err) { + return script, shared.ok() + } + if strings.has_prefix(parse_err.message, "normalized script failed minimal validation") { + fallback := build_fallback_script(opts) + return fallback, shared.ok() + } + last_err = parse_err + } + } + + if attempt < attempts && shared.should_retry(last_err) { + _ = backoff_ms(client.initial_backoff_ms, attempt) + continue + } + break + } + + return core.Comic_Script{}, last_err +} + +generate_comic_script_stub :: proc(cfg: shared.Config, opts: Generate_Script_Options) -> (core.Comic_Script, shared.App_Error) { + client := new_deepseek_client() + return generate_comic_script(client, cfg, opts) +} diff --git a/odin/src/adapters/export.odin b/odin/src/adapters/export.odin new file mode 100644 index 0000000..815289a --- /dev/null +++ b/odin/src/adapters/export.odin @@ -0,0 +1,251 @@ +package adapters + +import "core:fmt" +import "core:os" +import filepath "core:path/filepath" +import "core:strings" +import "../core" +import "../shared" + +Export_Options :: struct { + format: core.Export_Format, + page_size: core.Page_Size_Name, + dpi: int, + quality: int, +} + +Ordered_Panel :: struct { + page_number: int, + panel_number: int, + panel_id: string, +} + +collect_ordered_panels :: proc(layouts: []core.Page_Layout) -> []Ordered_Panel { + panels: [dynamic]Ordered_Panel + for page in layouts { + for p in page.panels { + append(&panels, Ordered_Panel{ + page_number = page.page_number, + panel_number = p.panel_number, + panel_id = p.panel_id, + }) + } + } + return panels[:] +} + +path_join2 :: proc(a, b: string) -> (string, shared.App_Error) { + parts := [2]string{a, b} + out, err := filepath.join(parts[:], context.allocator) + if err != nil { + msg := fmt.aprintf("path join failed: %v", err) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return "", err_out + } + return out, shared.ok() +} + +run_command :: proc(args: []string) -> shared.App_Error { + desc := os.Process_Desc{command = args} + state, _, stderr, err := os.process_exec(desc, context.temp_allocator) + if err != nil { + msg := fmt.aprintf("command failed to execute: %v", err) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return err_out + } + if !state.exited || state.exit_code != 0 { + err_text := string(stderr) + msg := fmt.aprintf("command failed: %s", err_text) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return err_out + } + return shared.ok() +} + +file_ext_from_url :: proc(url: string) -> string { + base := url + if q := strings.index(base, "?"); q >= 0 { + base = base[:q] + } + ext := filepath.ext(base) + if len(ext) == 0 { + return ".png" + } + return ext +} + +stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error { + staged_count := 0 + for p, idx in ordered { + img := panel_images[p.panel_id] + if len(img.url) == 0 { + continue + } + + ext := file_ext_from_url(img.url) + filename := fmt.aprintf("%03d_page%03d_panel%03d%s", idx+1, p.page_number, p.panel_number, ext) + out_path, jerr := path_join2(temp_dir, filename) + delete(filename) + if !shared.is_ok(jerr) { + return jerr + } + + if strings.has_prefix(img.url, "file://") { + src_path := img.url[len("file://"):] + if cerr := os.copy_file(out_path, src_path); cerr != nil { + msg := fmt.aprintf("failed to copy local panel image %s: %v", p.panel_id, cerr) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + delete(out_path) + return err_out + } + } else { + cmd := [6]string{"curl", "-L", "-sS", "-o", out_path, img.url} + cerr := run_command(cmd[:]) + if !shared.is_ok(cerr) { + msg := fmt.aprintf("failed to download panel %s: %s", p.panel_id, cerr.message) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + delete(out_path) + return err_out + } + } + + delete(out_path) + staged_count += 1 + } + + if staged_count == 0 { + return shared.new_error(.Export, "no panel images available for export", false) + } + return shared.ok() +} + +write_comic_info_xml :: proc(temp_dir: string, page_count: int) -> shared.App_Error { + comic_info := fmt.aprintf("\n\n Generated Comic\n %d\n\n", page_count) + defer delete(comic_info) + path, jerr := path_join2(temp_dir, "ComicInfo.xml") + if !shared.is_ok(jerr) { + return jerr + } + defer delete(path) + if err := os.write_entire_file(path, comic_info); err != nil { + msg := fmt.aprintf("failed to write ComicInfo.xml: %v", err) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return err_out + } + return shared.ok() +} + +zip_directory_with_python :: proc(output_path, source_dir: string, include_comic_info: bool) -> shared.App_Error { + script := "import os,sys,zipfile\nout=sys.argv[1]; src=sys.argv[2]; inc=sys.argv[3]=='1'\nwith zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as z:\n for n in sorted(os.listdir(src)):\n if (not inc) and n=='ComicInfo.xml':\n continue\n p=os.path.join(src,n)\n if os.path.isfile(p):\n z.write(p,arcname=n)" + inc := "0" + if include_comic_info { + inc = "1" + } + cmd := [6]string{"python3", "-c", script, output_path, source_dir, inc} + return run_command(cmd[:]) +} + +write_simple_pdf :: proc(output_path: string, ordered: []Ordered_Panel) -> shared.App_Error { + text := fmt.aprintf("Comic Export - Panels: %d", len(ordered)) + defer delete(text) + content := fmt.aprintf("BT /F1 12 Tf 50 780 Td (%s) Tj ET", text) + defer delete(content) + stream := fmt.aprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content) + defer delete(stream) + + obj1 := "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + obj2 := "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + obj3 := "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n" + obj4 := fmt.aprintf("4 0 obj\n%s\nendobj\n", stream) + defer delete(obj4) + obj5 := "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n" + + xref_start := len("%PDF-1.4\n") + len(obj1) + len(obj2) + len(obj3) + len(obj4) + len(obj5) + pdf := fmt.aprintf( + "%%PDF-1.4\n%s%s%s%s%sxref\n0 6\n0000000000 65535 f \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", + obj1, obj2, obj3, obj4, obj5, xref_start, + ) + defer delete(pdf) + + if err := os.write_entire_file(output_path, pdf); err != nil { + msg := fmt.aprintf("failed to write pdf: %v", err) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return err_out + } + return shared.ok() +} + +ensure_export_output_parent_dir :: proc(output_path: string) -> shared.App_Error { + dir, _ := filepath.split(output_path) + if len(dir) == 0 { + return shared.ok() + } + if err := os.mkdir_all(dir); err != nil && err != .Exist { + msg := fmt.aprintf("failed to create export output directory: %v", err) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return err_out + } + return shared.ok() +} + +export_comic :: proc(output_path: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image, opts: Export_Options) -> shared.App_Error { + if len(output_path) == 0 { + return shared.new_error(.Export, "output path is empty", false) + } + if len(layouts) == 0 { + return shared.new_error(.Export, "no layouts available for export", false) + } + if derr := ensure_export_output_parent_dir(output_path); !shared.is_ok(derr) { + return derr + } + + ordered := collect_ordered_panels(layouts) + defer delete(ordered) + if len(ordered) == 0 { + return shared.new_error(.Export, "no panels available for export", false) + } + + switch opts.format { + case .PDF: + return write_simple_pdf(output_path, ordered) + case .CBZ, .PNG: + temp_dir, terr := os.make_directory_temp("", "comic-export-*", context.temp_allocator) + if terr != nil { + msg := fmt.aprintf("failed to create temp dir: %v", terr) + err_out := shared.new_error(.Export, msg, true) + delete(msg) + return err_out + } + defer os.remove_all(temp_dir) + + if serr := stage_panel_images(temp_dir, ordered, panel_images); !shared.is_ok(serr) { + return serr + } + + include_comic_info := opts.format == .CBZ + if include_comic_info { + if ierr := write_comic_info_xml(temp_dir, len(layouts)); !shared.is_ok(ierr) { + return ierr + } + } + + if zerr := zip_directory_with_python(output_path, temp_dir, include_comic_info); !shared.is_ok(zerr) { + return zerr + } + return shared.ok() + } + + return shared.new_error(.Export, "unknown export format", false) +} + +export_comic_stub :: proc(output_path: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image, opts: Export_Options) -> shared.App_Error { + return export_comic(output_path, layouts, panel_images, opts) +} diff --git a/odin/src/adapters/fal.odin b/odin/src/adapters/fal.odin new file mode 100644 index 0000000..273eefe --- /dev/null +++ b/odin/src/adapters/fal.odin @@ -0,0 +1,299 @@ +package adapters + +import json "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:strings" +import "../core" +import "../shared" + +Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, seed: i64) -> (image_url: string, status_code: int, err: shared.App_Error) + +Fal_Generation_Queue :: struct { + max_concurrency: int, + in_flight: int, +} + +new_fal_queue :: proc(max_concurrency: int) -> Fal_Generation_Queue { + cap := max_concurrency + if cap < 1 { + cap = 1 + } + return Fal_Generation_Queue{max_concurrency = cap, in_flight = 0} +} + +try_acquire_slot :: proc(q: ^Fal_Generation_Queue) -> bool { + if q.in_flight >= q.max_concurrency { + return false + } + q.in_flight += 1 + return true +} + +release_slot :: proc(q: ^Fal_Generation_Queue) { + if q.in_flight > 0 { + q.in_flight -= 1 + } +} + +Fal_Client :: struct { + transport: Fal_Transport, + max_retries: int, + initial_backoff_ms: int, + queue: ^Fal_Generation_Queue, +} + +fal_parse_curl_output :: proc(output: string) -> (body: string, status_code: int, ok: bool) { + marker := "__STATUS__:" + idx := strings.last_index(output, marker) + if idx < 0 { + return output, 0, false + } + + body = output[:idx] + status_str := output[idx+len(marker):] + status_code = 0 + for c in status_str { + if c < '0' || c > '9' { + break + } + status_code = status_code*10 + int(c-'0') + } + return body, status_code, true +} + +Fal_Image :: struct { + url: string, + width: int, + height: int, +} + +Fal_Response :: struct { + images: []Fal_Image, +} + +dispose_fal_response :: proc(resp: ^Fal_Response) { + for img in resp.images { + delete(img.url) + } + delete(resp.images) +} + +fal_parse_response_body :: proc(body: string) -> (Fal_Response, shared.App_Error) { + resp: Fal_Response + if err := json.unmarshal_string(body, &resp); err != nil { + return Fal_Response{}, shared.generation_error(fmt.aprintf("failed to parse fal response JSON: %v", err)) + } + if len(resp.images) == 0 { + return Fal_Response{}, shared.generation_error("fal response missing images array") + } + if len(resp.images[0].url) == 0 { + return Fal_Response{}, shared.generation_error("fal response image url is empty") + } + return resp, shared.ok() +} + +fal_json_escape :: proc(s: string) -> string { + out: [dynamic]u8 + for c in s { + switch c { + case '"': + append(&out, '\\') + append(&out, '"') + case '\\': + append(&out, '\\') + append(&out, '\\') + case '\n': + append(&out, '\\') + append(&out, 'n') + case '\r': + append(&out, '\\') + append(&out, 'r') + case '\t': + append(&out, '\\') + append(&out, 't') + case: + append(&out, u8(c)) + } + } + return string(out[:]) +} + +default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt: string, seed: i64) -> (string, int, shared.App_Error) { + url := fmt.aprintf("https://fal.run/fal-ai/%s", endpoint) + auth := fmt.aprintf("Authorization: Key %s", cfg.fal_api_key) + payload := fmt.aprintf("{\"prompt\":\"%s\",\"seed\":%d}", fal_json_escape(prompt), seed) + + cmd := [13]string{ + "curl", "-sS", "-X", "POST", url, + "-H", "Content-Type: application/json", + "-H", auth, + "-d", payload, + "-w", "\\n__STATUS__:%{http_code}", + } + desc := os.Process_Desc{command = cmd[:]} + state, stdout, stderr, exec_err := os.process_exec(desc, context.temp_allocator) + if exec_err != nil { + return "", 0, shared.network_error(fmt.aprintf("curl execution failed: %v", exec_err)) + } + if !state.exited || state.exit_code != 0 { + return "", 0, shared.network_error(fmt.aprintf("curl failed: %s", string(stderr))) + } + + body, status_code, ok := fal_parse_curl_output(string(stdout)) + if !ok { + return "", 0, shared.network_error("unable to parse curl status output") + } + + if status_code >= 400 { + return "", status_code, shared.ok() + } + + resp, parse_err := fal_parse_response_body(body) + if !shared.is_ok(parse_err) { + return "", status_code, parse_err + } + return resp.images[0].url, status_code, shared.ok() +} + +new_fal_client :: proc(queue: ^Fal_Generation_Queue) -> Fal_Client { + return Fal_Client{ + transport = default_fal_transport, + max_retries = 3, + initial_backoff_ms = 500, + queue = queue, + } +} + +fal_http_error :: proc(status_code: int) -> shared.App_Error { + if status_code == 429 { + return shared.rate_limit_error("fal rate-limited (429)") + } + if status_code >= 500 { + return shared.network_error(fmt.aprintf("fal server error (%d)", status_code)) + } + if status_code >= 400 { + return shared.validation_error(fmt.aprintf("fal request failed (%d)", status_code)) + } + return shared.ok() +} + +fal_backoff_ms :: proc(initial_ms, attempt: int) -> int { + if attempt <= 0 { + return 0 + } + mul := 1 << u32(attempt-1) + return initial_ms * mul +} + +generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) { + if len(cfg.fal_api_key) == 0 { + return "", shared.config_error("FAL_API_KEY is missing") + } + if client.queue == nil { + return "", shared.config_error("fal queue is not configured") + } + if !try_acquire_slot(client.queue) { + return "", shared.generation_error("fal queue saturated") + } + defer release_slot(client.queue) + + prompt := core.build_character_prompt(c, "standing in neutral pose", "clean background", "studio lighting", art_style) + + attempts := client.max_retries + if attempts < 1 { + attempts = 1 + } + + last_err := shared.generation_error("unknown fal character generation error") + for attempt in 1..=attempts { + url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, c.seed) + if !shared.is_ok(transport_err) { + last_err = transport_err + } else if status_code >= 400 { + last_err = fal_http_error(status_code) + } else if len(url) == 0 { + last_err = shared.generation_error("fal returned empty image url") + } else { + return url, shared.ok() + } + + if attempt < attempts && shared.should_retry(last_err) { + _ = fal_backoff_ms(client.initial_backoff_ms, attempt) + continue + } + break + } + + return "", last_err +} + +generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) { + if len(cfg.fal_api_key) == 0 { + return core.Panel_Image{}, shared.config_error("FAL_API_KEY is missing") + } + if client.queue == nil { + return core.Panel_Image{}, shared.config_error("fal queue is not configured") + } + if !try_acquire_slot(client.queue) { + return core.Panel_Image{}, shared.generation_error("fal queue saturated") + } + defer release_slot(client.queue) + + _ = characters + seed := core.generate_panel_seed(project_id, 1, panel.panel_number, panel.panel_id) + prompt := fmt.aprintf("%s comic panel. %s", art_style, panel.description) + + attempts := client.max_retries + if attempts < 1 { + attempts = 1 + } + + last_err := shared.generation_error("unknown fal panel generation error") + for attempt in 1..=attempts { + url, status_code, transport_err := client.transport(cfg, "fast-sdxl", prompt, seed) + if !shared.is_ok(transport_err) { + last_err = transport_err + } else if status_code >= 400 { + last_err = fal_http_error(status_code) + } else if len(url) == 0 { + last_err = shared.generation_error("fal returned empty image url") + } else { + return core.Panel_Image{url = url, width = 1024, height = 1024, seed = seed, prompt = prompt}, shared.ok() + } + + if attempt < attempts && shared.should_retry(last_err) { + _ = fal_backoff_ms(client.initial_backoff_ms, attempt) + continue + } + break + } + + return core.Panel_Image{}, last_err +} + +generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id: string) -> (map[string]core.Panel_Image, shared.App_Error) { + results := make(map[string]core.Panel_Image) + + for p in panels { + img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id) + if !shared.is_ok(err) { + return results, err + } + results[p.panel_id] = img + } + + return results, shared.ok() +} + +generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) { + q := new_fal_queue(2) + client := new_fal_client(&q) + return generate_character_reference(client, cfg, c, art_style) +} + +generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) { + q := new_fal_queue(2) + client := new_fal_client(&q) + return generate_panel_image(client, cfg, panel, characters, art_style, project_id) +} diff --git a/odin/src/adapters/storage.odin b/odin/src/adapters/storage.odin new file mode 100644 index 0000000..222e67b --- /dev/null +++ b/odin/src/adapters/storage.odin @@ -0,0 +1,133 @@ +package adapters + +import json "core:encoding/json" +import "core:fmt" +import "core:os" +import filepath "core:path/filepath" +import "../core" +import "../shared" + +CURRENT_PROJECT_SCHEMA_VERSION :: 1 + +Comic_Project_Document :: struct { + schemaVersion: int, + assetCacheDir: string, + state: core.Comic_State, +} + +join_paths :: proc(a, b: string) -> (string, shared.App_Error) { + parts := [2]string{a, b} + joined, err := filepath.join(parts[:], context.temp_allocator) + if err != nil { + return "", shared.new_error(.Storage, fmt.aprintf("path join failed: %v", err), false) + } + return joined, shared.ok() +} + +derive_asset_cache_dir :: proc(project_file_path: string) -> (string, shared.App_Error) { + dir, _ := filepath.split(project_file_path) + if len(dir) == 0 { + dir = "." + } + return join_paths(dir, "assets") +} + +ensure_project_layout :: proc(project_file_path: string) -> shared.App_Error { + dir, _ := filepath.split(project_file_path) + if len(dir) > 0 { + if err := os.mkdir_all(dir); err != nil && err != .Exist { + return shared.new_error(.Storage, fmt.aprintf("failed to create project directory: %v", err), true) + } + } + + asset_dir, aerr := derive_asset_cache_dir(project_file_path) + if !shared.is_ok(aerr) { + return aerr + } + if err := os.mkdir_all(asset_dir); err != nil && err != .Exist { + return shared.new_error(.Storage, fmt.aprintf("failed to create asset cache directory: %v", err), true) + } + + return shared.ok() +} + +migrate_document_to_current_state :: proc(doc: Comic_Project_Document) -> (core.Comic_State, shared.App_Error) { + switch doc.schemaVersion { + case 1: + return doc.state, shared.ok() + case: + return core.Comic_State{}, shared.new_error(.Storage, fmt.aprintf("unsupported schemaVersion: %d", doc.schemaVersion), false) + } +} + +save_project :: proc(path: string, state: core.Comic_State) -> shared.App_Error { + if len(path) == 0 { + return shared.new_error(.Storage, "save path is empty", false) + } + + if lerr := ensure_project_layout(path); !shared.is_ok(lerr) { + return lerr + } + + asset_dir, derr := derive_asset_cache_dir(path) + if !shared.is_ok(derr) { + return derr + } + + doc := Comic_Project_Document{ + schemaVersion = CURRENT_PROJECT_SCHEMA_VERSION, + assetCacheDir = asset_dir, + state = state, + } + + payload, merr := json.marshal(doc, json.Marshal_Options{pretty = true, use_spaces = true, spaces = 2}, context.temp_allocator) + if merr != nil { + return shared.new_error(.Storage, fmt.aprintf("failed to marshal project: %v", merr), false) + } + + if werr := os.write_entire_file(path, payload); werr != nil { + return shared.new_error(.Storage, fmt.aprintf("failed to write project file: %v", werr), true) + } + + return shared.ok() +} + +load_project :: proc(path: string) -> (core.Comic_State, shared.App_Error) { + if len(path) == 0 { + return core.Comic_State{}, shared.new_error(.Storage, "load path is empty", false) + } + if !os.exists(path) { + return core.Comic_State{}, shared.new_error(.Storage, "project file does not exist", false) + } + + payload, rerr := os.read_entire_file(path, context.temp_allocator) + if rerr != nil { + return core.Comic_State{}, shared.new_error(.Storage, fmt.aprintf("failed to read project file: %v", rerr), true) + } + + doc: Comic_Project_Document + if err := json.unmarshal(payload, &doc); err == nil { + if doc.schemaVersion <= 0 { + return core.Comic_State{}, shared.new_error(.Storage, "invalid project schemaVersion", false) + } + state, merr := migrate_document_to_current_state(doc) + delete(doc.assetCacheDir) + return state, merr + } + + // Legacy fallback: raw Comic_State payload without wrapper + legacy: core.Comic_State + if err := json.unmarshal(payload, &legacy); err == nil { + return legacy, shared.ok() + } + + return core.Comic_State{}, shared.new_error(.Storage, "failed to decode project payload", false) +} + +save_project_stub :: proc(path: string, state: core.Comic_State) -> shared.App_Error { + return save_project(path, state) +} + +load_project_stub :: proc(path: string) -> (core.Comic_State, shared.App_Error) { + return load_project(path) +} diff --git a/odin/src/app/cli.odin b/odin/src/app/cli.odin new file mode 100644 index 0000000..ca52218 --- /dev/null +++ b/odin/src/app/cli.odin @@ -0,0 +1,1166 @@ +package main + +import "core:fmt" +import "core:os" +import "core:strconv" +import "core:strings" +import "../adapters" +import "../core" +import "../gui" +import "../shared" +import "../ui" + +CLI_Command_Kind :: enum { + Demo, + Status, + Save, + Load, + Tui, + Gui, + Help, +} + +Parsed_CLI_Command :: struct { + kind: CLI_Command_Kind, + path: string, +} + +parse_cli_command :: proc(args: []string) -> Parsed_CLI_Command { + if len(args) == 0 { + return Parsed_CLI_Command{kind = .Demo} + } + + switch args[0] { + case "status": + return Parsed_CLI_Command{kind = .Status} + case "save": + if len(args) >= 2 { + return Parsed_CLI_Command{kind = .Save, path = args[1]} + } + return Parsed_CLI_Command{kind = .Help} + case "load": + if len(args) >= 2 { + return Parsed_CLI_Command{kind = .Load, path = args[1]} + } + return Parsed_CLI_Command{kind = .Help} + case "tui": + return Parsed_CLI_Command{kind = .Tui} + case "gui": + return Parsed_CLI_Command{kind = .Gui} + case "help", "-h", "--help": + return Parsed_CLI_Command{kind = .Help} + } + + return Parsed_CLI_Command{kind = .Help} +} + +usage_text :: proc() -> string { + return "Usage: comic_odin [status|save |load |tui|gui|help]" +} + +tui_help_text :: proc() -> string { + return "TUI commands: help|h, status|s, doctor|?, ready|r, next|n, plan|p, auto|x, auto all , auto all local [pages], new, set idea , set genre , set audience , generate script [pages], generate script local [pages], generate panels [page ], generate panels local [page ], layout auto, export , quick local [pages], quick local all [pages], goto (or 1..8), step , save|saveas , load|open , start , progress <0-100>, done|d, fail , cancel|c, quit|q" +} + +bool_text :: proc(v: bool) -> string { + if v { + return "yes" + } + return "no" +} + +export_format_name :: proc(f: core.Export_Format) -> string { + switch f { + case .PDF: return "pdf" + case .PNG: return "png" + case .CBZ: return "cbz" + } + return "pdf" +} + +command_available :: proc(name: string) -> bool { + cmd := [2]string{"which", name} + desc := os.Process_Desc{command = cmd[:]} + state, _, _, err := os.process_exec(desc, context.temp_allocator) + if err != nil { + return false + } + return state.exited && state.exit_code == 0 +} + +build_doctor_report :: proc() -> string { + cfg := shared.load_config() + has_deepseek := len(cfg.deepseek_api_key) > 0 + has_fal := len(cfg.fal_api_key) > 0 + has_curl := command_available("curl") + has_python := command_available("python3") + return fmt.aprintf("Doctor\n- deepseek key: %s\n- fal key: %s\n- curl: %s\n- python3: %s", bool_text(has_deepseek), bool_text(has_fal), bool_text(has_curl), bool_text(has_python)) +} + +build_ready_report :: proc(c: ui.App_Controller) -> string { + has_script := len(c.state.script.pages) > 0 + has_panels := len(c.state.panel_images) > 0 + has_layout := len(c.state.page_layouts) > 0 + can_export := has_layout && has_panels + return fmt.aprintf("Ready\n- script generated: %s\n- panel images generated: %s\n- layout generated: %s\n- export ready: %s", bool_text(has_script), bool_text(has_panels), bool_text(has_layout), bool_text(can_export)) +} + +next_action_hint :: proc(c: ui.App_Controller) -> string { + cfg := shared.load_config() + if len(c.state.script.pages) == 0 { + if len(cfg.deepseek_api_key) > 0 { + return "next: generate script 4" + } + return "next: generate script local 2" + } + if len(c.state.panel_images) == 0 { + if len(cfg.fal_api_key) > 0 { + return "next: generate panels" + } + return "next: generate panels local" + } + if len(c.state.page_layouts) == 0 { + return "next: layout auto" + } + return "next: export pdf ./comic.pdf" +} + +plan_report :: proc(c: ui.App_Controller) -> string { + script_done := len(c.state.script.pages) > 0 + panels_done := len(c.state.panel_images) > 0 + layout_done := len(c.state.page_layouts) > 0 + export_ready := panels_done && layout_done + + return fmt.aprintf( + "Plan\n- [%-3s] 1) Script\n- [%-3s] 2) Panels\n- [%-3s] 3) Layout\n- [%-3s] 4) Export\n%s", + bool_text(script_done), + bool_text(panels_done), + bool_text(layout_done), + bool_text(export_ready), + next_action_hint(c), + ) +} + +collect_script_panels :: proc(script: core.Comic_Script) -> []core.Panel { + out: [dynamic]core.Panel + for p in script.pages { + for pan in p.panels { + append(&out, pan) + } + } + return out[:] +} + +collect_script_panels_for_page :: proc(script: core.Comic_Script, page_number: int) -> []core.Panel { + out: [dynamic]core.Panel + for p in script.pages { + if p.page_number != page_number { + continue + } + for pan in p.panels { + append(&out, pan) + } + } + return out[:] +} + +parse_generate_script_pages :: proc(input: string) -> (int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if trimmed == "generate script" { + return 4, true, shared.ok() + } + if !strings.has_prefix(trimmed, "generate script ") || strings.has_prefix(trimmed, "generate script local") { + return 0, false, shared.ok() + } + + raw := strings.trim_space(trimmed[len("generate script "):]) + pages, ok := strconv.parse_int(raw) + if !ok || pages <= 0 { + return 0, true, shared.validation_error("generate script pages must be a positive integer") + } + return pages, true, shared.ok() +} + +parse_generate_script_local_pages :: proc(input: string) -> (int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if trimmed == "generate script local" { + return 4, true, shared.ok() + } + if !strings.has_prefix(trimmed, "generate script local ") { + return 0, false, shared.ok() + } + + raw := strings.trim_space(trimmed[len("generate script local "):]) + pages, ok := strconv.parse_int(raw) + if !ok || pages <= 0 { + return 0, true, shared.validation_error("generate script local pages must be a positive integer") + } + return pages, true, shared.ok() +} + +local_panel_id_by_index :: proc(i: int) -> string { + switch i { + case 0: return "panel_local_001" + case 1: return "panel_local_002" + case 2: return "panel_local_003" + case 3: return "panel_local_004" + case 4: return "panel_local_005" + case 5: return "panel_local_006" + case 6: return "panel_local_007" + case 7: return "panel_local_008" + case 8: return "panel_local_009" + } + return "panel_local_overflow" +} + +build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script { + out_pages: [dynamic]core.Page + for i in 0.. (int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if trimmed == "generate panels" { + return 0, true, shared.ok() + } + if !strings.has_prefix(trimmed, "generate panels page ") || strings.has_prefix(trimmed, "generate panels local") { + return 0, false, shared.ok() + } + + raw := strings.trim_space(trimmed[len("generate panels page "):]) + page, ok := strconv.parse_int(raw) + if !ok || page <= 0 { + return 0, true, shared.validation_error("generate panels page must be a positive integer") + } + return page, true, shared.ok() +} + +parse_generate_panels_local_page :: proc(input: string) -> (int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if trimmed == "generate panels local" { + return 0, true, shared.ok() + } + if !strings.has_prefix(trimmed, "generate panels local page ") { + return 0, false, shared.ok() + } + + raw := strings.trim_space(trimmed[len("generate panels local page "):]) + page, ok := strconv.parse_int(raw) + if !ok || page <= 0 { + return 0, true, shared.validation_error("generate panels local page must be a positive integer") + } + return page, true, shared.ok() +} + +build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel_Image, shared.App_Error) { + tmp_dir, terr := os.make_directory_temp("", "comic-local-panels-*", context.temp_allocator) + if terr != nil { + return nil, shared.new_error(.Generation, "failed to create local panel temp dir", true) + } + + images := make(map[string]core.Panel_Image) + for p, idx in panels { + name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id) + out_path := fmt.aprintf("%s/%s", tmp_dir, name) + delete(name) + if werr := os.write_entire_file(out_path, "LOCAL PANEL IMAGE"); werr != nil { + delete(out_path) + return nil, shared.new_error(.Generation, "failed writing local panel image", true) + } + url := fmt.aprintf("file://%s", out_path) + prompt := fmt.aprintf("local") + images[p.panel_id] = core.Panel_Image{url = url, width = 1024, height = 1024, seed = i64(idx + 1), prompt = prompt} + delete(out_path) + } + return images, shared.ok() +} + +parse_export_command :: proc(input: string) -> (core.Export_Format, string, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if !strings.has_prefix(trimmed, "export ") { + return .PDF, "", false, shared.ok() + } + + rest := strings.trim_space(trimmed[len("export "):]) + sp := strings.index(rest, " ") + if sp < 0 { + return .PDF, "", true, shared.validation_error("usage: export ") + } + + fmt_name := strings.trim_space(rest[:sp]) + out_path := strings.trim_space(rest[sp+1:]) + if len(out_path) == 0 { + return .PDF, "", true, shared.validation_error("export path is required") + } + + switch fmt_name { + case "pdf": return .PDF, out_path, true, shared.ok() + case "png": return .PNG, out_path, true, shared.ok() + case "cbz": return .CBZ, out_path, true, shared.ok() + } + + return .PDF, "", true, shared.validation_error("unknown export format") +} + +parse_quick_local_command :: proc(input: string) -> (core.Export_Format, string, int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if !strings.has_prefix(trimmed, "quick local ") || strings.has_prefix(trimmed, "quick local all ") { + return .PDF, "", 0, false, shared.ok() + } + + rest := strings.trim_space(trimmed[len("quick local "):]) + parts, ferr := strings.fields(rest) + if ferr != nil { + return .PDF, "", 0, true, shared.new_error(.Validation, "quick local parse failed", true) + } + defer delete(parts) + + if len(parts) < 2 || len(parts) > 3 { + return .PDF, "", 0, true, shared.validation_error("usage: quick local [pages]") + } + + fmt_name := parts[0] + out_path := parts[1] + if len(out_path) == 0 { + return .PDF, "", 0, true, shared.validation_error("quick local export path is required") + } + + pages := 2 + if len(parts) == 3 { + v, ok := strconv.parse_int(parts[2]) + if !ok || v <= 0 { + return .PDF, "", 0, true, shared.validation_error("quick local pages must be a positive integer") + } + pages = v + } + + switch fmt_name { + case "pdf": return .PDF, out_path, pages, true, shared.ok() + case "png": return .PNG, out_path, pages, true, shared.ok() + case "cbz": return .CBZ, out_path, pages, true, shared.ok() + } + + return .PDF, "", 0, true, shared.validation_error("unknown quick local export format") +} + +parse_quick_local_all_command :: proc(input: string) -> (string, core.Export_Format, string, int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if !strings.has_prefix(trimmed, "quick local all ") { + return "", .PDF, "", 0, false, shared.ok() + } + + rest := strings.trim_space(trimmed[len("quick local all "):]) + parts, ferr := strings.fields(rest) + if ferr != nil { + return "", .PDF, "", 0, true, shared.new_error(.Validation, "quick local all parse failed", true) + } + defer delete(parts) + + if len(parts) < 3 || len(parts) > 4 { + return "", .PDF, "", 0, true, shared.validation_error("usage: quick local all [pages]") + } + + project_path := parts[0] + fmt_name := parts[1] + export_path := parts[2] + if len(project_path) == 0 || len(export_path) == 0 { + return "", .PDF, "", 0, true, shared.validation_error("quick local all paths are required") + } + + pages := 2 + if len(parts) == 4 { + v, ok := strconv.parse_int(parts[3]) + if !ok || v <= 0 { + return "", .PDF, "", 0, true, shared.validation_error("quick local all pages must be a positive integer") + } + pages = v + } + + switch fmt_name { + case "pdf": return project_path, .PDF, export_path, pages, true, shared.ok() + case "png": return project_path, .PNG, export_path, pages, true, shared.ok() + case "cbz": return project_path, .CBZ, export_path, pages, true, shared.ok() + } + + return "", .PDF, "", 0, true, shared.validation_error("unknown quick local all export format") +} + +parse_auto_all_command :: proc(input: string) -> (core.Export_Format, string, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if !strings.has_prefix(trimmed, "auto all ") || strings.has_prefix(trimmed, "auto all local ") { + return .PDF, "", false, shared.ok() + } + + rest := strings.trim_space(trimmed[len("auto all "):]) + parts, ferr := strings.fields(rest) + if ferr != nil { + return .PDF, "", true, shared.new_error(.Validation, "auto all parse failed", true) + } + defer delete(parts) + + if len(parts) != 2 { + return .PDF, "", true, shared.validation_error("usage: auto all ") + } + + switch parts[0] { + case "pdf": return .PDF, parts[1], true, shared.ok() + case "png": return .PNG, parts[1], true, shared.ok() + case "cbz": return .CBZ, parts[1], true, shared.ok() + } + return .PDF, "", true, shared.validation_error("unknown auto all export format") +} + +parse_auto_all_local_command :: proc(input: string) -> (core.Export_Format, string, int, bool, shared.App_Error) { + trimmed := strings.trim_space(input) + if !strings.has_prefix(trimmed, "auto all local ") { + return .PDF, "", 0, false, shared.ok() + } + + rest := strings.trim_space(trimmed[len("auto all local "):]) + parts, ferr := strings.fields(rest) + if ferr != nil { + return .PDF, "", 0, true, shared.new_error(.Validation, "auto all local parse failed", true) + } + defer delete(parts) + + if len(parts) < 2 || len(parts) > 3 { + return .PDF, "", 0, true, shared.validation_error("usage: auto all local [pages]") + } + + pages := 2 + if len(parts) == 3 { + v, ok := strconv.parse_int(parts[2]) + if !ok || v <= 0 { + return .PDF, "", 0, true, shared.validation_error("auto all local pages must be a positive integer") + } + pages = v + } + + switch parts[0] { + case "pdf": return .PDF, parts[1], pages, true, shared.ok() + case "png": return .PNG, parts[1], pages, true, shared.ok() + case "cbz": return .CBZ, parts[1], pages, true, shared.ok() + } + return .PDF, "", 0, true, shared.validation_error("unknown auto all local export format") +} + +run_quick_local_pipeline :: proc(controller: ^ui.App_Controller, export_format: core.Export_Format, export_path: string, pages: int) -> shared.App_Error { + story := controller.state.story_idea + if len(story) == 0 { + story = "A local adventure" + } + + script := build_local_script(story, pages) + core.dispose_script(&controller.state.script) + controller.state.script = script + controller.state.characters = controller.state.script.characters + + panels := collect_script_panels(controller.state.script) + defer delete(panels) + if len(panels) == 0 { + return shared.validation_error("quick local failed: no panels") + } + + images, lerr := build_local_panel_images(panels) + if !shared.is_ok(lerr) { + return lerr + } + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = images + + core.dispose_page_layouts(&controller.state.page_layouts) + controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "") + + opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} + err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) + if !shared.is_ok(err) { + controller.state.workflow.error_message = err.message + return err + } + controller.state.export_format = export_format + controller.active_screen = .Export + controller.state.workflow.current_step = .Complete + controller.state.workflow.error_message = "" + return shared.ok() +} + +clear_screen :: proc() { + fmt.print("\x1b[2J\x1b[H") +} + +normalize_tui_command :: proc(input: string) -> string { + cmd := strings.trim_space(input) + switch cmd { + case "h": return "help" + case "s": return "status" + case "?": return "doctor" + case "r": return "ready" + case "n": return "next" + case "p": return "plan" + case "x": return "auto" + case "d": return "done" + case "c": return "cancel" + case "q": return "quit" + case "1": return "goto story" + case "2": return "goto script" + case "3": return "goto characters" + case "4": return "goto panels" + case "5": return "goto layout" + case "6": return "goto bubbles" + case "7": return "goto export" + case "8": return "goto community" + } + return cmd +} + +screen_from_name :: proc(s: string) -> (ui.App_Screen, bool) { + switch s { + case "story": return .Story, true + case "script": return .Script, true + case "characters": return .Characters, true + case "panels": return .Panels, true + case "layout": return .Layout, true + case "bubbles", "speech": return .Bubbles, true + case "export": return .Export, true + case "community": return .Community, true + } + return .Story, false +} + +workflow_from_name :: proc(s: string) -> (core.Workflow_Step, bool) { + switch s { + case "story": return .Story_Input, true + case "generating-script": return .Generating_Script, true + case "script-review": return .Script_Review, true + case "character-setup": return .Character_Setup, true + case "generating-panels": return .Generating_Panels, true + case "layout": return .Layout, true + case "speech-bubbles": return .Speech_Bubbles, true + case "complete": return .Complete, true + } + return .Story_Input, false +} + +job_type_from_name :: proc(s: string) -> (ui.Job_Type, bool) { + switch s { + case "script": return .Generate_Script, true + case "character": return .Generate_Character, true + case "panel": return .Generate_Panel, true + case "export": return .Export, true + } + return .Generate_Script, false +} + +read_stdin_line :: proc() -> (line: string, ok: bool, err: shared.App_Error) { + buf: [dynamic]u8 + one: [1]u8 + + for { + n, rerr := os.read(os.stdin, one[:]) + if rerr != nil { + if rerr == .EOF { + if len(buf) == 0 { + return "", false, shared.ok() + } + break + } + delete(buf) + return "", false, shared.new_error(.Config, "stdin read error", true) + } + if n == 0 { + continue + } + if one[0] == '\n' { + break + } + if one[0] != '\r' { + append(&buf, one[0]) + } + } + + return string(buf[:]), true, shared.ok() +} + +run_tui_command :: proc(controller: ^ui.App_Controller, input: string, last_job_id: ^int) -> (quit: bool, out: string, err: shared.App_Error) { + if input == "help" { + return false, fmt.aprintf("%s", tui_help_text()), shared.ok() + } + if input == "status" { + return false, ui.render_app_to_string(controller^), shared.ok() + } + if input == "doctor" { + return false, build_doctor_report(), shared.ok() + } + if input == "ready" { + return false, build_ready_report(controller^), shared.ok() + } + if input == "next" { + return false, fmt.aprintf("%s", next_action_hint(controller^)), shared.ok() + } + if input == "plan" { + return false, plan_report(controller^), shared.ok() + } + auto_all_local_format, auto_all_local_path, auto_all_local_pages, is_auto_all_local, aalerr := parse_auto_all_local_command(input) + if !shared.is_ok(aalerr) { + return false, "", aalerr + } + if is_auto_all_local { + if err := run_quick_local_pipeline(controller, auto_all_local_format, auto_all_local_path, auto_all_local_pages); !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("auto all local exported %s (%d pages)", auto_all_local_path, auto_all_local_pages), shared.ok() + } + + auto_all_format, auto_all_path, is_auto_all, aaerr := parse_auto_all_command(input) + if !shared.is_ok(aaerr) { + return false, "", aaerr + } + if is_auto_all { + for i in 0..<6 { + hint := next_action_hint(controller^) + if strings.has_prefix(hint, "next: export ") { + export_cmd := fmt.aprintf("export %s %s", export_format_name(auto_all_format), auto_all_path) + _, out, eerr := run_tui_command(controller, export_cmd, last_job_id) + delete(export_cmd) + if !shared.is_ok(eerr) { + return false, "", eerr + } + if len(out) > 0 { delete(out) } + return false, fmt.aprintf("auto all exported %s", auto_all_path), shared.ok() + } + if !strings.has_prefix(hint, "next: ") { + return false, "", shared.validation_error("auto all failed: invalid next hint") + } + next_cmd := strings.trim_space(hint[len("next: "):]) + _, out, aerr := run_tui_command(controller, next_cmd, last_job_id) + if len(out) > 0 { delete(out) } + if !shared.is_ok(aerr) { + return false, "", aerr + } + } + return false, "", shared.validation_error("auto all exceeded step limit") + } + + if input == "auto" { + hint := next_action_hint(controller^) + if !strings.has_prefix(hint, "next: ") { + return false, "", shared.validation_error("auto failed: invalid next hint") + } + next_cmd := strings.trim_space(hint[len("next: "):]) + _, out, aerr := run_tui_command(controller, next_cmd, last_job_id) + if !shared.is_ok(aerr) { + return false, "", aerr + } + if len(out) > 0 { + msg := fmt.aprintf("auto ran: %s\n%s", next_cmd, out) + delete(out) + return false, msg, shared.ok() + } + return false, fmt.aprintf("auto ran: %s", next_cmd), shared.ok() + } + if input == "done" { + if last_job_id^ <= 0 { + return false, "", shared.validation_error("no active job") + } + err = ui.finish_background_job(controller, last_job_id^, "") + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("job marked done"), shared.ok() + } + if input == "cancel" { + if last_job_id^ <= 0 { + return false, "", shared.validation_error("no active job") + } + err = ui.cancel_background_job(controller, last_job_id^) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("job cancelled"), shared.ok() + } + if input == "quit" || input == "exit" { + return true, fmt.aprintf("exiting tui"), shared.ok() + } + + if strings.has_prefix(input, "goto ") { + target_name := strings.trim_space(input[len("goto "):]) + target, ok := screen_from_name(target_name) + if !ok { + return false, "", shared.validation_error("unknown screen") + } + err = ui.navigate_to_screen(controller, target) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("screen: %s", ui.screen_name(target)), shared.ok() + } + + if strings.has_prefix(input, "step ") { + step_name := strings.trim_space(input[len("step "):]) + next, ok := workflow_from_name(step_name) + if !ok { + return false, "", shared.validation_error("unknown workflow step") + } + err = ui.set_workflow_step(controller, next) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("step: %v", next), shared.ok() + } + + if strings.trim_space(input) == "new" { + core.dispose_state(&controller.state) + controller.state = core.new_initial_state() + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + last_job_id^ = 0 + return false, fmt.aprintf("new project initialized"), shared.ok() + } + + local_pages, is_generate_local_script, lserr := parse_generate_script_local_pages(input) + if !shared.is_ok(lserr) { + return false, "", lserr + } + if is_generate_local_script { + core.set_workflow_step(&controller.state, .Generating_Script) + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + controller.state.workflow.is_generating = true + controller.state.workflow.generation_progress = 25 + + script := build_local_script(controller.state.story_idea, local_pages) + core.dispose_script(&controller.state.script) + controller.state.script = script + controller.state.characters = controller.state.script.characters + core.set_workflow_step(&controller.state, .Script_Review) + controller.active_screen = .Script + controller.state.workflow.is_generating = false + controller.state.workflow.generation_progress = 100 + controller.state.workflow.error_message = "" + return false, fmt.aprintf("local script generated (%d pages)", local_pages), shared.ok() + } + + script_pages, is_generate_script, pserr := parse_generate_script_pages(input) + if !shared.is_ok(pserr) { + return false, "", pserr + } + if is_generate_script { + cfg := shared.load_config() + core.set_workflow_step(&controller.state, .Generating_Script) + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + controller.state.workflow.is_generating = true + controller.state.workflow.generation_progress = 25 + + opts := adapters.Generate_Script_Options{ + story_idea = controller.state.story_idea, + genre = controller.state.story_genre, + art_style = controller.state.art_style, + num_pages = script_pages, + audience = controller.state.target_audience, + } + script, gerr := adapters.generate_comic_script_stub(cfg, opts) + if !shared.is_ok(gerr) { + controller.state.workflow.is_generating = false + controller.state.workflow.error_message = gerr.message + controller.state.workflow.current_step = .Story_Input + controller.active_screen = .Story + return false, "", gerr + } + + core.dispose_script(&controller.state.script) + controller.state.script = script + controller.state.characters = controller.state.script.characters + core.set_workflow_step(&controller.state, .Script_Review) + controller.active_screen = .Script + controller.state.workflow.is_generating = false + controller.state.workflow.generation_progress = 100 + controller.state.workflow.error_message = "" + return false, fmt.aprintf("script generated (%d pages)", script_pages), shared.ok() + } + + panel_local_page, is_generate_panels_local, plerr := parse_generate_panels_local_page(input) + if !shared.is_ok(plerr) { + return false, "", plerr + } + if is_generate_panels_local { + panels: []core.Panel + if panel_local_page > 0 { + panels = collect_script_panels_for_page(controller.state.script, panel_local_page) + } else { + panels = collect_script_panels(controller.state.script) + } + defer delete(panels) + if len(panels) == 0 { + return false, "", shared.validation_error("no script panels available") + } + + core.set_workflow_step(&controller.state, .Generating_Panels) + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + controller.state.workflow.is_generating = true + controller.state.workflow.generation_progress = 35 + + images, gerr := build_local_panel_images(panels) + if !shared.is_ok(gerr) { + controller.state.workflow.is_generating = false + controller.state.workflow.error_message = gerr.message + return false, "", gerr + } + + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = images + core.set_workflow_step(&controller.state, .Layout) + controller.active_screen = .Layout + controller.state.workflow.is_generating = false + controller.state.workflow.generation_progress = 100 + controller.state.workflow.error_message = "" + if panel_local_page > 0 { + return false, fmt.aprintf("local panel images generated for page %d (%d)", panel_local_page, len(images)), shared.ok() + } + return false, fmt.aprintf("local panel images generated (%d)", len(images)), shared.ok() + } + + panel_page, is_generate_panels, pperr := parse_generate_panels_page(input) + if !shared.is_ok(pperr) { + return false, "", pperr + } + if is_generate_panels { + cfg := shared.load_config() + panels: []core.Panel + if panel_page > 0 { + panels = collect_script_panels_for_page(controller.state.script, panel_page) + } else { + panels = collect_script_panels(controller.state.script) + } + defer delete(panels) + if len(panels) == 0 { + return false, "", shared.validation_error("no script panels available") + } + + core.set_workflow_step(&controller.state, .Generating_Panels) + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + controller.state.workflow.is_generating = true + controller.state.workflow.generation_progress = 35 + + 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) + if !shared.is_ok(gerr) { + controller.state.workflow.is_generating = false + controller.state.workflow.error_message = gerr.message + return false, "", gerr + } + + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = images + core.set_workflow_step(&controller.state, .Layout) + controller.active_screen = .Layout + controller.state.workflow.is_generating = false + controller.state.workflow.generation_progress = 100 + controller.state.workflow.error_message = "" + if panel_page > 0 { + return false, fmt.aprintf("panel images generated for page %d (%d)", panel_page, len(images)), shared.ok() + } + return false, fmt.aprintf("panel images generated (%d)", len(images)), shared.ok() + } + + if strings.trim_space(input) == "layout auto" { + panels := collect_script_panels(controller.state.script) + defer delete(panels) + if len(panels) == 0 { + return false, "", shared.validation_error("no script panels available") + } + core.dispose_page_layouts(&controller.state.page_layouts) + controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "") + controller.state.workflow.current_step = .Layout + controller.active_screen = .Layout + return false, fmt.aprintf("layout generated (%d pages)", len(controller.state.page_layouts)), shared.ok() + } + + project_path, quick_all_format, quick_all_export_path, quick_all_pages, is_quick_local_all, qaerr := parse_quick_local_all_command(input) + if !shared.is_ok(qaerr) { + return false, "", qaerr + } + if is_quick_local_all { + if err := run_quick_local_pipeline(controller, quick_all_format, quick_all_export_path, quick_all_pages); !shared.is_ok(err) { + return false, "", err + } + err = adapters.save_project(project_path, controller.state) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("quick local all saved %s and exported %s (%d pages)", project_path, quick_all_export_path, quick_all_pages), shared.ok() + } + + quick_format, quick_path, quick_pages, is_quick_local, qerr := parse_quick_local_command(input) + if !shared.is_ok(qerr) { + return false, "", qerr + } + if is_quick_local { + if err := run_quick_local_pipeline(controller, quick_format, quick_path, quick_pages); !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("quick local exported %s (%d pages)", quick_path, quick_pages), shared.ok() + } + + export_format, export_path, is_export, exerr := parse_export_command(input) + if !shared.is_ok(exerr) { + return false, "", exerr + } + if is_export { + opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} + err = adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) + if !shared.is_ok(err) { + controller.state.workflow.error_message = err.message + return false, "", err + } + controller.state.export_format = export_format + controller.active_screen = .Export + controller.state.workflow.current_step = .Complete + controller.state.workflow.error_message = "" + return false, fmt.aprintf("exported %s", export_path), shared.ok() + } + + if strings.has_prefix(input, "set idea ") { + v := strings.trim_space(input[len("set idea "):]) + controller.state.story_idea = fmt.aprintf("%s", v) + return false, fmt.aprintf("story idea updated"), shared.ok() + } + + if strings.has_prefix(input, "set genre ") { + v := strings.trim_space(input[len("set genre "):]) + controller.state.story_genre = fmt.aprintf("%s", v) + return false, fmt.aprintf("story genre updated"), shared.ok() + } + + if strings.has_prefix(input, "set audience ") { + v := strings.trim_space(input[len("set audience "):]) + controller.state.target_audience = fmt.aprintf("%s", v) + return false, fmt.aprintf("target audience updated"), shared.ok() + } + + if strings.has_prefix(input, "saveas ") { + path := strings.trim_space(input[len("saveas "):]) + err = adapters.save_project(path, controller.state) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("saved to %s", path), shared.ok() + } + + if strings.has_prefix(input, "save ") { + path := strings.trim_space(input[len("save "):]) + err = adapters.save_project(path, controller.state) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("saved to %s", path), shared.ok() + } + + if strings.has_prefix(input, "open ") { + path := strings.trim_space(input[len("open "):]) + loaded, lerr := adapters.load_project(path) + if !shared.is_ok(lerr) { + return false, "", lerr + } + core.dispose_state(&controller.state) + controller.state = loaded + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + return false, fmt.aprintf("loaded from %s", path), shared.ok() + } + + if strings.has_prefix(input, "load ") { + path := strings.trim_space(input[len("load "):]) + loaded, lerr := adapters.load_project(path) + if !shared.is_ok(lerr) { + return false, "", lerr + } + core.dispose_state(&controller.state) + controller.state = loaded + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + return false, fmt.aprintf("loaded from %s", path), shared.ok() + } + + if strings.has_prefix(input, "start ") { + name := strings.trim_space(input[len("start "):]) + job_type, ok := job_type_from_name(name) + if !ok { + return false, "", shared.validation_error("unknown job type") + } + id := ui.start_background_job(controller, job_type, "job") + _ = ui.mark_job_running(&controller.jobs, id) + last_job_id^ = id + return false, fmt.aprintf("started job %d", id), shared.ok() + } + + if strings.has_prefix(input, "progress ") { + v := strings.trim_space(input[len("progress "):]) + n, ok := strconv.parse_int(v) + if !ok { + return false, "", shared.validation_error("invalid progress") + } + if n < 0 || n > 100 { + return false, "", shared.validation_error("progress must be 0..100") + } + ui.set_generation_progress(controller, f32(n)) + controller.state.workflow.is_generating = true + return false, fmt.aprintf("progress set to %d", n), shared.ok() + } + + if strings.has_prefix(input, "fail ") { + if last_job_id^ <= 0 { + return false, "", shared.validation_error("no active job") + } + msg := strings.trim_space(input[len("fail "):]) + if len(msg) == 0 { + msg = "job failed" + } + err = ui.finish_background_job(controller, last_job_id^, msg) + if !shared.is_ok(err) { + return false, "", err + } + return false, fmt.aprintf("job marked failed"), shared.ok() + } + + return false, "", shared.validation_error("unknown command") +} + +run_tui_loop :: proc(state: ^core.Comic_State) -> (string, shared.App_Error) { + controller := ui.new_controller(state^) + defer ui.dispose_job_manager(&controller.jobs) + + last_job_id := 0 + + for { + clear_screen() + fmt.println("comic-odin interactive tui") + fmt.println("screens: [1]Story [2]Script [3]Characters [4]Panels [5]Layout [6]Bubbles [7]Export [8]Community") + fmt.println("shortcuts: h help | s status | ? doctor | r ready | n next | p plan | x auto | d done | c cancel | q quit") + fmt.println() + view := ui.render_app_to_string(controller) + fmt.println(view) + delete(view) + fmt.print("\ntui> ") + + line, ok, rerr := read_stdin_line() + if !shared.is_ok(rerr) { + return "", rerr + } + if !ok { + break + } + + cmd := normalize_tui_command(line) + if len(cmd) == 0 { + delete(line) + continue + } + + quit, out, cerr := run_tui_command(&controller, cmd, &last_job_id) + delete(line) + if !shared.is_ok(cerr) { + fmt.printf("error: %s\n", cerr.message) + } else if len(out) > 0 { + fmt.println(out) + delete(out) + } + if quit { + break + } + } + + core.dispose_state(state) + state^ = controller.state + controller.state = core.Comic_State{} + return "", shared.ok() +} + +run_cli_command :: proc(cmd: Parsed_CLI_Command, state: ^core.Comic_State) -> (string, shared.App_Error) { + switch cmd.kind { + case .Demo: + controller := ui.new_controller(state^) + defer ui.dispose_controller(&controller) + job_id := ui.start_background_job(&controller, .Generate_Script, "Generating script") + _ = ui.mark_job_running(&controller.jobs, job_id) + ui.set_generation_progress(&controller, 35) + _ = ui.finish_background_job(&controller, job_id, "") + return ui.render_app_to_string(controller), shared.ok() + case .Status: + controller := ui.new_controller(state^) + defer ui.dispose_controller(&controller) + return ui.render_app_to_string(controller), shared.ok() + case .Save: + if len(cmd.path) == 0 { + return usage_text(), shared.validation_error("missing save path") + } + err := adapters.save_project(cmd.path, state^) + if !shared.is_ok(err) { + return "", err + } + return fmt.aprintf("Saved project to %s", cmd.path), shared.ok() + case .Load: + if len(cmd.path) == 0 { + return usage_text(), shared.validation_error("missing load path") + } + loaded, err := adapters.load_project(cmd.path) + if !shared.is_ok(err) { + return "", err + } + core.dispose_state(state) + state^ = loaded + return fmt.aprintf("Loaded project from %s", cmd.path), shared.ok() + case .Tui: + return run_tui_loop(state) + case .Gui: + err := gui.run_gui_app(state) + if !shared.is_ok(err) { + return "", err + } + return fmt.aprintf("GUI session ended"), shared.ok() + case .Help: + fallthrough + case: + return usage_text(), shared.ok() + } +} + +run_cli_from_process_args :: proc(state: ^core.Comic_State) -> (string, shared.App_Error) { + if len(os.args) <= 1 { + return run_cli_command(Parsed_CLI_Command{kind = .Demo}, state) + } + cmd := parse_cli_command(os.args[1:]) + return run_cli_command(cmd, state) +} diff --git a/odin/src/app/main.odin b/odin/src/app/main.odin new file mode 100644 index 0000000..f00822f --- /dev/null +++ b/odin/src/app/main.odin @@ -0,0 +1,25 @@ +package main + +import "core:fmt" +import "../core" +import "../shared" + +main :: proc() { + cfg := shared.load_config() + state := core.new_initial_state() + defer core.dispose_state(&state) + + fmt.println("comic-odin: phase6 cli runtime") + fmt.printf("DeepSeek configured: %v\n", len(cfg.deepseek_api_key) > 0) + + out, err := run_cli_from_process_args(&state) + if !shared.is_ok(err) { + fmt.printf("Error: %s\n", err.message) + if len(out) > 0 { + fmt.println(out) + } + return + } + + fmt.println(out) +} diff --git a/odin/src/core/bubble.odin b/odin/src/core/bubble.odin new file mode 100644 index 0000000..78ffa4c --- /dev/null +++ b/odin/src/core/bubble.odin @@ -0,0 +1,163 @@ +package core + +import "core:fmt" + +DEFAULT_BUBBLE_STYLE := Speech_Bubble_Style{ + background_color = "#ffffff", + border_color = "#000000", + border_width = 2, + border_radius = 16, + font_family = "Comic Sans MS, cursive, sans-serif", + font_size = 14, + text_color = "#000000", + padding = 12, +} + +clampf :: proc(v, minv, maxv: f32) -> f32 { + if v < minv { return minv } + if v > maxv { return maxv } + return v +} + +calculate_bubble_size :: proc(text: string, style: Speech_Bubble_Style) -> Size { + char_width := style.font_size * 0.6 + char_height := style.font_size * 1.4 + chars_per_line: f32 = 25 + lines := f32(len(text)) / chars_per_line + if lines < 1 { + lines = 1 + } + + width := f32(len(text))*char_width/lines + style.padding*2 + width = clampf(width, 100, 300) + height := lines*char_height + style.padding*2 + 20 + + return Size{width = width, height = height} +} + +auto_place_bubble :: proc(bubble: Speech_Bubble, panel_w, panel_h: f32, speaker_pos: Position) -> Speech_Bubble { + updated := bubble + size := calculate_bubble_size(bubble.text, bubble.style) + updated.size = size + + speaker := speaker_pos + if speaker.x == 0 && speaker.y == 0 { + speaker = Position{x = panel_w * 0.5, y = panel_h * 0.7} + } + + switch bubble.type { + case .Thought: + updated.position = Position{ + x = clampf(speaker.x-size.width/2, 20, panel_w-size.width-20), + y = clampf(speaker.y-size.height-40, 20, panel_h-size.height-10), + } + updated.tail_target = Position{x = speaker.x, y = speaker.y - 20} + updated.tail_direction = "bottom" + case .Shout: + updated.size = Size{width = size.width * 1.1, height = size.height * 1.1} + updated.position = Position{ + x = clampf(speaker.x-updated.size.width/2, 10, panel_w-updated.size.width-10), + y = clampf(speaker.y-updated.size.height-30, 10, panel_h-updated.size.height-10), + } + updated.tail_target = Position{x = speaker.x, y = speaker.y - 10} + updated.tail_direction = "bottom" + case .Whisper: + updated.position = Position{ + x = clampf(speaker.x-size.width/2, 20, panel_w-size.width-20), + y = clampf(speaker.y-size.height-30, 20, panel_h-size.height-10), + } + updated.tail_target = Position{x = speaker.x, y = speaker.y - 20} + updated.tail_direction = "bottom" + case .Narration: + updated.position = Position{x = panel_w * 0.1, y = 20} + updated.size = Size{width = panel_w * 0.8, height = size.height} + updated.tail_direction = "bottom" + case .Sound_Effect: + updated.position = Position{ + x = clampf(speaker.x+20, 10, panel_w-size.width-10), + y = clampf(speaker.y-size.height-50, 10, panel_h-size.height-10), + } + updated.tail_target = Position{x = speaker.x + 30, y = speaker.y - 30} + updated.tail_direction = "bottom-left" + case .Normal: + updated.position = Position{ + x = clampf(speaker.x-size.width/2, 20, panel_w-size.width-20), + y = clampf(speaker.y-size.height-30, 20, panel_h-size.height-10), + } + updated.tail_target = Position{x = speaker.x, y = speaker.y - 20} + updated.tail_direction = "bottom" + } + + return updated +} + +auto_place_panel_bubbles :: proc(panel: Panel, panel_w, panel_h: f32) -> []Speech_Bubble { + if len(panel.dialogue) == 0 && len(panel.caption) == 0 { + return nil + } + + bubbles: [dynamic]Speech_Bubble + + for dialogue, idx in panel.dialogue { + speaker_count := len(panel.characters_present) + speaker_idx := 0 + for char_id, j in panel.characters_present { + if char_id == dialogue.speaker_id { + speaker_idx = j + break + } + } + + speaker_x := panel_w * 0.5 + if speaker_count > 1 { + speaker_x = (panel_w / f32(speaker_count + 1)) * f32(speaker_idx + 1) + } + speaker_y := panel_h * 0.75 + + bubble := Speech_Bubble{ + id = fmt.aprintf("bubble_%s_%d", panel.panel_id, idx), + panel_id = panel.panel_id, + type = dialogue.bubble_type, + text = dialogue.text, + position = Position{}, + size = Size{width = 100, height = 50}, + tail_direction = "bottom", + tail_target = Position{x = speaker_x, y = speaker_y}, + style = DEFAULT_BUBBLE_STYLE, + speaker_id = dialogue.speaker_id, + } + + placed := auto_place_bubble(bubble, panel_w, panel_h, Position{x = speaker_x, y = speaker_y}) + placed.position.y = clampf(placed.position.y-f32(idx*10), 10, panel_h-placed.size.height-10) + placed.position.x = clampf(placed.position.x, 10, panel_w-placed.size.width-10) + + append(&bubbles, placed) + } + + if len(panel.caption) > 0 { + caption_bubble := Speech_Bubble{ + id = fmt.aprintf("caption_%s", panel.panel_id), + panel_id = panel.panel_id, + type = .Narration, + text = panel.caption, + position = Position{x = panel_w * 0.1, y = 20}, + size = Size{width = panel_w * 0.8, height = 40}, + tail_direction = "bottom", + tail_target = Position{x = panel_w * 0.5, y = 60}, + style = Speech_Bubble_Style{ + background_color = "#f5f5f5", + border_color = "#999999", + border_width = DEFAULT_BUBBLE_STYLE.border_width, + border_radius = DEFAULT_BUBBLE_STYLE.border_radius, + font_family = DEFAULT_BUBBLE_STYLE.font_family, + font_size = DEFAULT_BUBBLE_STYLE.font_size, + text_color = DEFAULT_BUBBLE_STYLE.text_color, + padding = DEFAULT_BUBBLE_STYLE.padding, + }, + speaker_id = "", + } + append(&bubbles, caption_bubble) + } + + return bubbles[:] +} diff --git a/odin/src/core/character_prompt.odin b/odin/src/core/character_prompt.odin new file mode 100644 index 0000000..5158ccf --- /dev/null +++ b/odin/src/core/character_prompt.odin @@ -0,0 +1,108 @@ +package core + +import "core:fmt" + +DEFAULT_PROMPT_AGE :: "young adult" +DEFAULT_PROMPT_GENDER :: "person" +DEFAULT_PROMPT_BODY :: "average" +DEFAULT_PROMPT_OUTFIT :: "casual clothing" +DEFAULT_PROMPT_ACCESSORIES :: "no accessories" + +build_character_prompt :: proc(c: Character, action, setting, lighting, art_style: string) -> string { + t := c.prompt_template + + age := t.age + if len(age) == 0 { age = DEFAULT_PROMPT_AGE } + gender := t.gender + if len(gender) == 0 { gender = DEFAULT_PROMPT_GENDER } + body := t.body_type + if len(body) == 0 { body = DEFAULT_PROMPT_BODY } + outfit := t.outfit + if len(outfit) == 0 { outfit = DEFAULT_PROMPT_OUTFIT } + accessories := t.accessories + if len(accessories) == 0 { accessories = DEFAULT_PROMPT_ACCESSORIES } + + return fmt.aprintf( + "%s style. %s-year-old %s, %s %s hair, %s eyes, %s skin, %s build, wearing %s, %s, %s, %s, %s, %s", + art_style, + age, + gender, + t.hair_color, + t.hair_style, + t.eye_color, + t.skin_tone, + body, + outfit, + accessories, + t.distinguishing_features, + action, + setting, + lighting, + ) +} + +derive_seed_from_string :: proc(s: string) -> i64 { + hash: i32 = 0 + for r in s { + hash = ((hash << 5) - hash) + i32(r) + } + + res := i64(hash) + if res < 0 { + res = -res + } + return res % 2147483647 +} + +hash_char :: proc(hash: ^i32, c: u8) { + hash^ = ((hash^ << 5) - hash^) + i32(c) +} + +hash_string :: proc(hash: ^i32, s: string) { + for r in s { + hash^ = ((hash^ << 5) - hash^) + i32(r) + } +} + +hash_decimal_int :: proc(hash: ^i32, n: int) { + if n == 0 { + hash_char(hash, '0') + return + } + + value := n + if value < 0 { + hash_char(hash, '-') + value = -value + } + + buf: [20]u8 + i := len(buf) + for value > 0 { + i -= 1 + d := value % 10 + buf[i] = u8('0' + d) + value /= 10 + } + + for ; i < len(buf); i += 1 { + hash_char(hash, buf[i]) + } +} + +generate_panel_seed :: proc(project_id: string, page_number, panel_number: int, panel_id: string) -> i64 { + hash: i32 = 0 + hash_string(&hash, project_id) + hash_char(&hash, '_') + hash_decimal_int(&hash, page_number) + hash_char(&hash, '_') + hash_decimal_int(&hash, panel_number) + hash_char(&hash, '_') + hash_string(&hash, panel_id) + + res := i64(hash) + if res < 0 { + res = -res + } + return res % 2147483647 +} diff --git a/odin/src/core/dispose.odin b/odin/src/core/dispose.odin new file mode 100644 index 0000000..28d4953 --- /dev/null +++ b/odin/src/core/dispose.odin @@ -0,0 +1,140 @@ +package core + +dispose_character :: proc(c: Character) { + delete(c.character_sheet_urls) +} + +dispose_character_owned :: proc(c: Character) { + delete(c.id) + delete(c.name) + delete(c.description) + delete(c.reference_image_url) + delete(c.first_appearance_panel) + delete(c.character_sheet_urls) +} + +dispose_script :: proc(script: ^Comic_Script) { + for c in script.characters { + dispose_character(c) + } + for p in script.pages { + for pan in p.panels { + delete(pan.characters_present) + delete(pan.dialogue) + delete(pan.sound_effects) + } + delete(p.panels) + } + delete(script.characters) + delete(script.pages) +} + +dispose_script_owned :: proc(script: ^Comic_Script) { + delete(script.title) + delete(script.synopsis) + + for c in script.characters { + dispose_character_owned(c) + } + for p in script.pages { + for pan in p.panels { + delete(pan.panel_id) + delete(pan.description) + delete(pan.caption) + for id in pan.characters_present { delete(id) } + delete(pan.characters_present) + for d in pan.dialogue { + delete(d.speaker_id) + delete(d.text) + delete(d.emotion) + } + delete(pan.dialogue) + for s in pan.sound_effects { delete(s) } + delete(pan.sound_effects) + } + delete(p.panels) + } + delete(script.characters) + delete(script.pages) +} + +dispose_page_layouts :: proc(layouts: ^[]Page_Layout) { + for l in layouts^ { + delete(l.panels) + } + delete(layouts^) +} + +dispose_speech_bubbles :: proc(bubbles: ^map[string][]Speech_Bubble) { + for _, s in bubbles^ { + delete(s) + } + delete(bubbles^) +} + +dispose_state :: proc(state: ^Comic_State) { + dispose_script(&state.script) + + for c in state.characters { + dispose_character(c) + } + if raw_data(state.characters) != raw_data(state.script.characters) { + delete(state.characters) + } + + delete(state.panel_images) + delete(state.panel_errors) + dispose_page_layouts(&state.page_layouts) + dispose_speech_bubbles(&state.speech_bubbles) + delete(state.workflow.completed_steps) + + state.panel_images = nil + state.panel_errors = nil + state.speech_bubbles = nil + state.page_layouts = nil + state.characters = nil + state.workflow.completed_steps = nil +} + +dispose_state_owned :: proc(state: ^Comic_State) { + delete(state.project.project_id) + delete(state.project.project_name) + delete(state.project.created_at_iso) + delete(state.project.last_modified_iso) + delete(state.story_idea) + delete(state.story_genre) + delete(state.target_audience) + delete(state.art_style) + delete(state.workflow.error_message) + + dispose_script_owned(&state.script) + + for c in state.characters { + dispose_character_owned(c) + } + if raw_data(state.characters) != raw_data(state.script.characters) { + delete(state.characters) + } + + for _, img in state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(state.panel_images) + + for _, msg in state.panel_errors { + delete(msg) + } + delete(state.panel_errors) + + dispose_page_layouts(&state.page_layouts) + dispose_speech_bubbles(&state.speech_bubbles) + delete(state.workflow.completed_steps) + + state.panel_images = nil + state.panel_errors = nil + state.speech_bubbles = nil + state.page_layouts = nil + state.characters = nil + state.workflow.completed_steps = nil +} diff --git a/odin/src/core/layout.odin b/odin/src/core/layout.odin new file mode 100644 index 0000000..95df1a8 --- /dev/null +++ b/odin/src/core/layout.odin @@ -0,0 +1,264 @@ +package core + +Page_Size :: struct { + name: string, + width: int, + height: int, + dpi: int, +} + +get_page_size :: proc(name: Page_Size_Name) -> Page_Size { + switch name { + case .A4: + return Page_Size{"A4", 2480, 3508, 300} + case .Letter: + return Page_Size{"US Letter", 2550, 3300, 300} + case .Manga: + return Page_Size{"B5 Manga", 2158, 3035, 300} + case .Webtoon: + return Page_Size{"Webtoon", 800, 1280, 72} + case .Square: + return Page_Size{"Square", 1080, 1080, 72} + } + return Page_Size{"A4", 2480, 3508, 300} +} + +GRID_2X2_CELLS : [4]Layout_Cell = [4]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.47, h = 0.47}, + {x = 0.51, y = 0.02, w = 0.47, h = 0.47}, + {x = 0.02, y = 0.51, w = 0.47, h = 0.47}, + {x = 0.51, y = 0.51, w = 0.47, h = 0.47}, +} + +MANGA_3_TIER_CELLS : [6]Layout_Cell = [6]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.3, h = 0.3}, + {x = 0.35, y = 0.02, w = 0.3, h = 0.3}, + {x = 0.68, y = 0.02, w = 0.3, h = 0.3}, + {x = 0.02, y = 0.35, w = 0.47, h = 0.3}, + {x = 0.51, y = 0.35, w = 0.47, h = 0.3}, + {x = 0.02, y = 0.68, w = 0.96, h = 0.3}, +} + +ACTION_DYNAMIC_CELLS : [5]Layout_Cell = [5]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.6, h = 0.35}, + {x = 0.64, y = 0.02, w = 0.34, h = 0.35}, + {x = 0.02, y = 0.4, w = 0.34, h = 0.28}, + {x = 0.38, y = 0.4, w = 0.6, h = 0.28}, + {x = 0.02, y = 0.71, w = 0.96, h = 0.27}, +} + +SPLASH_CELLS : [1]Layout_Cell = [1]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.96, h = 0.96}, +} + +WEBTOON_CELLS : [3]Layout_Cell = [3]Layout_Cell{ + {x = 0.02, y = 0.01, w = 0.96, h = 0.32}, + {x = 0.02, y = 0.34, w = 0.96, h = 0.32}, + {x = 0.02, y = 0.67, w = 0.96, h = 0.32}, +} + +WESTERN_3X3_CELLS : [9]Layout_Cell = [9]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.3, h = 0.29}, + {x = 0.35, y = 0.02, w = 0.3, h = 0.29}, + {x = 0.68, y = 0.02, w = 0.3, h = 0.29}, + {x = 0.02, y = 0.35, w = 0.3, h = 0.29}, + {x = 0.35, y = 0.35, w = 0.3, h = 0.29}, + {x = 0.68, y = 0.35, w = 0.3, h = 0.29}, + {x = 0.02, y = 0.68, w = 0.3, h = 0.29}, + {x = 0.35, y = 0.68, w = 0.3, h = 0.29}, + {x = 0.68, y = 0.68, w = 0.3, h = 0.29}, +} + +DIALOGUE_HEAVY_CELLS : [8]Layout_Cell = [8]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.47, h = 0.22}, + {x = 0.51, y = 0.02, w = 0.47, h = 0.22}, + {x = 0.02, y = 0.26, w = 0.47, h = 0.22}, + {x = 0.51, y = 0.26, w = 0.47, h = 0.22}, + {x = 0.02, y = 0.5, w = 0.47, h = 0.22}, + {x = 0.51, y = 0.5, w = 0.47, h = 0.22}, + {x = 0.02, y = 0.74, w = 0.47, h = 0.22}, + {x = 0.51, y = 0.74, w = 0.47, h = 0.22}, +} + +CINEMATIC_CELLS : [4]Layout_Cell = [4]Layout_Cell{ + {x = 0.02, y = 0.02, w = 0.96, h = 0.22}, + {x = 0.02, y = 0.26, w = 0.96, h = 0.22}, + {x = 0.02, y = 0.5, w = 0.96, h = 0.22}, + {x = 0.02, y = 0.74, w = 0.96, h = 0.22}, +} + +pattern_max_panels :: proc(id: string) -> int { + switch id { + case "grid-2x2": + return 4 + case "manga-3-tier": + return 6 + case "action-dynamic": + return 5 + case "splash-page": + return 1 + case "webtoon-scroll": + return 3 + case "western-3x3": + return 9 + case "dialogue-heavy": + return 8 + case "cinematic-widescreen": + return 4 + } + return 4 +} + +pattern_matches_genre :: proc(id: string, genre: string) -> bool { + if len(genre) == 0 { + return true + } + + switch id { + case "grid-2x2": + return true + case "manga-3-tier": + return genre == "manga" || genre == "action" + case "action-dynamic": + return genre == "action" || genre == "superhero" + case "splash-page": + return true + case "webtoon-scroll": + return genre == "webtoon" || genre == "slice-of-life" + case "western-3x3": + return genre == "western-comic" + case "dialogue-heavy": + return genre == "drama" || genre == "slice-of-life" || genre == "romance" + case "cinematic-widescreen": + return genre == "action" || genre == "scifi" || genre == "noir" + } + + return true +} + +get_layout_pattern_by_id :: proc(id: string) -> Layout_Pattern { + switch id { + case "grid-2x2": + return Layout_Pattern{id = id, name = "Classic Grid", description = "2x2 equal panels", genres = nil, max_panels = 4, cells = GRID_2X2_CELLS[:]} + case "manga-3-tier": + return Layout_Pattern{id = id, name = "Manga 3-Tier", description = "Three horizontal tiers", genres = nil, max_panels = 6, cells = MANGA_3_TIER_CELLS[:]} + case "action-dynamic": + return Layout_Pattern{id = id, name = "Action Dynamic", description = "Varied action panels", genres = nil, max_panels = 5, cells = ACTION_DYNAMIC_CELLS[:]} + case "splash-page": + return Layout_Pattern{id = id, name = "Splash Page", description = "Single full-page panel", genres = nil, max_panels = 1, cells = SPLASH_CELLS[:]} + case "webtoon-scroll": + return Layout_Pattern{id = id, name = "Webtoon Scroll", description = "Vertical mobile panels", genres = nil, max_panels = 3, cells = WEBTOON_CELLS[:]} + case "western-3x3": + return Layout_Pattern{id = id, name = "Western 3x3", description = "Classic 3x3", genres = nil, max_panels = 9, cells = WESTERN_3X3_CELLS[:]} + case "dialogue-heavy": + return Layout_Pattern{id = id, name = "Dialogue Heavy", description = "Conversation-focused", genres = nil, max_panels = 8, cells = DIALOGUE_HEAVY_CELLS[:]} + case "cinematic-widescreen": + return Layout_Pattern{id = id, name = "Cinematic Widescreen", description = "Wide cinematic panels", genres = nil, max_panels = 4, cells = CINEMATIC_CELLS[:]} + } + + return Layout_Pattern{id = "grid-2x2", name = "Classic Grid", description = "2x2 equal panels", genres = nil, max_panels = 4, cells = GRID_2X2_CELLS[:]} +} + +select_best_pattern :: proc(panel_count: int, genre: string, preference: string) -> Layout_Pattern { + pattern_ids := [8]string{ + "grid-2x2", + "manga-3-tier", + "action-dynamic", + "splash-page", + "webtoon-scroll", + "western-3x3", + "dialogue-heavy", + "cinematic-widescreen", + } + + if len(preference) > 0 { + if pattern_max_panels(preference) >= panel_count { + return get_layout_pattern_by_id(preference) + } + } + + best_genre_id := "" + best_genre_max := 1 << 30 + for id in pattern_ids { + max_panels := pattern_max_panels(id) + if max_panels < panel_count { + continue + } + if !pattern_matches_genre(id, genre) { + continue + } + if max_panels < best_genre_max { + best_genre_max = max_panels + best_genre_id = id + } + } + if len(best_genre_id) > 0 { + return get_layout_pattern_by_id(best_genre_id) + } + + best_id := "" + best_max := 1 << 30 + for id in pattern_ids { + max_panels := pattern_max_panels(id) + if max_panels < panel_count { + continue + } + if max_panels < best_max { + best_max = max_panels + best_id = id + } + } + if len(best_id) > 0 { + return get_layout_pattern_by_id(best_id) + } + + return get_layout_pattern_by_id("grid-2x2") +} + +auto_layout_pages :: proc(panels: []Panel, page_size: Page_Size_Name, genre: string, pattern_preference: string) -> []Page_Layout { + if len(panels) == 0 { + return nil + } + + size := get_page_size(page_size) + pages: [dynamic]Page_Layout + panel_index := 0 + page_number := 1 + + for panel_index < len(panels) { + remaining := len(panels) - panel_index + pattern := select_best_pattern(remaining, genre, pattern_preference) + take := pattern.max_panels + if take > remaining { + take = remaining + } + + layout_panels: [dynamic]Page_Layout_Panel + for i in 0..= len(pattern.cells) { + cell_index = len(pattern.cells) - 1 + } + + append(&layout_panels, Page_Layout_Panel{ + panel_id = p.panel_id, + panel_number = p.panel_number, + layout_cell = pattern.cells[cell_index], + }) + } + + append(&pages, Page_Layout{ + page_number = page_number, + pattern_id = pattern.id, + panels = layout_panels[:], + width = size.width, + height = size.height, + }) + + panel_index += take + page_number += 1 + } + + return pages[:] +} diff --git a/odin/src/core/script.odin b/odin/src/core/script.odin new file mode 100644 index 0000000..57644c2 --- /dev/null +++ b/odin/src/core/script.odin @@ -0,0 +1,56 @@ +package core + +import "core:fmt" + +script_is_valid_minimal :: proc(script: Comic_Script) -> bool { + if len(script.title) == 0 { + return false + } + if len(script.synopsis) == 0 { + return false + } + if len(script.pages) == 0 { + return false + } + return true +} + +normalize_script :: proc(script: Comic_Script) -> Comic_Script { + normalized := script + + if len(normalized.title) == 0 { + normalized.title = fmt.aprintf("Untitled Comic") + } + if len(normalized.synopsis) == 0 { + normalized.synopsis = fmt.aprintf("Generated comic synopsis") + } + + for idx in 0.. Comic_State { + iso := "" + + return Comic_State{ + project = Project_Metadata{ + project_id = "proj_todo", + project_name = "Untitled Comic", + created_at_iso = iso, + last_modified_iso = iso, + }, + user_mode = .Casual, + story_idea = "", + story_genre = "action", + target_audience = "general", + art_style = "manga", + export_format = .PDF, + page_size = .A4, + color_profile = .RGB, + workflow = Workflow_State{ + current_step = .Story_Input, + completed_steps = nil, + is_generating = false, + generation_progress = 0, + error_message = "", + }, + panel_images = nil, + panel_errors = nil, + speech_bubbles = nil, + } +} + +set_workflow_step :: proc(state: ^Comic_State, step: Workflow_Step) { + state.workflow.current_step = step + + for s in state.workflow.completed_steps { + if s == step { + return + } + } + + old_steps := state.workflow.completed_steps + steps: [dynamic]Workflow_Step + for s in old_steps { + append(&steps, s) + } + append(&steps, step) + state.workflow.completed_steps = steps[:] + delete(old_steps) +} diff --git a/odin/src/core/types.odin b/odin/src/core/types.odin new file mode 100644 index 0000000..21a0e2d --- /dev/null +++ b/odin/src/core/types.odin @@ -0,0 +1,251 @@ +package core + +User_Mode :: enum { + Casual, + Professional, +} + +Workflow_Step :: enum { + Story_Input, + Generating_Script, + Script_Review, + Character_Setup, + Generating_Panels, + Layout, + Speech_Bubbles, + Complete, +} + +Export_Format :: enum { + PDF, + CBZ, + PNG, +} + +Page_Size_Name :: enum { + A4, + Letter, + Manga, + Webtoon, + Square, +} + +Color_Profile :: enum { + RGB, + CMYK, +} + +Character_Role :: enum { + Protagonist, + Antagonist, + Supporting, + Extra, +} + +Shot_Type :: enum { + Establishing, + Wide, + Medium, + Close_Up, + Extreme_Close_Up, + Over_Shoulder, + Aerial, +} + +Layout_Type :: enum { + Grid, + Manga, + Western, + Action, + Dialogue, + Splash, +} + +Bubble_Type :: enum { + Normal, + Thought, + Shout, + Whisper, + Narration, + Sound_Effect, +} + +Transition_Type :: enum { + None, + Fade, + Wipe, + Dissolve, + Action_Lines, +} + +Position :: struct { + x: f32, + y: f32, +} + +Size :: struct { + width: f32, + height: f32, +} + +Character_Prompt_Template :: struct { + age: string, + gender: string, + hair_color: string, + hair_style: string, + skin_tone: string, + eye_color: string, + body_type: string, + outfit: string, + accessories: string, + distinguishing_features: string, +} + +Color_Palette :: struct { + hair: string, + eyes: string, + skin: string, + outfit: string, +} + +Character :: struct { + id: string, + name: string, + role: Character_Role, + description: string, + prompt_template: Character_Prompt_Template, + reference_image_url: string, + character_sheet_urls: []string, + seed: i64, + color_palette: Color_Palette, + appearance_count: int, + first_appearance_panel: string, +} + +Dialogue :: struct { + speaker_id: string, + text: string, + bubble_type: Bubble_Type, + emotion: string, +} + +Panel :: struct { + panel_id: string, + panel_number: int, + shot_type: Shot_Type, + description: string, + characters_present: []string, + dialogue: []Dialogue, + caption: string, + sound_effects: []string, + transition_from_previous: Transition_Type, +} + +Page :: struct { + page_number: int, + layout_type: Layout_Type, + panels: []Panel, +} + +Comic_Script :: struct { + title: string, + synopsis: string, + characters: []Character, + pages: []Page, +} + +Panel_Image :: struct { + url: string, + width: int, + height: int, + seed: i64, + prompt: string, +} + +Layout_Cell :: struct { + x: f32, + y: f32, + w: f32, + h: f32, +} + +Layout_Pattern :: struct { + id: string, + name: string, + description: string, + genres: []string, + max_panels: int, + cells: []Layout_Cell, +} + +Page_Layout_Panel :: struct { + panel_id: string, + panel_number: int, + layout_cell: Layout_Cell, +} + +Page_Layout :: struct { + page_number: int, + pattern_id: string, + panels: []Page_Layout_Panel, + width: int, + height: int, +} + +Speech_Bubble_Style :: struct { + background_color: string, + border_color: string, + border_width: f32, + border_radius: f32, + font_family: string, + font_size: f32, + text_color: string, + padding: f32, +} + +Speech_Bubble :: struct { + id: string, + panel_id: string, + type: Bubble_Type, + text: string, + position: Position, + size: Size, + tail_direction: string, + tail_target: Position, + style: Speech_Bubble_Style, + speaker_id: string, +} + +Project_Metadata :: struct { + project_id: string, + project_name: string, + created_at_iso: string, + last_modified_iso: string, +} + +Workflow_State :: struct { + current_step: Workflow_Step, + completed_steps: []Workflow_Step, + is_generating: bool, + generation_progress: f32, + error_message: string, +} + +Comic_State :: struct { + project: Project_Metadata, + user_mode: User_Mode, + story_idea: string, + story_genre: string, + target_audience: string, + art_style: string, + script: Comic_Script, + characters: []Character, + panel_images: map[string]Panel_Image, + panel_errors: map[string]string, + page_layouts: []Page_Layout, + speech_bubbles: map[string][]Speech_Bubble, + export_format: Export_Format, + page_size: Page_Size_Name, + color_profile: Color_Profile, + workflow: Workflow_State, +} diff --git a/odin/src/core/workflow.odin b/odin/src/core/workflow.odin new file mode 100644 index 0000000..40a57ed --- /dev/null +++ b/odin/src/core/workflow.odin @@ -0,0 +1,24 @@ +package core + +can_transition :: proc(from, to: Workflow_Step) -> bool { + switch from { + case .Story_Input: + return to == .Generating_Script + case .Generating_Script: + return to == .Script_Review || to == .Story_Input + case .Script_Review: + return to == .Character_Setup + case .Character_Setup: + return to == .Generating_Panels + case .Generating_Panels: + return to == .Layout + case .Layout: + return to == .Speech_Bubbles + case .Speech_Bubbles: + return to == .Complete + case .Complete: + return to == .Story_Input + } + + return false +} diff --git a/odin/src/gui/actions.odin b/odin/src/gui/actions.odin new file mode 100644 index 0000000..c2fe18f --- /dev/null +++ b/odin/src/gui/actions.odin @@ -0,0 +1,429 @@ +package gui + +import "core:fmt" +import "core:strconv" +import "core:strings" +import rl "vendor:raylib" +import "../adapters" +import "../core" +import "../shared" +import "../ui" + +action_generate_local_script :: proc(controller: ^ui.App_Controller, pages: int) -> string { + story := controller.state.story_idea + if len(story) == 0 { + story = "A local GUI adventure" + } + script := build_local_script(story, pages) + core.dispose_script(&controller.state.script) + controller.state.script = script + controller.state.characters = controller.state.script.characters + controller.active_screen = .Script + controller.state.workflow.current_step = .Script_Review + return "Generated local script" +} + +action_generate_deepseek_script :: proc(controller: ^ui.App_Controller, pages: int) -> string { + cfg := shared.load_config() + if len(cfg.deepseek_api_key) == 0 { + return "DeepSeek key missing (set DEEPSEEK_API_KEY)" + } + opts := adapters.Generate_Script_Options{ + story_idea = controller.state.story_idea, + genre = controller.state.story_genre, + art_style = controller.state.art_style, + num_pages = pages, + audience = controller.state.target_audience, + } + script, gerr := adapters.generate_comic_script_stub(cfg, opts) + if !shared.is_ok(gerr) { + return fmt.aprintf("DeepSeek script failed: %s", gerr.message) + } + core.dispose_script(&controller.state.script) + controller.state.script = script + controller.state.characters = controller.state.script.characters + controller.active_screen = .Script + controller.state.workflow.current_step = .Script_Review + return "Generated DeepSeek script" +} + +action_generate_local_panels :: proc(controller: ^ui.App_Controller) -> string { + panels := collect_script_panels(controller.state.script) + defer delete(panels) + if len(panels) == 0 { + return "No script panels available" + } + images, ierr := build_local_panel_images(panels) + if !shared.is_ok(ierr) { + return ierr.message + } + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = images + controller.active_screen = .Panels + return "Generated local panels" +} + +action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string { + panels := collect_script_panels(controller.state.script) + defer delete(panels) + + target_panel: core.Panel + found := false + for p in panels { + if p.panel_id == panel_id { + target_panel = p + found = true + break + } + } + if !found { + return "Panel not found in script" + } + + single := make([]core.Panel, 1) + single[0] = target_panel + defer delete(single) + + images, ierr := build_local_panel_images(single) + if !shared.is_ok(ierr) { + if controller.state.panel_errors == nil { + controller.state.panel_errors = make(map[string]string) + } + controller.state.panel_errors[panel_id] = strings.clone(ierr.message) + return "Panel generation failed" + } + + if img, has := images[panel_id]; has { + if old, exists := controller.state.panel_images[panel_id]; exists { + delete(old.url) + delete(old.prompt) + } + if controller.state.panel_images == nil { + controller.state.panel_images = make(map[string]core.Panel_Image) + } + controller.state.panel_images[panel_id] = img + + if err_msg, err_exists := controller.state.panel_errors[panel_id]; err_exists { + delete(err_msg) + delete_key(&controller.state.panel_errors, panel_id) + } + } + delete(images) // free the map shell returned by build_local_panel_images + + return "Regenerated panel" +} + +action_layout_auto :: proc(controller: ^ui.App_Controller) -> string { + panels := collect_script_panels(controller.state.script) + defer delete(panels) + if len(panels) == 0 { + return "No script panels for layout" + } + core.dispose_page_layouts(&controller.state.page_layouts) + controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "") + controller.active_screen = .Layout + controller.state.workflow.current_step = .Layout + return "Auto layout generated" +} + +export_format_name :: proc(f: core.Export_Format) -> string { + switch f { + case .PDF: return "PDF" + case .PNG: return "PNG" + case .CBZ: return "CBZ" + } + return "PDF" +} + +parse_pages_or_default :: proc(s: string, def: int) -> int { + v, ok := strconv.parse_int(strings.trim_space(s)) + if !ok || v <= 0 { + return def + } + return v +} + +parse_autosave_interval :: proc(s: string, def: int) -> int { + v, ok := strconv.parse_int(strings.trim_space(s)) + if !ok { + return def + } + if v < 5 { + return 5 + } + if v > 300 { + return 300 + } + return v +} + +set_autosave_interval_text :: proc(dst: ^string, seconds: int) -> string { + v := seconds + if v < 5 { + v = 5 + } + if v > 300 { + v = 300 + } + dst^ = fmt.aprintf("%d", v) + return fmt.aprintf("Autosave interval: %ds", v) +} + +yn :: proc(v: bool) -> string { + if v { + return "yes" + } + return "no" +} + +toggle_summary_show :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string { + #partial switch active_screen { + case .Script: + opts.script_show_all = !opts.script_show_all + return fmt.aprintf("Script summary show-all: %s", yn(opts.script_show_all)) + case .Layout: + opts.layout_show_all = !opts.layout_show_all + return fmt.aprintf("Layout summary show-all: %s", yn(opts.layout_show_all)) + } + return "Summary show toggle unavailable on this screen" +} + +toggle_summary_sort :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string { + #partial switch active_screen { + case .Script: + opts.script_desc = !opts.script_desc + sort_name := "asc" + if opts.script_desc { + sort_name = "desc" + } + return fmt.aprintf("Script summary sort: %s", sort_name) + case .Layout: + opts.layout_desc = !opts.layout_desc + sort_name := "asc" + if opts.layout_desc { + sort_name = "desc" + } + return fmt.aprintf("Layout summary sort: %s", sort_name) + } + return "Summary sort toggle unavailable on this screen" +} + +toggle_summary_show_if_supported :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string { + if active_screen == .Script || active_screen == .Layout { + return toggle_summary_show(active_screen, opts) + } + return "" +} + +toggle_summary_sort_if_supported :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string { + if active_screen == .Script || active_screen == .Layout { + return toggle_summary_sort(active_screen, opts) + } + return "" +} + +reset_project_session :: proc(controller: ^ui.App_Controller, is_dirty: ^bool, last_autosave_at: ^f64, touch_time: bool) -> string { + core.dispose_state(&controller.state) + controller.state = core.new_initial_state() + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + is_dirty^ = false + if touch_time { + last_autosave_at^ = rl.GetTime() + } + return "Reset project" +} + +open_project_session :: proc(controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, is_dirty: ^bool, last_autosave_at: ^f64) -> string { + normalize_project_path_field(project_path) + loaded, lerr := adapters.load_project(project_path^) + if !shared.is_ok(lerr) { + return lerr.message + } + core.dispose_state(&controller.state) + controller.state = loaded + controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) + sync_export_path_to_project_dir(project_path^, export_path, export_format) + is_dirty^ = false + last_autosave_at^ = rl.GetTime() + return fmt.aprintf("Opened project: %s (export -> %s)", project_path^, export_path^) +} + +resolve_confirm_action_with_message :: proc(action: Pending_Confirm_Action, controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, is_dirty: ^bool, last_autosave_at: ^f64) -> string { + switch action { + case .Reset_Project: + return reset_project_session(controller, is_dirty, last_autosave_at, true) + case .Open_Project: + return open_project_session(controller, project_path, export_path, export_format, is_dirty, last_autosave_at) + case .None: + return "No pending destructive action" + } + return "No pending destructive action" +} + +save_project_session_with_message :: proc(project_path: ^string, state: core.Comic_State, is_dirty: ^bool, last_autosave_at, last_save_at: ^f64, success_prefix: string) -> string { + normalize_project_path_field(project_path) + err := adapters.save_project(project_path^, state) + if !shared.is_ok(err) { + return err.message + } + is_dirty^ = false + last_autosave_at^ = rl.GetTime() + last_save_at^ = last_autosave_at^ + return fmt.aprintf("%s: %s", success_prefix, project_path^) +} + +action_export :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format) -> string { + opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} + err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) + if !shared.is_ok(err) { + return err.message + } + controller.active_screen = .Export + controller.state.workflow.current_step = .Complete + controller.state.export_format = export_format + return fmt.aprintf("Exported %s", export_format_name(export_format)) +} + +gui_next_hint_with_source :: proc(controller: ui.App_Controller, use_deepseek_script: bool) -> string { + if len(controller.state.script.pages) == 0 { + if use_deepseek_script { + return "generate script" + } + return "generate script local" + } + if len(controller.state.panel_images) == 0 { + return "generate panels local" + } + if len(controller.state.page_layouts) == 0 { + return "layout auto" + } + return "export pdf" +} + +gui_next_hint :: proc(controller: ui.App_Controller) -> string { + return gui_next_hint_with_source(controller, false) +} + +action_run_next :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int, use_deepseek_script: bool) -> string { + hint := gui_next_hint_with_source(controller^, use_deepseek_script) + switch hint { + case "generate script": + return action_generate_deepseek_script(controller, script_pages) + case "generate script local": + return action_generate_local_script(controller, script_pages) + case "generate panels local": + return action_generate_local_panels(controller) + case "layout auto": + return action_layout_auto(controller) + case "export pdf": + return action_export(controller, export_path, export_format) + } + return "No next action" +} + +action_run_auto_all_local :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int, use_deepseek_script: bool) -> string { + for _ in 0..<4 { + msg := action_run_next(controller, export_path, export_format, script_pages, use_deepseek_script) + if controller.active_screen == .Export { + return fmt.aprintf("Auto-all complete: %s", msg) + } + } + return "Auto-all could not complete" +} + +run_script_action :: proc(controller: ^ui.App_Controller, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool) -> string { + is_dirty^ = true + if use_deepseek_script { + return action_generate_deepseek_script(controller, pages_count) + } + return action_generate_local_script(controller, pages_count) +} + +run_panels_action :: proc(controller: ^ui.App_Controller, can_generate_panels: bool, is_dirty: ^bool) -> string { + if !can_generate_panels { + return "Generate script before panels" + } + is_dirty^ = true + return action_generate_local_panels(controller) +} + +run_layout_action :: proc(controller: ^ui.App_Controller, can_layout: bool, is_dirty: ^bool) -> string { + if !can_layout { + return "Generate panels before layout" + } + is_dirty^ = true + return action_layout_auto(controller) +} + +run_export_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, can_export: bool, is_dirty: ^bool, last_export_at: ^f64) -> string { + normalize_export_path_field(export_path, export_format) + if !can_export { + return "Export blocked: generate panels + layout first" + } + msg := action_export(controller, export_path^, export_format) + is_dirty^ = true + if strings.has_prefix(msg, "Exported ") { + last_export_at^ = rl.GetTime() + } + return msg +} + +run_next_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool, last_export_at: ^f64) -> string { + normalize_export_path_field(export_path, export_format) + msg := action_run_next(controller, export_path^, export_format, pages_count, use_deepseek_script) + is_dirty^ = true + if controller.active_screen == .Export { + last_export_at^ = rl.GetTime() + } + return msg +} + +run_auto_all_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool, last_export_at: ^f64) -> string { + normalize_export_path_field(export_path, export_format) + msg := action_run_auto_all_local(controller, export_path^, export_format, pages_count, use_deepseek_script) + is_dirty^ = true + if controller.active_screen == .Export { + last_export_at^ = rl.GetTime() + } + return msg +} + +run_auto_all_save_action :: proc(controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool, last_export_at, last_autosave_at, last_save_at: ^f64) -> string { + normalize_export_path_field(export_path, export_format) + msg := action_run_auto_all_local(controller, export_path^, export_format, pages_count, use_deepseek_script) + if controller.active_screen == .Export { + last_export_at^ = rl.GetTime() + return save_project_session_with_message(project_path, controller.state, is_dirty, last_autosave_at, last_save_at, "Auto-all + saved") + } + return msg +} + +set_export_format_with_message :: proc(export_format: ^core.Export_Format, export_path: ^string, next: core.Export_Format, is_dirty: ^bool) -> string { + export_format^ = next + export_path^ = export_path_for_format(export_path^, export_format^) + is_dirty^ = true + return fmt.aprintf("Export format: %s (%s)", export_format_name(export_format^), export_path^) +} + +autosave_tick_with_message :: proc(project_path: ^string, state: core.Comic_State, autosave_enabled: bool, is_dirty: ^bool, last_autosave_at: ^f64, last_save_at: ^f64, autosave_interval_s: f64) -> string { + if !autosave_enabled || !is_dirty^ { + return "" + } + now := rl.GetTime() + if now-last_autosave_at^ < autosave_interval_s { + return "" + } + normalize_project_path_field(project_path) + err := adapters.save_project(project_path^, state) + last_autosave_at^ = now + if shared.is_ok(err) { + is_dirty^ = false + last_save_at^ = now + return fmt.aprintf("Autosaved: %s", project_path^) + } + return fmt.aprintf("Autosave failed: %s", err.message) +} diff --git a/odin/src/gui/controls.odin b/odin/src/gui/controls.odin new file mode 100644 index 0000000..96d6f0f --- /dev/null +++ b/odin/src/gui/controls.odin @@ -0,0 +1,177 @@ +package gui + +import "core:fmt" +import rl "vendor:raylib" + +button_clicked :: proc(rec: rl.Rectangle) -> bool { + if !rl.IsMouseButtonPressed(.LEFT) { + return false + } + return rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) +} + +draw_button :: proc(rec: rl.Rectangle, label: string) { + hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) + bg := BTN_BG + border := BTN_BORDER + text := BTN_TEXT + if hover { + bg = BTN_BG_HOVER + border = BTN_BORDER_HOVER + } + rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border) + draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text) +} + +draw_button_primary :: proc(rec: rl.Rectangle, label: string) { + hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) + bg := ACCENT + border := ACCENT_MUTED + if hover { + bg = ACCENT_HOVER + border = ACCENT + } + rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border) + draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT) +} + +draw_button_danger :: proc(rec: rl.Rectangle, label: string) { + hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) + bg := DANGER_BG + border := DANGER_BORDER + if hover { + bg = DANGER_BG_HOVER + border = DANGER_BORDER + } + rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border) + draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT) +} + +draw_button_warning :: proc(rec: rl.Rectangle, label: string) { + hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) + bg := WARN_BTN_BG + border := WARN_BTN_BORDER + text := WARN_BTN_TEXT + if hover { + bg = WARN_BTN_BG_HOVER + border = WARN_BTN_BORDER + } + rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border) + draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text) +} + +draw_button_soft_accent :: proc(rec: rl.Rectangle, label: string) { + hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) + bg := BTN_SOFT_BG + border := BTN_SOFT_BORDER + text := BTN_SOFT_TEXT + if hover { + bg = BTN_SOFT_BG_HOVER + border = BTN_SOFT_BORDER + } + rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border) + draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text) +} + +draw_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) { + if !enabled { + rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, BTN_DISABLED_BG) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, BTN_DISABLED_BORDER) + draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, BTN_DISABLED_TEXT) + return + } + draw_button(rec, label) +} + +draw_small_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) { + if !enabled { + rl.DrawRectangleRounded(rec, 0.20, 6, BTN_DISABLED_BG) + rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, BTN_DISABLED_BORDER) + draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, BTN_DISABLED_TEXT) + return + } + hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) + bg := SBTN_BG + border := SBTN_BORDER + if hover { + bg = SBTN_BG_HOVER + border = SBTN_BORDER_HOVER + } + rl.DrawRectangleRounded(rec, 0.20, 6, bg) + rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, border) + draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, SBTN_TEXT) +} + +draw_small_button :: proc(rec: rl.Rectangle, label: string) { + draw_small_button_state(rec, label, true) +} + +button_readiness_hint :: proc(mouse: rl.Vector2, panels_btn, layout_btn, export_btn: rl.Rectangle, can_generate_panels, can_layout, can_export: bool) -> string { + if rl.CheckCollisionPointRec(mouse, panels_btn) && !can_generate_panels { + return "Panels requires a generated script" + } + if rl.CheckCollisionPointRec(mouse, layout_btn) && !can_layout { + return "Layout requires generated panels" + } + if rl.CheckCollisionPointRec(mouse, export_btn) && !can_export { + return "Export requires panels + layout" + } + return "" +} + +draw_button_recommended :: proc(rec: rl.Rectangle, label: string) { + halo := rl.Rectangle{x = rec.x-2, y = rec.y-2, width = rec.width+4, height = rec.height+4} + rl.DrawRectangleRounded(halo, RADIUS_BUTTON, 8, RECOMMEND_HALO_FILL) + rl.DrawRectangleRoundedLinesEx(halo, RADIUS_BUTTON, 8, 1.4, RECOMMEND_HALO_BORDER) + draw_button(rec, label) +} + +draw_nav_item :: proc(rec: rl.Rectangle, label: string, active: bool) { + bg := NAV_BG + border := NAV_BORDER + text := NAV_TEXT + if active { + bg = NAV_ACTIVE_BG + border = NAV_ACTIVE_BG + text = NAV_ACTIVE_TEXT + } else if rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) { + bg = NAV_BG_HOVER + border = NAV_BORDER_HOVER + } + rl.DrawRectangleRounded(rec, RADIUS_NAV, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_NAV, 8, 1.0, border) + if active { + rl.DrawRectangleRounded(rl.Rectangle{x = rec.x+2, y = rec.y+4, width = 4, height = rec.height-8}, 0.5, 8, NAV_ACTIVE_BAR) + } + label_x := i32(rec.x) + 8 + label_w := int(rec.width) - 16 + if active { + label_x = i32(rec.x) + 14 + label_w = int(rec.width) - 22 + } + draw_text_fitted(label, label_x, i32(rec.y)+6, 18, label_w, 8, text) +} + +draw_input_field :: proc(rec: rl.Rectangle, value: string, selected: bool) { + bg := INPUT_BG + border := INPUT_BORDER + if selected { + halo := rl.Rectangle{x = rec.x - 2, y = rec.y - 2, width = rec.width + 4, height = rec.height + 4} + rl.DrawRectangleRounded(halo, RADIUS_INPUT, 8, INPUT_FOCUS_RING) + rl.DrawRectangleRoundedLinesEx(halo, RADIUS_INPUT, 8, 1.0, INPUT_FOCUS_BORDER) + bg = INPUT_FOCUS_BG + border = INPUT_FOCUS_BORDER + } + rl.DrawRectangleRounded(rec, RADIUS_INPUT, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_INPUT, 8, 1.2, border) + if !selected { + draw_text_fitted(value, i32(rec.x)+8, i32(rec.y)+6, 18, int(rec.width)-16, 8, INPUT_TEXT) + return + } + rl.DrawText(fmt.ctprintf("%s", value), i32(rec.x)+8, i32(rec.y)+6, 18, INPUT_TEXT_FOCUS) +} diff --git a/odin/src/gui/diagnostics.odin b/odin/src/gui/diagnostics.odin new file mode 100644 index 0000000..f35feba --- /dev/null +++ b/odin/src/gui/diagnostics.odin @@ -0,0 +1,159 @@ +package gui + +import "core:fmt" +import "core:os" +import "core:path/filepath" +import rl "vendor:raylib" +import "../ui" + +build_diagnostics_snapshot :: proc(controller: ui.App_Controller, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string { + log_order := "newest" + if log_oldest_first { + log_order = "oldest" + } + return fmt.aprintf("screen=%s workflow=%v next=%s dirty=%s autosave=%s(%ds) content=pages:%d,panels:%d,layouts:%d,chars:%d paths=P:%s,E:%s project=%s export=%s log=%d,%s uptime=%.1fs", ui.screen_name(controller.active_screen), controller.state.workflow.current_step, gui_next_hint(controller), yn(is_dirty), yn(autosave_enabled), autosave_secs, len(controller.state.script.pages), len(controller.state.panel_images), len(controller.state.page_layouts), len(controller.state.characters), yn(project_ok), yn(export_ok), project_path, export_path, log_show_lines, log_order, rl.GetTime()) +} + +build_action_log_snapshot :: proc(log: Action_Log) -> string { + if log.count == 0 { + return fmt.aprintf("(action log empty)") + } + now := rl.GetTime() + max_lines := len(log.entries) + if log.count < max_lines { + max_lines = log.count + } + out := "" + for line in 0.. string { + diag := build_diagnostics_snapshot(controller, is_dirty, autosave_enabled, project_ok, export_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + log_text := build_action_log_snapshot(log) + report := fmt.aprintf("# comic-odin gui session report\n\n[meta]\ngenerated_uptime=%.1fs\n\n[diagnostics]\n%s\n\n[action_log]\n%s\n", rl.GetTime(), diag, log_text) + delete(diag) + delete(log_text) + return report +} + +Diagnostics_Action_Context :: struct { + controller: ^ui.App_Controller, + action_log: ^Action_Log, + is_dirty: bool, + autosave_enabled: bool, + project_ok: bool, + export_ok: bool, + autosave_secs: int, + project_path: string, + export_path: string, + log_show_lines: i32, + log_oldest_first: bool, +} + +make_diagnostics_action_context :: proc(controller: ^ui.App_Controller, action_log: ^Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> Diagnostics_Action_Context { + return Diagnostics_Action_Context{ + controller = controller, + action_log = action_log, + is_dirty = is_dirty, + autosave_enabled = autosave_enabled, + project_ok = project_ok, + export_ok = export_ok, + autosave_secs = autosave_secs, + project_path = project_path, + export_path = export_path, + log_show_lines = log_show_lines, + log_oldest_first = log_oldest_first, + } +} + +diagnostics_path_for_project :: proc(project_path: string) -> string { + dir, _ := filepath.split(project_path) + if len(dir) == 0 { + dir = "./" + } + parts := []string{dir, "gui_diagnostics.txt"} + joined, err := filepath.join(parts) + if err != nil { + return "./gui_diagnostics.txt" + } + return joined +} + +session_report_path_for_project :: proc(project_path: string) -> string { + dir, _ := filepath.split(project_path) + if len(dir) == 0 { + dir = "./" + } + parts := []string{dir, "gui_session_report.txt"} + joined, err := filepath.join(parts) + if err != nil { + return "./gui_session_report.txt" + } + return joined +} + +write_diagnostics_file :: proc(project_path, diag: string) -> string { + diag_path := diagnostics_path_for_project(project_path) + defer delete(diag_path) + if err := os.write_entire_file(diag_path, diag); err == nil { + return fmt.aprintf("Wrote diagnostics file: %s", diag_path) + } + return fmt.aprintf("Failed writing diagnostics file") +} + +write_session_report_file :: proc(project_path, report: string) -> string { + report_path := session_report_path_for_project(project_path) + defer delete(report_path) + if err := os.write_entire_file(report_path, report); err == nil { + return fmt.aprintf("Wrote session report: %s", report_path) + } + return fmt.aprintf("Failed writing session report") +} + +write_session_report_with_message :: proc(ctx: Diagnostics_Action_Context) -> string { + report := build_session_report(ctx.controller^, ctx.action_log^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first) + msg := write_session_report_file(ctx.project_path, report) + delete(report) + return msg +} + +write_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string { + diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first) + msg := write_diagnostics_file(ctx.project_path, diag) + delete(diag) + return msg +} + +copy_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string { + diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first) + msg := copy_text_with_status(diag, "Copied diagnostics snapshot") + delete(diag) + return msg +} + +copy_action_log_snapshot_with_message :: proc(ctx: Diagnostics_Action_Context) -> string { + log_text := build_action_log_snapshot(ctx.action_log^) + msg := copy_text_with_status(log_text, "Copied action log snapshot") + delete(log_text) + return msg +} + +copy_text_with_status :: proc(text, status: string) -> string { + rl.SetClipboardText(fmt.ctprintf("%s", text)) + return status +} diff --git a/odin/src/gui/local_helpers.odin b/odin/src/gui/local_helpers.odin new file mode 100644 index 0000000..af66c86 --- /dev/null +++ b/odin/src/gui/local_helpers.odin @@ -0,0 +1,124 @@ +package gui + +import "core:fmt" +import "core:os" +import "../core" +import "../shared" + +collect_script_panels :: proc(script: core.Comic_Script) -> []core.Panel { + out: [dynamic]core.Panel + for p in script.pages { + for pan in p.panels { + append(&out, pan) + } + } + return out[:] +} + +count_script_panels :: proc(script: core.Comic_Script) -> int { + count := 0 + for p in script.pages { + count += len(p.panels) + } + return count +} + +local_panel_id_by_index :: proc(i: int) -> string { + switch i { + case 0: return "panel_local_001" + case 1: return "panel_local_002" + case 2: return "panel_local_003" + case 3: return "panel_local_004" + case 4: return "panel_local_005" + case 5: return "panel_local_006" + } + return "panel_local_overflow" +} + +build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script { + out_pages: [dynamic]core.Page + for i in 0.. (map[string]core.Panel_Image, shared.App_Error) { + tmp_dir, terr := os.make_directory_temp("", "comic-gui-local-panels-*", context.temp_allocator) + if terr != nil { + return nil, shared.new_error(.Generation, "failed to create local panel temp dir", true) + } + + images := make(map[string]core.Panel_Image) + for p, idx in panels { + name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id) + out_path := fmt.aprintf("%s/%s", tmp_dir, name) + delete(name) + if werr := os.write_entire_file(out_path, "LOCAL PANEL IMAGE"); werr != nil { + delete(out_path) + return nil, shared.new_error(.Generation, "failed writing local panel image", true) + } + url := fmt.aprintf("file://%s", out_path) + prompt := fmt.aprintf("local") + images[p.panel_id] = core.Panel_Image{url = url, width = 1024, height = 1024, seed = i64(idx + 1), prompt = prompt} + delete(out_path) + } + return images, shared.ok() +} + +append_char :: proc(dst: ^string, ch: rune) { + dst^ = fmt.aprintf("%s%c", dst^, ch) +} + +pop_char :: proc(dst: ^string) { + if len(dst^) == 0 { + return + } + dst^ = dst^[:len(dst^)-1] +} + +recommended_label_from_hint :: proc(hint: string) -> string { + switch hint { + case "generate script": + return "Generate Script" + case "generate script local": + return "Generate Script Local" + case "generate panels local": + return "Generate Panels Local" + case "layout auto": + return "Layout" + case "export pdf": + return "Export" + } + return "Next" +} + +pending_action_name :: proc(a: Pending_Confirm_Action) -> string { + switch a { + case .Reset_Project: return "reset project" + case .Open_Project: return "open project" + case .None: return "continue" + } + return "continue" +} diff --git a/odin/src/gui/overlays.odin b/odin/src/gui/overlays.odin new file mode 100644 index 0000000..b3d5442 --- /dev/null +++ b/odin/src/gui/overlays.odin @@ -0,0 +1,151 @@ +package gui + +import "core:fmt" +import "core:strings" +import rl "vendor:raylib" + +draw_action_log :: proc(log: Action_Log, x, y, max_visible: i32, oldest_first: bool) { + now := rl.GetTime() + max_lines := len(log.entries) + if log.count < max_lines { + max_lines = log.count + } + if max_visible > 0 && int(max_visible) < max_lines { + max_lines = int(max_visible) + } + for line in 0.. bool { + return strings.contains(msg, "failed") || strings.contains(msg, "blocked") || strings.contains(msg, "No script") +} + +is_warning_message :: proc(msg: string) -> bool { + return strings.contains(msg, "Unsaved") || strings.contains(msg, "Confirm") || strings.contains(msg, "requires") || strings.contains(msg, "before") || strings.contains(msg, "Cancelled") +} + +status_text_color :: proc(msg: string) -> rl.Color { + if is_error_message(msg) { + return ERROR + } + if is_warning_message(msg) { + return WARNING + } + return SUCCESS +} + +toast_bg_color :: proc(msg: string) -> rl.Color { + if is_error_message(msg) { + return TOAST_ERROR + } + if is_warning_message(msg) { + return TOAST_WARNING + } + return TOAST_SUCCESS +} + +draw_toast :: proc(log: Action_Log, x, y, w: i32) { + if log.count == 0 { + return + } + age := rl.GetTime() - log.last_push_at + if age > 2.8 { + return + } + idx := (log.count - 1) % len(log.entries) + if idx < 0 { + idx += len(log.entries) + } + msg := log.entries[idx] + bg := toast_bg_color(msg) + rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 34} + shadow := rl.Rectangle{x = f32(x), y = f32(y + 2), width = f32(w), height = 34} + rl.DrawRectangleRounded(shadow, RADIUS_TOAST, 8, TOAST_SHADOW) + rl.DrawRectangleRounded(rec, RADIUS_TOAST, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_TOAST, 8, 1.0, TOAST_BORDER) + draw_text_fitted(msg, x+10, y+8, 16, int(w-20), 8, TEXT_BRIGHT) +} + +draw_help_line :: proc(x, y: i32, text: string) { + draw_text_fitted(text, x, y, 16, 820, 8, HELP_LINE) +} + +draw_help_overlay :: proc() { + sw := rl.GetScreenWidth() + sh := rl.GetScreenHeight() + rec := rl.Rectangle{x = f32((sw-860)/2), y = f32((sh-642)/2), width = 860, height = 642} + rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY) + draw_card(rec) + x := i32(rec.x) + 30 + y := i32(rec.y) + 28 + rl.DrawText("Keyboard Shortcuts", x, y, 28, HELP_TITLE) + rl.DrawText("Navigation", x, y+44, 20, HELP_SECTION) + draw_help_line(x, y+72, "1..8 screens | TAB fields | click to focus | F11 pages | F12 project") + rl.DrawText("Core Actions", x, y+106, 20, HELP_SECTION) + draw_help_line(x, y+134, "F5 script F6 panels F7 layout F8 export F9 next F10 auto-all") + draw_help_line(x, y+160, "Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary") + rl.DrawText("Clipboard + Logs", x, y+196, 20, HELP_SECTION) + draw_help_line(x, y+224, "Ctrl+L clear log | Ctrl+Shift+L copy log | Ctrl+Shift+T/B log view | Ctrl+Shift+Z reset") + draw_help_line(x, y+248, "Ctrl+Shift+C status") + draw_help_line(x, y+272, "Ctrl+Shift+Y diag copy | Ctrl+Shift+R diag file | Ctrl+Shift+W report") + draw_help_line(x, y+296, "Ctrl+0 reset helpers | Ctrl+V paste | Ctrl+Shift+I copy | Ctrl+Backspace clear") + rl.DrawText("Paths", x, y+332, 20, HELP_SECTION) + draw_help_line(x, y+360, "Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export") + draw_help_line(x, y+386, "Ctrl+Shift+J fix project | F fix export | K/M quick-fix P/E | U fix all") + rl.DrawText("Autosave", x, y+422, 20, HELP_SECTION) + draw_help_line(x, y+450, "Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60") + rl.DrawText("Safety", x, y+486, 20, HELP_SECTION) + draw_help_line(x, y+514, "Dirty guard: Shift-click New/Open | Keyboard confirm: Ctrl+Shift+N / Ctrl+Shift+O") + rl.DrawText("Close help: Esc or /", x, y+542, 18, HELP_CLOSE) +} + +draw_sidebar_shortcut_line :: proc(x, y: i32, text: string, c: rl.Color) { + draw_text_fitted(text, x, y, 14, 220, 7, c) +} + +draw_sidebar_shortcuts :: proc(screen_h: i32) { + base_y := screen_h - 280 + if base_y < 120 { + base_y = 120 + } + draw_card(rl.Rectangle{x = 14, y = f32(base_y), width = 236, height = 210}) + rl.DrawText("Quick Keys", 26, base_y+12, 18, SIDEBAR_TITLE) + draw_sidebar_shortcut_line(26, base_y+36, "F5/F6/F7/F8 generate/layout/export", SIDEBAR_TEXT) + draw_sidebar_shortcut_line(26, base_y+54, "Ctrl+S save Ctrl+O open", SIDEBAR_TEXT) + draw_sidebar_shortcut_line(26, base_y+72, "Ctrl+N new", SIDEBAR_TEXT) + draw_sidebar_shortcut_line(26, base_y+90, "F9 next F10 auto-all", SIDEBAR_TEXT) + draw_sidebar_shortcut_line(26, base_y+108, "/ full shortcut help", SIDEBAR_TEXT) + draw_sidebar_shortcut_line(26, base_y+140, "Press / for all shortcuts", SIDEBAR_FOOTER) +} + +draw_confirm_overlay :: proc(action: Pending_Confirm_Action) { + sw := rl.GetScreenWidth() + sh := rl.GetScreenHeight() + rec := rl.Rectangle{x = f32((sw-520)/2), y = f32((sh-230)/2), width = 520, height = 230} + rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY) + draw_card(rec) + rl.DrawRectangleRounded(rl.Rectangle{x = rec.x, y = rec.y, width = rec.width, height = 8}, 0.08, 8, CONFIRM_ACCENT) + x := i32(rec.x) + 30 + y := i32(rec.y) + 34 + rl.DrawText("Confirm destructive action", x, y, 26, CONFIRM_TITLE) + draw_text_fitted(fmt.tprintf("You have unsaved changes. Do you want to %s?", pending_action_name(action)), x, y+42, 18, 470, 8, CONFIRM_BODY) + rl.DrawText("Enter/Y confirm • Esc/N cancel", x, y+72, 16, CONFIRM_HINT) +} diff --git a/odin/src/gui/path_helpers.odin b/odin/src/gui/path_helpers.odin new file mode 100644 index 0000000..9f6747b --- /dev/null +++ b/odin/src/gui/path_helpers.odin @@ -0,0 +1,165 @@ +package gui + +import "core:fmt" +import "core:path/filepath" +import "core:strings" +import "../core" + +format_suffix :: proc(f: core.Export_Format) -> string { + switch f { + case .PDF: return ".pdf" + case .PNG: return ".zip" + case .CBZ: return ".cbz" + } + return ".pdf" +} + +trim_known_export_suffix :: proc(path: string) -> string { + if strings.has_suffix(path, ".pdf") { return path[:len(path)-4] } + if strings.has_suffix(path, ".zip") { return path[:len(path)-4] } + if strings.has_suffix(path, ".cbz") { return path[:len(path)-4] } + if strings.has_suffix(path, ".png") { return path[:len(path)-4] } + return path +} + +export_path_for_format :: proc(path: string, f: core.Export_Format) -> string { + base := trim_known_export_suffix(path) + return fmt.aprintf("%s%s", base, format_suffix(f)) +} + +default_export_filename_for_format :: proc(f: core.Export_Format) -> string { + switch f { + case .PDF: return "comic.pdf" + case .PNG: return "comic_png.zip" + case .CBZ: return "comic.cbz" + } + return "comic.pdf" +} + +default_export_path_for_format :: proc(f: core.Export_Format) -> string { + return fmt.aprintf("./%s", default_export_filename_for_format(f)) +} + +export_path_in_project_dir :: proc(project_path: string, f: core.Export_Format) -> string { + dir, _ := filepath.split(project_path) + if len(dir) == 0 { + dir = "./" + } + parts := []string{dir, default_export_filename_for_format(f)} + joined, err := filepath.join(parts) + if err != nil { + return default_export_path_for_format(f) + } + return joined +} + +reset_helper_fields :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) { + export_path^ = default_export_path_for_format(f) + local_script_pages^ = "2" + autosave_interval_text^ = "20" +} + +sync_export_path_to_project_dir :: proc(project_path: string, export_path: ^string, f: core.Export_Format) { + export_path^ = export_path_in_project_dir(project_path, f) +} + +project_path_in_export_dir :: proc(export_path: string) -> string { + dir, _ := filepath.split(export_path) + if len(dir) == 0 { + dir = "./" + } + parts := []string{dir, "gui_project.comic.json"} + joined, err := filepath.join(parts) + if err != nil { + return "./gui_project.comic.json" + } + return joined +} + +normalize_project_path :: proc(path: string) -> string { + trimmed := strings.trim_space(path) + if len(trimmed) == 0 { + return "./gui_project.comic.json" + } + if strings.has_suffix(trimmed, ".comic.json") { + return trimmed + } + if strings.has_suffix(trimmed, ".json") { + return fmt.aprintf("%s.comic.json", trimmed[:len(trimmed)-5]) + } + return fmt.aprintf("%s.comic.json", trimmed) +} + +normalize_project_path_field :: proc(path: ^string) { + path^ = normalize_project_path(path^) +} + +normalize_export_path_field :: proc(path: ^string, f: core.Export_Format) { + trimmed := strings.trim_space(path^) + if len(trimmed) == 0 { + path^ = default_export_path_for_format(f) + return + } + path^ = export_path_for_format(trimmed, f) +} + +fix_all_paths :: proc(project_path, export_path: ^string, f: core.Export_Format) { + normalize_project_path_field(project_path) + normalize_export_path_field(export_path, f) +} + +set_export_preset_with_message :: proc(export_path: ^string, f: core.Export_Format) -> string { + export_path^ = default_export_path_for_format(f) + return fmt.aprintf("Preset export path: %s", export_path^) +} + +set_export_path_from_project_with_message :: proc(export_path: ^string, project_path: string, f: core.Export_Format) -> string { + export_path^ = export_path_in_project_dir(project_path, f) + return fmt.aprintf("Export path from project dir: %s", export_path^) +} + +set_project_path_from_export_with_message :: proc(project_path: ^string, export_path: string) -> string { + project_path^ = project_path_in_export_dir(export_path) + return fmt.aprintf("Project path from export dir: %s", project_path^) +} + +normalize_project_path_with_message :: proc(project_path: ^string) -> string { + normalize_project_path_field(project_path) + return fmt.aprintf("Normalized project path: %s", project_path^) +} + +normalize_export_path_with_message :: proc(export_path: ^string, f: core.Export_Format) -> string { + normalize_export_path_field(export_path, f) + return fmt.aprintf("Normalized export path: %s", export_path^) +} + +fix_all_paths_with_message :: proc(project_path, export_path: ^string, f: core.Export_Format) -> string { + fix_all_paths(project_path, export_path, f) + return fmt.aprintf("Normalized paths: P=%s E=%s", project_path^, export_path^) +} + +project_path_is_normalized :: proc(path: string) -> bool { + trimmed := strings.trim_space(path) + return len(trimmed) > 0 && strings.has_suffix(trimmed, ".comic.json") +} + +export_path_matches_format :: proc(path: string, f: core.Export_Format) -> bool { + trimmed := strings.trim_space(path) + if len(trimmed) == 0 { + return false + } + return strings.has_suffix(trimmed, format_suffix(f)) +} + +path_health_hint :: proc(project_ok, export_ok: bool) -> string { + if project_ok && export_ok { + return "" + } + if !project_ok && !export_ok { + return "Fix paths: P/E/PE buttons or Ctrl+Shift+U" + } + if !project_ok { + return "Fix project path: P button or Ctrl+Shift+K" + } + return "Fix export path: E button or Ctrl+Shift+M" +} diff --git a/odin/src/gui/runtime.odin b/odin/src/gui/runtime.odin new file mode 100644 index 0000000..b0b4fd8 --- /dev/null +++ b/odin/src/gui/runtime.odin @@ -0,0 +1,973 @@ +package gui + +import "core:fmt" +import "core:strings" +import rl "vendor:raylib" +import "../core" +import "../shared" +import "../ui" + +run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error { + controller := ui.new_controller(state^) + defer ui.dispose_job_manager(&controller.jobs) + + rl.SetConfigFlags({.WINDOW_RESIZABLE, .WINDOW_UNDECORATED}) + rl.InitWindow(1240, 820, "comic-odin gui") + defer rl.CloseWindow() + monitor := rl.GetCurrentMonitor() + monitor_pos := rl.GetMonitorPosition(monitor) + rl.SetWindowPosition(i32(monitor_pos.x), i32(monitor_pos.y)) + rl.SetWindowSize(rl.GetMonitorWidth(monitor), rl.GetMonitorHeight(monitor)) + rl.SetWindowState({.BORDERLESS_WINDOWED_MODE}) + rl.SetTargetFPS(60) + + selected_field := 0 // 0 idea, 1 genre, 2 audience, 3 export path, 4 local pages, 5 project path, 6 autosave interval + export_path := "./gui_export.pdf" + project_path := "./gui_project.comic.json" + local_script_pages := "2" + autosave_interval_text := "20" + export_format: core.Export_Format = .PDF + use_deepseek_script := false + status_msg := fmt.aprintf("GUI ready") + is_dirty := false + autosave_enabled := true + autosave_interval_s: f64 = 20 + last_autosave_at := rl.GetTime() + last_save_at: f64 = -1 + last_export_at: f64 = -1 + action_log: Action_Log + log_show_lines: i32 = 6 + log_oldest_first := false + summary_opts := Summary_View_Options{} + show_help_overlay := false + show_confirm_overlay := false + pending_confirm_action: Pending_Confirm_Action = .None + push_status(&status_msg, &action_log, status_msg) + defer action_log_dispose(&action_log) + defer delete(status_msg) + + for !rl.WindowShouldClose() { + screen_w_loop := rl.GetScreenWidth() + screen_h_loop := rl.GetScreenHeight() + compact_mode := screen_h_loop < 860 + cfg_loop := shared.load_config() + has_deepseek_key := len(cfg_loop.deepseek_api_key) > 0 + main_w_loop := screen_w_loop - 282 - 20 + if main_w_loop < 960 { + main_w_loop = 960 + } + status_w_loop := (main_w_loop - 2) / 2 + log_x_loop := 282 + status_w_loop + 2 + lower_y_loop := screen_h_loop - 252 + if lower_y_loop < 450 { + lower_y_loop = 450 + } + + idea_rec := rl.Rectangle{x = 420, y = 90, width = f32(main_w_loop-460), height = 36} + genre_rec := rl.Rectangle{x = 420, y = 146, width = f32(main_w_loop-460), height = 36} + audience_rec := rl.Rectangle{x = 420, y = 202, width = f32(main_w_loop-460), height = 36} + export_rec := rl.Rectangle{x = 420, y = 258, width = f32(main_w_loop-460), height = 36} + project_rec := rl.Rectangle{x = 420, y = 314, width = f32(main_w_loop-460), height = 36} + pages_rec := rl.Rectangle{x = f32(screen_w_loop - 120), y = 22, width = 100, height = 28} + + fmt_pdf_btn := rl.Rectangle{x = 420, y = 400, width = 80, height = 30} + fmt_png_btn := rl.Rectangle{x = 510, y = 400, width = 80, height = 30} + fmt_cbz_btn := rl.Rectangle{x = 600, y = 400, width = 80, height = 30} + script_src_local_btn := rl.Rectangle{x = 800, y = 400, width = 92, height = 30} + script_src_deepseek_btn := rl.Rectangle{x = 898, y = 400, width = 120, height = 30} + + // Main Actions Row 1 (y=470) + new_btn := rl.Rectangle{x = 290, y = 470, width = 140, height = 38} + script_btn := rl.Rectangle{x = 440, y = 470, width = 230, height = 38} + panels_btn := rl.Rectangle{x = 680, y = 470, width = 230, height = 38} + layout_btn := rl.Rectangle{x = 920, y = 470, width = 140, height = 38} + export_btn := rl.Rectangle{x = 1070, y = 470, width = 140, height = 38} + + // Secondary Actions Row 2 (y=518) + save_btn := rl.Rectangle{x = 290, y = 518, width = 160, height = 38} + open_btn := rl.Rectangle{x = 460, y = 518, width = 160, height = 38} + next_btn := rl.Rectangle{x = 630, y = 518, width = 160, height = 38} + auto_btn := rl.Rectangle{x = 800, y = 518, width = 160, height = 38} + auto_save_btn := rl.Rectangle{x = 970, y = 518, width = 240, height = 38} + + // Utility Strip Row 3 (y=566) + autosave_btn := rl.Rectangle{x = 290, y = 566, width = 160, height = 34} + autosave_rec := rl.Rectangle{x = 600, y = 568, width = 70, height = 30} + autosave_15_btn := rl.Rectangle{x = 680, y = 568, width = 44, height = 30} + autosave_30_btn := rl.Rectangle{x = 730, y = 568, width = 44, height = 30} + autosave_60_btn := rl.Rectangle{x = 780, y = 568, width = 44, height = 30} + help_btn := rl.Rectangle{x = 850, y = 566, width = 110, height = 34} + clear_field_btn := rl.Rectangle{x = 970, y = 566, width = 110, height = 34} + reset_helpers_btn := rl.Rectangle{x = 1090, y = 566, width = 120, height = 34} + + // Path Fixes (now below project inputs) + export_copy_btn := rl.Rectangle{x = 420, y = 360, width = 110, height = 24} + export_preset_btn := rl.Rectangle{x = 540, y = 360, width = 110, height = 24} + path_fix_btn := rl.Rectangle{x = 660, y = 360, width = 110, height = 24} + project_fix_btn := rl.Rectangle{x = 780, y = 360, width = 110, height = 24} + project_from_export_btn := rl.Rectangle{x = 900, y = 360, width = 110, height = 24} + export_project_btn := rl.Rectangle{x = 1020, y = 360, width = 110, height = 24} + + log_reset_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 68, height = 26} + report_file_btn := rl.Rectangle{x = f32(log_x_loop + 92), y = f32(lower_y_loop + 2), width = 68, height = 26} + log_copy_btn := rl.Rectangle{x = f32(log_x_loop + 166), y = f32(lower_y_loop + 2), width = 68, height = 26} + script_copy_page_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 96, height = 26} + script_copy_all_btn := rl.Rectangle{x = f32(log_x_loop + 120), y = f32(lower_y_loop + 2), width = 86, height = 26} + diag_file_btn := rl.Rectangle{x = f32(log_x_loop + 240), y = f32(lower_y_loop + 2), width = 68, height = 26} + status_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 2), width = 68, height = 26} + log_clear_btn := rl.Rectangle{x = f32(log_x_loop + 388), y = f32(lower_y_loop + 2), width = 68, height = 26} + diag_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 32), width = 68, height = 26} + confirm_base_x := (screen_w_loop - 520) / 2 + confirm_base_y := (screen_h_loop - 230) / 2 + confirm_yes_btn := rl.Rectangle{x = f32(confirm_base_x + 180), y = f32(confirm_base_y + 154), width = 140, height = 34} + confirm_no_btn := rl.Rectangle{x = f32(confirm_base_x + 330), y = f32(confirm_base_y + 154), width = 140, height = 34} + path_fix_project_status_btn := rl.Rectangle{x = 638, y = 556, width = 32, height = 20} + path_fix_export_status_btn := rl.Rectangle{x = 674, y = 556, width = 32, height = 20} + path_fix_all_status_btn := rl.Rectangle{x = 710, y = 556, width = 34, height = 20} + + summary_show_btn := rl.Rectangle{x = f32(282 + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24} + summary_sort_btn := rl.Rectangle{x = f32(282 + status_w_loop - 94), y = f32(lower_y_loop + 18), width = 78, height = 24} + summary_prev_btn := rl.Rectangle{x = f32(300), y = f32(lower_y_loop + 18), width = 52, height = 24} + summary_next_btn := rl.Rectangle{x = f32(358), y = f32(lower_y_loop + 18), width = 52, height = 24} + + if rl.IsKeyPressed(.SLASH) { + toggle_help_overlay(&show_help_overlay) + } + if rl.IsKeyPressed(.ESCAPE) { + close_help_overlay_if_open(&show_help_overlay) + } + interaction_locked := show_help_overlay || show_confirm_overlay + + nav_story := rl.Rectangle{x = 16, y = 90, width = 228, height = 32} + nav_script := rl.Rectangle{x = 16, y = 126, width = 228, height = 32} + nav_chars := rl.Rectangle{x = 16, y = 162, width = 228, height = 32} + nav_panels := rl.Rectangle{x = 16, y = 198, width = 228, height = 32} + nav_layout := rl.Rectangle{x = 16, y = 234, width = 228, height = 32} + nav_bubbles := rl.Rectangle{x = 16, y = 270, width = 228, height = 32} + nav_export := rl.Rectangle{x = 16, y = 306, width = 228, height = 32} + nav_community := rl.Rectangle{x = 16, y = 342, width = 228, height = 32} + + if !interaction_locked { + if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&controller, .Story) } + if rl.IsKeyPressed(.TWO) { _ = ui.navigate_to_screen(&controller, .Script) } + if rl.IsKeyPressed(.THREE) { _ = ui.navigate_to_screen(&controller, .Characters) } + if rl.IsKeyPressed(.FOUR) { _ = ui.navigate_to_screen(&controller, .Panels) } + if rl.IsKeyPressed(.FIVE) { _ = ui.navigate_to_screen(&controller, .Layout) } + if rl.IsKeyPressed(.SIX) { _ = ui.navigate_to_screen(&controller, .Bubbles) } + if rl.IsKeyPressed(.SEVEN) { _ = ui.navigate_to_screen(&controller, .Export) } + if rl.IsKeyPressed(.EIGHT) { _ = ui.navigate_to_screen(&controller, .Community) } + + if button_clicked(nav_story) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Story)) + } + if button_clicked(nav_script) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Script)) + } + if button_clicked(nav_chars) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Characters)) + } + if button_clicked(nav_panels) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Panels)) + } + if button_clicked(nav_layout) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Layout)) + } + if button_clicked(nav_bubbles) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Bubbles)) + } + if button_clicked(nav_export) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Export)) + } + if button_clicked(nav_community) { + push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Community)) + } + + if rl.IsKeyPressed(.TAB) { selected_field = (selected_field + 1) % 7 } + if rl.IsKeyPressed(.F1) { selected_field = 0 } + if rl.IsKeyPressed(.F2) { selected_field = 1 } + if rl.IsKeyPressed(.F3) { selected_field = 2 } + if rl.IsKeyPressed(.F4) { selected_field = 3 } + if rl.IsKeyPressed(.F11) { selected_field = 4 } + if rl.IsKeyPressed(.F12) { selected_field = 5 } + if button_clicked(idea_rec) { selected_field = 0 } + if button_clicked(genre_rec) { selected_field = 1 } + if button_clicked(audience_rec) { selected_field = 2 } + if button_clicked(export_rec) { selected_field = 3 } + if button_clicked(pages_rec) { selected_field = 4 } + if button_clicked(project_rec) { selected_field = 5 } + if button_clicked(autosave_rec) { selected_field = 6 } + if button_clicked(autosave_15_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15)) + } + if button_clicked(autosave_30_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30)) + } + if button_clicked(autosave_60_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60)) + } + } + + pages_count := parse_pages_or_default(local_script_pages, 2) + autosave_secs := parse_autosave_interval(autosave_interval_text, 20) + autosave_interval_s = f64(autosave_secs) + if selected_field != 6 && len(strings.trim_space(autosave_interval_text)) == 0 { + autosave_interval_text = fmt.aprintf("%d", autosave_secs) + } + shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT) + can_generate_panels := len(controller.state.script.pages) > 0 + can_layout := len(controller.state.panel_images) > 0 + can_export := len(controller.state.page_layouts) > 0 && len(controller.state.panel_images) > 0 + project_path_ok := project_path_is_normalized(project_path) + export_path_ok := export_path_matches_format(export_path, export_format) + + if show_confirm_overlay { + confirm_yes := button_clicked(confirm_yes_btn) || rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y) + confirm_no := button_clicked(confirm_no_btn) || rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N) + if confirm_no { + show_confirm_overlay = false + pending_confirm_action = .None + push_status(&status_msg, &action_log, "Cancelled destructive action") + } else if confirm_yes { + action := pending_confirm_action + show_confirm_overlay = false + pending_confirm_action = .None + push_status(&status_msg, &action_log, resolve_confirm_action_with_message(action, &controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at)) + } + } + + if !interaction_locked { + if button_clicked(export_copy_btn) { + push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard")) + } + if button_clicked(export_preset_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format)) + } + if button_clicked(path_fix_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format)) + } + if button_clicked(project_fix_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path)) + } + if button_clicked(project_from_export_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path)) + } + if button_clicked(export_project_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format)) + } + if button_clicked(path_fix_project_status_btn) && !project_path_ok { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path)) + } + if button_clicked(path_fix_export_status_btn) && !export_path_ok { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format)) + } + if button_clicked(path_fix_all_status_btn) && (!project_path_ok || !export_path_ok) { + push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format)) + } + if button_clicked(fmt_pdf_btn) { + push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PDF, &is_dirty)) + } + if button_clicked(fmt_png_btn) { + push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PNG, &is_dirty)) + } + if button_clicked(fmt_cbz_btn) { + push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .CBZ, &is_dirty)) + } + if button_clicked(script_src_local_btn) { + use_deepseek_script = false + push_status(&status_msg, &action_log, "Script source: Local") + } + if button_clicked(script_src_deepseek_btn) { + if !has_deepseek_key { + push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)") + } else { + use_deepseek_script = true + push_status(&status_msg, &action_log, "Script source: DeepSeek") + } + } + + if button_clicked(summary_show_btn) { + push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts)) + } + if button_clicked(summary_sort_btn) { + push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts)) + } + if controller.active_screen == .Script && button_clicked(summary_prev_btn) { + page_count := len(controller.state.script.pages) + if page_count > 0 { + summary_opts.script_page_cursor -= 1 + if summary_opts.script_page_cursor < 0 { + summary_opts.script_page_cursor = page_count - 1 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count)) + } + } + if controller.active_screen == .Script && button_clicked(summary_next_btn) { + page_count := len(controller.state.script.pages) + if page_count > 0 { + summary_opts.script_page_cursor += 1 + if summary_opts.script_page_cursor >= page_count { + summary_opts.script_page_cursor = 0 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count)) + } + } + if controller.active_screen == .Panels && button_clicked(summary_prev_btn) { + panel_count := count_script_panels(controller.state.script) + if panel_count > 0 { + summary_opts.panel_cursor -= 1 + if summary_opts.panel_cursor < 0 { + summary_opts.panel_cursor = panel_count - 1 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count)) + } + } + if controller.active_screen == .Panels && button_clicked(summary_next_btn) { + panel_count := count_script_panels(controller.state.script) + if panel_count > 0 { + summary_opts.panel_cursor += 1 + if summary_opts.panel_cursor >= panel_count { + summary_opts.panel_cursor = 0 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count)) + } + } + + if button_clicked(new_btn) { + if is_dirty && !shift_down { + push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?")) + } else { + push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, false)) + } + } + if button_clicked(save_btn) { + push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project")) + } + if button_clicked(open_btn) { + if is_dirty && !shift_down { + push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?")) + } else { + push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at)) + } + } + if button_clicked(auto_save_btn) { + push_status(&status_msg, &action_log, run_auto_all_save_action(&controller, &project_path, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at, &last_autosave_at, &last_save_at)) + } + if button_clicked(autosave_btn) { + push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled)) + } + if button_clicked(help_btn) { + toggle_help_overlay(&show_help_overlay) + } + if button_clicked(clear_field_btn) { + push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty)) + } + if button_clicked(reset_helpers_btn) { + push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format)) + } + if controller.active_screen != .Script { + if button_clicked(log_reset_btn) { + push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first)) + } + if button_clicked(report_file_btn) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx)) + } + if button_clicked(log_copy_btn) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx)) + } + if button_clicked(diag_file_btn) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx)) + } + if button_clicked(log_clear_btn) { + set_status(&status_msg, clear_action_log_with_message(&action_log)) + } + if button_clicked(status_copy_btn) { + push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard")) + } + if button_clicked(diag_copy_btn) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx)) + } + } + if controller.active_screen == .Script && button_clicked(script_copy_page_btn) { + page_text := build_script_page_detail_text(controller.state, summary_opts.script_page_cursor) + push_status(&status_msg, &action_log, copy_text_with_status(page_text, "Copied script page detail")) + delete(page_text) + } + if controller.active_screen == .Script && button_clicked(script_copy_all_btn) { + full_text := build_full_script_text(controller.state) + push_status(&status_msg, &action_log, copy_text_with_status(full_text, "Copied full script")) + delete(full_text) + } + if button_clicked(script_btn) { + push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty)) + } + if button_clicked(panels_btn) { + push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty)) + } + if button_clicked(layout_btn) { + push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty)) + } + if button_clicked(export_btn) { + push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at)) + } + if button_clicked(next_btn) { + push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at)) + } + if button_clicked(auto_btn) { + push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at)) + } + + if rl.IsKeyPressed(.F5) { + push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty)) + } + if rl.IsKeyPressed(.F6) { + push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty)) + } + if rl.IsKeyPressed(.F7) { + push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty)) + } + if rl.IsKeyPressed(.F8) { + push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at)) + } + if rl.IsKeyPressed(.F9) { + push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at)) + } + if rl.IsKeyPressed(.F10) { + push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at)) + } + + ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL) + if ctrl_down && rl.IsKeyPressed(.S) { + push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project")) + } + if ctrl_down && rl.IsKeyPressed(.N) { + if is_dirty && !shift_down { + push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?")) + } else { + push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, true)) + } + } + if ctrl_down && rl.IsKeyPressed(.O) { + if is_dirty && !shift_down { + push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?")) + } else { + push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at)) + } + } + if ctrl_down && rl.IsKeyPressed(.E) { + push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at)) + } + if ctrl_down && rl.IsKeyPressed(.G) { + if use_deepseek_script { + use_deepseek_script = false + push_status(&status_msg, &action_log, "Script source: Local") + } else { + if !has_deepseek_key { + push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)") + } else { + use_deepseek_script = true + push_status(&status_msg, &action_log, "Script source: DeepSeek") + } + } + } + if ctrl_down && rl.IsKeyPressed(.H) { + push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts)) + } + if ctrl_down && rl.IsKeyPressed(.J) { + push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts)) + } + if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) { + page_count := len(controller.state.script.pages) + if page_count > 0 { + summary_opts.script_page_cursor -= 1 + if summary_opts.script_page_cursor < 0 { + summary_opts.script_page_cursor = page_count - 1 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count)) + } + } + if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) { + page_count := len(controller.state.script.pages) + if page_count > 0 { + summary_opts.script_page_cursor += 1 + if summary_opts.script_page_cursor >= page_count { + summary_opts.script_page_cursor = 0 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count)) + } + } + if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) { + panel_count := count_script_panels(controller.state.script) + if panel_count > 0 { + summary_opts.panel_cursor -= 1 + if summary_opts.panel_cursor < 0 { + summary_opts.panel_cursor = panel_count - 1 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count)) + } + } + if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) { + panel_count := count_script_panels(controller.state.script) + if panel_count > 0 { + summary_opts.panel_cursor += 1 + if summary_opts.panel_cursor >= panel_count { + summary_opts.panel_cursor = 0 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count)) + } + } + if ctrl_down && rl.IsKeyPressed(.MINUS) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs-5)) + } + if ctrl_down && rl.IsKeyPressed(.EQUAL) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs+5)) + } + if ctrl_down && rl.IsKeyPressed(.SEVEN) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15)) + } + if ctrl_down && rl.IsKeyPressed(.EIGHT) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30)) + } + if ctrl_down && rl.IsKeyPressed(.NINE) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60)) + } + if ctrl_down && rl.IsKeyPressed(.ZERO) { + push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format)) + } + if ctrl_down && !shift_down && rl.IsKeyPressed(.L) { + set_status(&status_msg, clear_action_log_with_message(&action_log)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.L) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.Z) { + push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.W) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx)) + } + if ctrl_down && rl.IsKeyPressed(.V) { + push_status_if_nonempty(&status_msg, &action_log, paste_clipboard_into_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty)) + } + if ctrl_down && rl.IsKeyPressed(.BACKSPACE) { + push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.I) { + field_text := selected_field_value(selected_field, controller.state, export_path, local_script_pages, project_path, autosave_interval_text) + push_status(&status_msg, &action_log, copy_text_with_status(field_text, "Copied selected field to clipboard")) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.C) { + push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard")) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.Y) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.T) { + push_status(&status_msg, &action_log, toggle_log_lines_with_message(&log_show_lines)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.B) { + push_status(&status_msg, &action_log, toggle_log_order_with_message(&log_oldest_first)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.R) { + diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first) + push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.X) { + push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard")) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.P) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.D) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.G) { + push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.J) { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.K) { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.M) { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.U) { + push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.F) { + push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format)) + } + if ctrl_down && shift_down && rl.IsKeyPressed(.A) { + push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled)) + } + + if !interaction_locked { + for { + ch := rl.GetCharPressed() + if ch == 0 { break } + if ch < 32 || ch > 126 { continue } + if selected_field == 6 && (ch < '0' || ch > '9') { + continue + } + switch selected_field { + case 0: append_char(&controller.state.story_idea, ch) + case 1: append_char(&controller.state.story_genre, ch) + case 2: append_char(&controller.state.target_audience, ch) + case 3: append_char(&export_path, ch) + case 4: append_char(&local_script_pages, ch) + case 5: append_char(&project_path, ch) + case 6: append_char(&autosave_interval_text, ch) + } + is_dirty = true + } + if rl.IsKeyPressed(.BACKSPACE) { + switch selected_field { + case 0: pop_char(&controller.state.story_idea) + case 1: pop_char(&controller.state.story_genre) + case 2: pop_char(&controller.state.target_audience) + case 3: pop_char(&export_path) + case 4: pop_char(&local_script_pages) + case 5: pop_char(&project_path) + case 6: pop_char(&autosave_interval_text) + } + is_dirty = true + } + } + + } + + push_status_if_nonempty(&status_msg, &action_log, autosave_tick_with_message(&project_path, controller.state, autosave_enabled, &is_dirty, &last_autosave_at, &last_save_at, autosave_interval_s)) + + rl.BeginDrawing() + rl.ClearBackground(BG_BASE) + screen_w := rl.GetScreenWidth() + screen_h := rl.GetScreenHeight() + main_w := screen_w - 282 - 20 + if main_w < 960 { + main_w = 960 + } + + rl.DrawRectangle(0, 0, 260, screen_h, BG_SIDEBAR) + rl.DrawRectangle(260, 0, screen_w-260, 72, BG_TOPBAR) + rl.DrawLine(260, 72, screen_w, 72, BORDER_DIVIDER) + rl.DrawLine(260, 0, 260, screen_h, BORDER_DIVIDER) + + draw_card(rl.Rectangle{x = 14, y = 10, width = 236, height = 64}) + rl.DrawText("comic-odin", 24, 22, 23, BRAND_TITLE) + rl.DrawText("Native GUI", 24, 46, 14, BRAND_SUBTITLE) + draw_section_title(24, 68, "Screens") + draw_card(rl.Rectangle{x = 14, y = 86, width = 236, height = 296}) + draw_nav_item(nav_story, "1 Story", controller.active_screen == .Story) + draw_nav_item(nav_script, "2 Script", controller.active_screen == .Script) + draw_nav_item(nav_chars, "3 Characters", controller.active_screen == .Characters) + draw_nav_item(nav_panels, "4 Panels", controller.active_screen == .Panels) + draw_nav_item(nav_layout, "5 Layout", controller.active_screen == .Layout) + draw_nav_item(nav_bubbles, "6 Bubbles", controller.active_screen == .Bubbles) + draw_nav_item(nav_export, "7 Export", controller.active_screen == .Export) + draw_nav_item(nav_community, "8 Community", controller.active_screen == .Community) + draw_sidebar_shortcuts(screen_h_loop) + + // --- Pipeline Stepper in Topbar --- + draw_card(rl.Rectangle{x = 282, y = 12, width = f32(main_w), height = 48}) + rl.DrawText(fmt.ctprintf("%s", ui.screen_name(controller.active_screen)), 300, 26, 20, BRAND_TITLE) + + script_ok := len(controller.state.script.pages) > 0 + panels_ok := len(controller.state.panel_images) > 0 + layout_ok := len(controller.state.page_layouts) > 0 + export_ok := panels_ok && layout_ok + + step_x := i32(460) + step_y := i32(36) + step_spacing := i32(140) + + // Draw connecting lines first + for i in 0..<3 { + x1 := step_x + i32(i)*step_spacing + 24 + x2 := step_x + i32(i+1)*step_spacing - 24 + c := STEP_LINE_TODO + if (i == 0 && panels_ok) || (i == 1 && layout_ok) || (i == 2 && export_ok) { + c = STEP_LINE_DONE + } + rl.DrawLineEx(rl.Vector2{f32(x1), f32(step_y)}, rl.Vector2{f32(x2), f32(step_y)}, 2.0, c) + } + + // Helper to draw a single pipeline step + draw_step := proc(x, y: i32, label: string, done: bool) { + fill := STEP_TODO_FILL + border := STEP_TODO_BORDER + text_col := STEP_LABEL_TODO + if done { + fill = STEP_DONE_FILL + border = STEP_DONE_BORDER + text_col = STEP_LABEL_DONE + } + rl.DrawCircle(x, y, 10, fill) + rl.DrawCircleLines(x, y, 10, border) + // Draw checkmark if done + if done { + rl.DrawLineEx(rl.Vector2{f32(x-3), f32(y+1)}, rl.Vector2{f32(x-1), f32(y+4)}, 2.0, BG_BASE) + rl.DrawLineEx(rl.Vector2{f32(x-1), f32(y+4)}, rl.Vector2{f32(x+4), f32(y-3)}, 2.0, BG_BASE) + } + draw_text_fitted(label, x - 24, y + 16, 14, 48, 7, text_col) + } + + draw_step(step_x, step_y, "Script", script_ok) + draw_step(step_x + step_spacing, step_y, "Panels", panels_ok) + draw_step(step_x + step_spacing*2, step_y, "Layout", layout_ok) + draw_step(step_x + step_spacing*3, step_y, "Export", export_ok) + + // Pages input on far right of topbar + rl.DrawText("Local script pages", screen_w_loop - 250, 26, 16, TEXT_SECONDARY) + draw_input_field(pages_rec, local_script_pages, selected_field == 4) + + draw_card(rl.Rectangle{x = 282, y = 82, width = f32(main_w), height = 356}) + draw_card(rl.Rectangle{x = 282, y = 460, width = f32(main_w), height = 110}) + draw_section_title(300, 92, "Project Setup") + draw_section_title(300, 470, "Actions") + + rl.DrawText("Story Idea", 290, 96, 16, TEXT_SECONDARY) + draw_input_field(idea_rec, controller.state.story_idea, selected_field == 0) + + rl.DrawText("Genre", 290, 152, 16, TEXT_SECONDARY) + draw_input_field(genre_rec, controller.state.story_genre, selected_field == 1) + + rl.DrawText("Audience", 290, 208, 16, TEXT_SECONDARY) + draw_input_field(audience_rec, controller.state.target_audience, selected_field == 2) + + rl.DrawText("Export Path", 290, 264, 16, TEXT_SECONDARY) + draw_input_field(export_rec, export_path, selected_field == 3) + + rl.DrawText("Project Path", 290, 320, 16, TEXT_SECONDARY) + draw_input_field(project_rec, project_path, selected_field == 5) + + // Compact utility row below inputs + draw_small_button(export_copy_btn, "Copy Export") + draw_small_button(export_preset_btn, "Preset Ext") + draw_small_button(path_fix_btn, "Fix Exp Ext") + draw_small_button(project_fix_btn, "Fix Proj Ext") + draw_small_button(project_from_export_btn, "Proj From Exp") + draw_small_button(export_project_btn, "Exp From Proj") + + rl.DrawText("Format", 290, 404, 16, TEXT_SECONDARY) + draw_nav_item(fmt_pdf_btn, "PDF", export_format == .PDF) + draw_nav_item(fmt_png_btn, "PNG", export_format == .PNG) + draw_nav_item(fmt_cbz_btn, "CBZ", export_format == .CBZ) + + rl.DrawText("Script Source", 700, 404, 16, TEXT_SECONDARY) + draw_nav_item(script_src_local_btn, "Local", !use_deepseek_script) + draw_nav_item(script_src_deepseek_btn, "DeepSeek", use_deepseek_script) + if !has_deepseek_key { + draw_summary_subline(1024, 408, "set DEEPSEEK_API_KEY", KEY_MISSING_COLOR) + } + + next_hint := gui_next_hint_with_source(controller, use_deepseek_script) + recommended_label := recommended_label_from_hint(next_hint) + script_btn_label := "Generate Script Local" + if use_deepseek_script { + script_btn_label = "Generate Script" + } + + draw_button_warning(new_btn, "New Project") + if recommended_label == "Generate Script" || recommended_label == "Generate Script Local" { + draw_button_recommended(script_btn, script_btn_label) + } else { + draw_button(script_btn, script_btn_label) + } + if recommended_label == "Generate Panels Local" { + draw_button_recommended(panels_btn, "Generate Panels Local") + } else { + draw_button_state(panels_btn, "Generate Panels Local", can_generate_panels) + } + if recommended_label == "Layout" { + draw_button_recommended(layout_btn, "Layout Pages") + } else { + draw_button_state(layout_btn, "Layout Pages", can_layout) + } + if recommended_label == "Export" { + draw_button_recommended(export_btn, "Export") + } else { + draw_button_state(export_btn, "Export", can_export) + } + + draw_button_soft_accent(save_btn, "Save") + draw_button_soft_accent(open_btn, "Open") + draw_button_primary(next_btn, "Next Step") + draw_button_primary(auto_btn, "Auto-All") + draw_button_primary(auto_save_btn, "Auto-All + Save") + + draw_button_soft_accent(autosave_btn, autosave_enabled ? "Autosave: yes" : "Autosave: no") + rl.DrawText("Interval(s)", 500, 574, 16, TEXT_SECONDARY) + draw_input_field(autosave_rec, autosave_interval_text, selected_field == 6) + draw_small_button(autosave_15_btn, "15") + draw_small_button(autosave_30_btn, "30") + draw_small_button(autosave_60_btn, "60") + draw_button_soft_accent(help_btn, "Help (/)") + draw_button(clear_field_btn, "Clear Field") + draw_button_soft_accent(reset_helpers_btn, "Reset Helpers") + + label := "idea" + if selected_field == 1 { label = "genre" } + if selected_field == 2 { label = "audience" } + if selected_field == 3 { label = "export path" } + if selected_field == 4 { label = "local pages" } + if selected_field == 5 { label = "project path" } + if selected_field == 6 { label = "autosave interval" } + if !compact_mode { + hint_msg := button_readiness_hint(rl.GetMousePosition(), panels_btn, layout_btn, export_btn, can_generate_panels, can_layout, can_export) + if len(hint_msg) > 0 { + draw_hint_pill(rl.Rectangle{x = 1000, y = 580, width = 210, height = 22}, fmt.tprintf("%s", hint_msg), true) + } + } + + draw_card(rl.Rectangle{x = 282, y = f32(lower_y_loop-140), width = f32(status_w_loop), height = 160}) + now_draw := rl.GetTime() + status_y := lower_y_loop - 126 + rl.DrawText("Status", 300, status_y, 19, BRAND_TITLE) + rl.DrawLine(370, status_y+10, i32(282+status_w_loop)-14, status_y+10, BORDER_DIVIDER) + draw_text_fitted(status_msg, 300, status_y+26, 18, int(status_w_loop-36), 8, status_text_color(status_msg)) + draw_readiness_row(controller, 300, status_y+50) + ready_count, total_count := ready_stage_count(controller) + progress := f32(0) + if total_count > 0 { + progress = f32(ready_count) / f32(total_count) + } + draw_progress_bar(300, status_y+84, status_w_loop-26, progress) + draw_text_fitted(fmt.tprintf("Pipeline: %d/%d", ready_count, total_count), 300, status_y+102, 14, 122, 7, TEXT_TERTIARY) + draw_text_fitted(fmt.tprintf("Next: %s", gui_next_hint_with_source(controller, use_deepseek_script)), 430, status_y+102, 14, int(status_w_loop-166), 7, TEXT_SECONDARY) + draw_status_badge(rl.Rectangle{x = 300, y = f32(status_y+120), width = 118, height = 22}, fmt.tprintf("Dirty: %s", yn(is_dirty)), !is_dirty) + draw_status_badge(rl.Rectangle{x = 430, y = f32(status_y+120), width = 188, height = 22}, fmt.tprintf("Autosave: %s (%ds)", yn(autosave_enabled), autosave_secs), autosave_enabled) + save_meta := "save: never" + if last_save_at >= 0 { + save_meta = fmt.tprintf("save: %.0fs", now_draw-last_save_at) + } + export_meta := "export: never" + if last_export_at >= 0 { + export_meta = fmt.tprintf("export: %.0fs", now_draw-last_export_at) + } + draw_text_fitted(fmt.tprintf("%s | %s", save_meta, export_meta), 630, status_y+124, 13, int(status_w_loop-26), 7, TEXT_TERTIARY) + path_fix_project_status_btn.y = f32(status_y + 114) + path_fix_export_status_btn.y = f32(status_y + 114) + path_fix_all_status_btn.y = f32(status_y + 114) + draw_small_button_state(path_fix_project_status_btn, "P", !project_path_ok) + draw_small_button_state(path_fix_export_status_btn, "E", !export_path_ok) + draw_small_button_state(path_fix_all_status_btn, "PE", !project_path_ok || !export_path_ok) + + draw_screen_summary(controller, export_path, 300, lower_y_loop+12, status_w_loop-8, summary_opts) + if controller.active_screen == .Script || controller.active_screen == .Layout || controller.active_screen == .Panels { + if controller.active_screen == .Script || controller.active_screen == .Layout { + show_txt := "Top" + sort_txt := "Asc" + if controller.active_screen == .Script { + if summary_opts.script_show_all { show_txt = "All" } + if summary_opts.script_desc { sort_txt = "Desc" } + } else { + if summary_opts.layout_show_all { show_txt = "All" } + if summary_opts.layout_desc { sort_txt = "Desc" } + } + show_btn_label := "Show:Top" + if show_txt == "All" { + show_btn_label = "Show:All" + } + sort_btn_label := "Sort:Asc" + if sort_txt == "Desc" { + sort_btn_label = "Sort:Desc" + } + draw_small_button(summary_show_btn, show_btn_label) + draw_small_button(summary_sort_btn, sort_btn_label) + } + if controller.active_screen == .Script { + draw_small_button(summary_prev_btn, "< Pg") + draw_small_button(summary_next_btn, "Pg >") + } else if controller.active_screen == .Panels { + draw_small_button(summary_prev_btn, "< Pn") + draw_small_button(summary_next_btn, "Pn >") + } + if !compact_mode { + hint_label := "Ctrl+[ / Ctrl+]" + if controller.active_screen == .Script || controller.active_screen == .Layout { + hint_label = "Ctrl+H / Ctrl+J" + } + draw_hint_pill(rl.Rectangle{x = f32(282 + status_w_loop - 186), y = f32(lower_y_loop + 46), width = 172, height = 20}, hint_label, false) + } + } + if !compact_mode { + draw_hint_pill(rl.Rectangle{x = 300, y = f32(lower_y_loop + 206), width = f32(status_w_loop-18), height = 24}, "Tip: New/Open show confirm modal when dirty (Shift still quick-confirms)", true) + } + + if controller.active_screen == .Script { + draw_script_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.script_page_cursor) + draw_small_button(script_copy_page_btn, "Copy Page") + draw_small_button(script_copy_all_btn, "Copy All") + draw_text_fitted("Ctrl+[ / Ctrl+] page nav", log_x_loop+216, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-228), 7, TEXT_TERTIARY) + } else if controller.active_screen == .Panels { + retry, new_cursor := draw_panels_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.panel_cursor) + panel_count := count_script_panels(controller.state.script) + if summary_opts.panel_cursor != new_cursor { + summary_opts.panel_cursor = new_cursor + push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count)) + } + if retry { + panel, _, ok := panel_by_flat_index(controller.state.script, summary_opts.panel_cursor) + if ok { + msg := action_regenerate_panel(&controller, panel.panel_id) + push_status(&status_msg, &action_log, msg) + } + } + + wheel := rl.GetMouseWheelMove() + if wheel != 0 && panel_count > 0 { + summary_opts.panel_cursor -= int(wheel) + if summary_opts.panel_cursor < 0 { + summary_opts.panel_cursor = 0 + } + if summary_opts.panel_cursor >= panel_count { + summary_opts.panel_cursor = panel_count - 1 + } + push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count)) + } + + draw_text_fitted("Ctrl+[ / Ctrl+] panel nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY) + } else { + draw_card(rl.Rectangle{x = f32(log_x_loop), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-2), height = 200}) + draw_section_title(log_x_loop+18, lower_y_loop+6, "Action Log") + draw_subtle_strip(rl.Rectangle{x = f32(log_x_loop+12), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-26), height = 40}) + draw_small_button(log_reset_btn, "Default") + draw_small_button(report_file_btn, "Report") + draw_small_button(log_copy_btn, "LogCopy") + draw_small_button(diag_file_btn, "DiagFile") + draw_small_button(status_copy_btn, "Copy") + draw_small_button(log_clear_btn, "Clear") + draw_small_button(diag_copy_btn, "Diag") + order_label := "newest" + if log_oldest_first { + order_label = "oldest" + } + draw_text_fitted(fmt.tprintf("View: %d lines, %s first", log_show_lines, order_label), log_x_loop+18, lower_y_loop+36, 13, 216, 7, TEXT_TERTIARY) + draw_action_log(action_log, log_x_loop+18, lower_y_loop+52, log_show_lines, log_oldest_first) + } + draw_toast(action_log, log_x_loop+8, 70, main_w_loop-status_w_loop-18) + if show_help_overlay { + draw_help_overlay() + } + if show_confirm_overlay { + draw_confirm_overlay(pending_confirm_action) + draw_button_danger(confirm_yes_btn, "Confirm") + draw_button_soft_accent(confirm_no_btn, "Cancel") + } + + rl.EndDrawing() + } + + core.dispose_state(state) + state^ = controller.state + controller.state = core.Comic_State{} + return shared.ok() +} diff --git a/odin/src/gui/session_helpers.odin b/odin/src/gui/session_helpers.odin new file mode 100644 index 0000000..379538c --- /dev/null +++ b/odin/src/gui/session_helpers.odin @@ -0,0 +1,212 @@ +package gui + +import "core:fmt" +import rl "vendor:raylib" +import "../core" +import "../shared" +import "../ui" + +set_status :: proc(status: ^string, msg: string) { + delete(status^) + status^ = fmt.aprintf("%s", msg) +} + +push_status :: proc(status: ^string, log: ^Action_Log, msg: string) { + set_status(status, msg) + action_log_push(log, status^) +} + +push_status_if_nonempty :: proc(status: ^string, log: ^Action_Log, msg: string) { + if len(msg) > 0 { + push_status(status, log, msg) + } +} + +push_dirty_status :: proc(is_dirty: ^bool, status: ^string, log: ^Action_Log, msg: string) { + is_dirty^ = true + push_status(status, log, msg) +} + +screen_status_label :: proc(screen: ui.App_Screen) -> string { + switch screen { + case .Story: return "Story" + case .Script: return "Script" + case .Characters: return "Characters" + case .Panels: return "Panels" + case .Layout: return "Layout" + case .Bubbles: return "Bubbles" + case .Export: return "Export" + case .Community: return "Community" + } + return "Unknown" +} + +navigate_screen_with_status :: proc(controller: ^ui.App_Controller, screen: ui.App_Screen) -> string { + err := ui.navigate_to_screen(controller, screen) + if !shared.is_ok(err) { + return err.message + } + return fmt.aprintf("Screen: %s", screen_status_label(screen)) +} + +request_confirmation :: proc(show_confirm_overlay, show_help_overlay: ^bool, pending_confirm_action: ^Pending_Confirm_Action, action: Pending_Confirm_Action, prompt: string) -> string { + show_confirm_overlay^ = true + show_help_overlay^ = false + pending_confirm_action^ = action + return prompt +} + +toggle_autosave_with_message :: proc(autosave_enabled: ^bool) -> string { + autosave_enabled^ = !autosave_enabled^ + return fmt.aprintf("Autosave: %s", yn(autosave_enabled^)) +} + +reset_helper_fields_with_message :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) -> string { + reset_helper_fields(export_path, local_script_pages, autosave_interval_text, f) + return "Reset helper fields to defaults" +} + +toggle_help_overlay :: proc(show_help_overlay: ^bool) { + show_help_overlay^ = !show_help_overlay^ +} + +close_help_overlay_if_open :: proc(show_help_overlay: ^bool) { + if show_help_overlay^ { + show_help_overlay^ = false + } +} + +clear_action_log_with_message :: proc(log: ^Action_Log) -> string { + action_log_dispose(log) + return "Action log cleared" +} + +reset_log_view :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) { + log_show_lines^ = 6 + log_oldest_first^ = false +} + +reset_log_view_with_message :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) -> string { + reset_log_view(log_show_lines, log_oldest_first) + return "Reset log view" +} + +toggle_log_lines_with_message :: proc(log_show_lines: ^i32) -> string { + if log_show_lines^ == 6 { + log_show_lines^ = 4 + } else { + log_show_lines^ = 6 + } + return fmt.aprintf("Log lines: %d", log_show_lines^) +} + +toggle_log_order_with_message :: proc(log_oldest_first: ^bool) -> string { + log_oldest_first^ = !log_oldest_first^ + order := "newest" + if log_oldest_first^ { + order = "oldest" + } + return fmt.aprintf("Log order: %s first", order) +} + +selected_field_value :: proc(selected_field: int, state: core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: string) -> string { + switch selected_field { + case 0: return state.story_idea + case 1: return state.story_genre + case 2: return state.target_audience + case 3: return export_path + case 4: return local_script_pages + case 5: return project_path + case 6: return autosave_interval_text + } + return "" +} + +clear_selected_field :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string) -> bool { + switch selected_field { + case 0: + if len(state.story_idea) == 0 { return false } + state.story_idea = "" + case 1: + if len(state.story_genre) == 0 { return false } + state.story_genre = "" + case 2: + if len(state.target_audience) == 0 { return false } + state.target_audience = "" + case 3: + if len(export_path^) == 0 { return false } + export_path^ = "" + case 4: + if len(local_script_pages^) == 0 { return false } + local_script_pages^ = "" + case 5: + if len(project_path^) == 0 { return false } + project_path^ = "" + case 6: + if len(autosave_interval_text^) == 0 { return false } + autosave_interval_text^ = "" + case: + return false + } + return true +} + +clear_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string { + if clear_selected_field(selected_field, state, export_path, local_script_pages, project_path, autosave_interval_text) { + is_dirty^ = true + return "Cleared selected field" + } + return "Selected field already empty" +} + +paste_clipboard_into_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string { + clip_raw := rl.GetClipboardText() + if clip_raw == nil { + return "" + } + clip_text := fmt.aprintf("%s", clip_raw) + switch selected_field { + case 0: state.story_idea = clip_text + case 1: state.story_genre = clip_text + case 2: state.target_audience = clip_text + case 3: export_path^ = clip_text + case 4: local_script_pages^ = clip_text + case 5: project_path^ = clip_text + case 6: autosave_interval_text^ = clip_text + } + is_dirty^ = true + return "Pasted clipboard into selected field" +} + +Action_Log :: struct { + entries: [8]string, + owned: [8]bool, + entry_times: [8]f64, + count: int, + last_push_at: f64, +} + +action_log_push :: proc(log: ^Action_Log, msg: string) { + now := rl.GetTime() + idx := log.count % len(log.entries) + if log.owned[idx] { + delete(log.entries[idx]) + } + log.entries[idx] = fmt.aprintf("%s", msg) + log.owned[idx] = true + log.entry_times[idx] = now + log.count += 1 + log.last_push_at = now +} + +action_log_dispose :: proc(log: ^Action_Log) { + for i in 0.. (ready: int, total: int) { + script_ok := len(controller.state.script.pages) > 0 + panels_ok := len(controller.state.panel_images) > 0 + layout_ok := len(controller.state.page_layouts) > 0 + export_ok := panels_ok && layout_ok + ready = 0 + if script_ok { ready += 1 } + if panels_ok { ready += 1 } + if layout_ok { ready += 1 } + if export_ok { ready += 1 } + return ready, 4 +} + +draw_progress_bar :: proc(x, y, w: i32, progress: f32) { + track := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 12} + rl.DrawRectangleRounded(track, RADIUS_BAR, 8, PROGRESS_TRACK) + fill_w := i32(f32(w) * progress) + if fill_w > 0 { + fill := rl.Rectangle{x = f32(x), y = f32(y), width = f32(fill_w), height = 12} + rl.DrawRectangleRounded(fill, RADIUS_BAR, 8, PROGRESS_FILL) + } +} + +draw_readiness_row :: proc(controller: ui.App_Controller, x, y: i32) { + script_ok := len(controller.state.script.pages) > 0 + panels_ok := len(controller.state.panel_images) > 0 + layout_ok := len(controller.state.page_layouts) > 0 + export_ok := panels_ok && layout_ok + draw_readiness_chip(x, y, 120, "Script", script_ok) + draw_readiness_chip(x+126, y, 120, "Panels", panels_ok) + draw_readiness_chip(x+252, y, 120, "Layout", layout_ok) + draw_readiness_chip(x+378, y, 120, "Export", export_ok) +} + +export_block_reason :: proc(state: core.Comic_State) -> string { + if len(state.panel_images) == 0 && len(state.page_layouts) == 0 { + return "need panels + layout" + } + if len(state.panel_images) == 0 { + return "need panels" + } + if len(state.page_layouts) == 0 { + return "need layout" + } + return "" +} + +draw_screen_summary :: proc(controller: ui.App_Controller, export_path: string, x, y, w: i32, opts: Summary_View_Options) { + draw_card(rl.Rectangle{x = f32(x-18), y = f32(y-12), width = f32(w), height = 200}) + rl.DrawText("Screen Summary", x, y, 22, SUMMARY_TITLE) + chip_base := x + w - 258 + if chip_base < x+210 { + chip_base = x + 210 + } + draw_stat_chip(chip_base, y-4, "Pages", len(controller.state.script.pages)) + draw_stat_chip(chip_base+86, y-4, "Panels", len(controller.state.panel_images)) + draw_stat_chip(chip_base+172, y-4, "Layout", len(controller.state.page_layouts)) + + switch controller.active_screen { + case .Story: + draw_summary_line(x, y+30, fmt.tprintf("Idea length: %d chars", len(controller.state.story_idea)), rl.DARKGRAY) + draw_summary_line(x, y+54, fmt.tprintf("Genre: %s", controller.state.story_genre), rl.DARKGRAY) + draw_summary_line(x, y+78, fmt.tprintf("Audience: %s", controller.state.target_audience), rl.DARKGRAY) + rl.DrawText("Use Generate Script Local to begin", x, y+112, 18, SUMMARY_HINT) + case .Script: + draw_summary_line(x, y+30, fmt.tprintf("Title: %s", controller.state.script.title), rl.DARKGRAY) + page_count := len(controller.state.script.pages) + draw_summary_line(x, y+54, fmt.tprintf("Pages: %d | Characters: %d", page_count, len(controller.state.script.characters)), rl.DARKGRAY) + if page_count == 0 { + rl.DrawText("No script pages yet. Generate Script Local to continue.", x, y+86, 18, SUMMARY_HINT) + } else { + cursor := opts.script_page_cursor + if cursor < 0 { + cursor = 0 + } + if cursor >= page_count { + cursor = page_count - 1 + } + page := controller.state.script.pages[cursor] + draw_summary_line(x, y+78, fmt.tprintf("Viewing page %d/%d (script page #%d)", cursor+1, page_count, page.page_number), SUMMARY_ACCENT) + draw_summary_line(x, y+100, fmt.tprintf("Panels on page: %d", len(page.panels)), rl.DARKGRAY) + line_y := y + 124 + show_panels := len(page.panels) + if show_panels > 2 { + show_panels = 2 + } + for i in 0.. 0 { + draw_summary_subline(x+12, line_y+i32(i*28)+14, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-54), 7), SUMMARY_DIM) + } + } + if len(page.panels) > show_panels { + draw_summary_subline(x+w-158, y+166, fmt.tprintf("+%d more panels", len(page.panels)-show_panels), SUMMARY_ACCENT) + } + } + case .Characters: + draw_summary_line(x, y+30, fmt.tprintf("Character count: %d", len(controller.state.characters)), rl.DARKGRAY) + rl.DrawText("Character editor is scaffolded", x, y+54, 18, rl.DARKGRAY) + rl.DrawText("Use script generation to populate", x, y+78, 18, rl.DARKGRAY) + case .Panels: + script_panel_count := count_script_panels(controller.state.script) + draw_summary_line(x, y+30, fmt.tprintf("Panel images: %d", len(controller.state.panel_images)), rl.DARKGRAY) + draw_summary_line(x, y+54, fmt.tprintf("Script panels: %d", script_panel_count), rl.DARKGRAY) + draw_summary_line(x, y+78, fmt.tprintf("Script pages: %d", len(controller.state.script.pages)), rl.DARKGRAY) + if script_panel_count == 0 { + rl.DrawText("No script panels yet. Generate Script first.", x, y+112, 18, SUMMARY_HINT) + } else { + pidx := clamp_panel_cursor(script_panel_count, opts.panel_cursor) + panel, page_num, _ := panel_by_flat_index(controller.state.script, pidx) + status := "missing" + if _, has_img := controller.state.panel_images[panel.panel_id]; has_img { + status = "ready" + } + draw_summary_line(x, y+102, fmt.tprintf("Viewing panel %d/%d • page %d # %d", pidx+1, script_panel_count, page_num, panel.panel_number), SUMMARY_ACCENT) + draw_summary_subline(x, y+124, fmt.tprintf("%s • %s", panel.panel_id, status), SUMMARY_SUBLINE) + } + case .Layout: + draw_summary_line(x, y+30, fmt.tprintf("Layout pages: %d", len(controller.state.page_layouts)), rl.DARKGRAY) + draw_summary_line(x, y+54, fmt.tprintf("Page size: %v", controller.state.page_size), rl.DARKGRAY) + layout_show := len(controller.state.page_layouts) + if !opts.layout_show_all && layout_show > 3 { layout_show = 3 } + if layout_show == 0 { + rl.DrawText("No layouts yet. Use Layout Auto after panels are ready.", x, y+86, 18, SUMMARY_HINT) + } else { + if opts.layout_desc { + for i in 0.. 0 { + last := controller.state.page_layouts[len(controller.state.page_layouts)-1] + draw_summary_subline(x, y+102, fmt.tprintf("Last layout pattern: %s", last.pattern_id), SUMMARY_DIM) + } + reason := export_block_reason(controller.state) + if len(reason) > 0 { + draw_summary_line(x, y+124, fmt.tprintf("Export blocked: %s", reason), ERROR) + } else { + rl.DrawText("Use Export button or Ctrl+E", x, y+124, 18, SUMMARY_HINT) + } + case .Community: + rl.DrawText("Community features coming soon", x, y+30, 18, rl.DARKGRAY) + rl.DrawText("Current focus: local GUI workflows", x, y+54, 18, rl.DARKGRAY) + } +} + +clamp_script_cursor :: proc(page_count, cursor: int) -> int { + if page_count <= 0 { + return 0 + } + if cursor < 0 { + return 0 + } + if cursor >= page_count { + return page_count - 1 + } + return cursor +} + +clamp_panel_cursor :: proc(panel_count, cursor: int) -> int { + if panel_count <= 0 { + return 0 + } + if cursor < 0 { + return 0 + } + if cursor >= panel_count { + return panel_count - 1 + } + return cursor +} + +panel_by_flat_index :: proc(script: core.Comic_Script, panel_idx: int) -> (core.Panel, int, bool) { + if panel_idx < 0 { + return core.Panel{}, 0, false + } + flat := 0 + for page in script.pages { + for panel in page.panels { + if flat == panel_idx { + return panel, page.page_number, true + } + flat += 1 + } + } + return core.Panel{}, 0, false +} + +build_script_page_detail_text :: proc(state: core.Comic_State, cursor: int) -> string { + page_count := len(state.script.pages) + if page_count == 0 { + return fmt.aprintf("No script pages available.") + } + idx := clamp_script_cursor(page_count, cursor) + page := state.script.pages[idx] + out := fmt.aprintf("Title: %s\nPage %d/%d (script page #%d)\nPanels: %d", state.script.title, idx+1, page_count, page.page_number, len(page.panels)) + for pn in page.panels { + desc := pn.description + if len(desc) == 0 { + desc = "(no description)" + } + next := fmt.aprintf("%s\n\nPanel %d [%v]\n%s", out, pn.panel_number, pn.shot_type, desc) + delete(out) + out = next + for d in pn.dialogue { + line := fmt.aprintf("%s\n- %s: %s", out, d.speaker_id, d.text) + delete(out) + out = line + } + if len(pn.caption) > 0 { + line := fmt.aprintf("%s\n caption: %s", out, pn.caption) + delete(out) + out = line + } + } + return out +} + +build_full_script_text :: proc(state: core.Comic_State) -> string { + page_count := len(state.script.pages) + if page_count == 0 { + return fmt.aprintf("No script pages available.") + } + out := fmt.aprintf("Title: %s\nSynopsis: %s\nCharacters: %d\nPages: %d", state.script.title, state.script.synopsis, len(state.script.characters), page_count) + for page in state.script.pages { + head := fmt.aprintf("%s\n\n=== Page %d (%d panels) ===", out, page.page_number, len(page.panels)) + delete(out) + out = head + for pn in page.panels { + desc := pn.description + if len(desc) == 0 { + desc = "(no description)" + } + row := fmt.aprintf("%s\nPanel %d: %s", out, pn.panel_number, desc) + delete(out) + out = row + } + } + return out +} + + +draw_script_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) { + draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)}) + draw_section_title(x+18, y+6, "Script Detail") + draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34}) + page_count := len(controller.state.script.pages) + if page_count == 0 { + draw_summary_line(x+18, y+46, "No script pages yet. Run Generate Script Local.", SUMMARY_HINT) + return + } + idx := clamp_script_cursor(page_count, cursor) + page := controller.state.script.pages[idx] + draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d (#%d) • panels %d", idx+1, page_count, page.page_number, len(page.panels)), SUMMARY_ACCENT) + line_y := y + 70 + line_step: i32 = 20 + line_max: i32 = (h - 84) / line_step + lines_used: i32 = 0 + for pn in page.panels { + if lines_used >= line_max { + break + } + desc := pn.description + if len(desc) == 0 { + desc = "(no description)" + } + draw_summary_subline(x+18, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-40), 7), SUMMARY_SUBLINE) + lines_used += 1 + if len(pn.dialogue) > 0 && lines_used < line_max { + draw_summary_subline(x+30, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-52), 7), SUMMARY_DIM) + lines_used += 1 + } + } + if len(page.panels) > 0 && lines_used >= line_max { + draw_summary_subline(x+w-140, y+h-18, "…more", SUMMARY_ACCENT) + } +} + + +draw_panels_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) -> (retry_clicked: bool, new_cursor: int) { + new_cursor = cursor + retry_clicked = false + + draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)}) + draw_section_title(x+18, y+6, "Panel Results") + draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34}) + panel_count := count_script_panels(controller.state.script) + if panel_count == 0 { + draw_summary_line(x+18, y+46, "No script panels yet. Generate Script first.", SUMMARY_HINT) + return + } + idx := clamp_panel_cursor(panel_count, cursor) + panel, page_num, ok := panel_by_flat_index(controller.state.script, idx) + if !ok { + draw_summary_line(x+18, y+46, "Panel index out of range.", ERROR) + return + } + img, has_img := controller.state.panel_images[panel.panel_id] + err_msg, has_err := controller.state.panel_errors[panel.panel_id] + status := "missing" + status_color := WARNING + if has_err { + status = "error" + status_color = ERROR + } + if has_img { + status = "ready" + status_color = SUCCESS + } + draw_summary_line(x+18, y+46, fmt.tprintf("Panel %d/%d • page %d # %d • %s", idx+1, panel_count, page_num, panel.panel_number, status), status_color) + + btn_label := "Regenerate" + if status != "ready" { + btn_label = "Generate" + } + btn_rec := rl.Rectangle{x = f32(x + w - 100), y = f32(y+42), width = 80, height = 24} + draw_small_button_state(btn_rec, btn_label, true) + if button_clicked(btn_rec) { + retry_clicked = true + } + + draw_summary_subline(x+18, y+66, fit_text_for_width(fmt.tprintf("id: %s", panel.panel_id), int(w-120), 7), SUMMARY_SUBLINE) + if has_err { + draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("err: %s", err_msg), int(w-36), 7), ERROR) + } else if has_img { + draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("img: %dx%d seed:%d", img.width, img.height, img.seed), int(w-36), 7), SUMMARY_DIM) + } else { + draw_summary_subline(x+18, y+84, "img: not generated", SUMMARY_DIM) + } + desc := panel.description + if len(desc) == 0 { + desc = "(no description)" + } + draw_summary_subline(x+18, y+104, fit_text_for_width(fmt.tprintf("desc: %s", desc), int(w-36), 7), SUMMARY_SUBLINE) + if has_img { + draw_summary_subline(x+18, y+124, fit_text_for_width(fmt.tprintf("src: %s", img.url), int(w-36), 7), SUMMARY_DIM) + } + + list_y := y + 146 + row_h: i32 = 18 + rows: i32 = (h - 154) / row_h + if rows < 1 { + rows = 1 + } + start := idx - int(rows/2) + if start < 0 { + start = 0 + } + end := start + int(rows) + if end > panel_count { + end = panel_count + start = end - int(rows) + if start < 0 { + start = 0 + } + } + line: i32 = 0 + for i in start.. string { + if px_per_char <= 0 { + return text + } + max_chars := width_px / px_per_char + if max_chars < 4 { + max_chars = 4 + } + if len(text) <= max_chars { + return text + } + return fmt.tprintf("%s…", text[:max_chars-1]) +} + +draw_text_fitted :: proc(text: string, x, y, font_size: i32, width_px, px_per_char: int, color: rl.Color) { + display := fit_text_for_width(text, width_px, px_per_char) + rl.DrawText(fmt.ctprintf("%s", display), x, y, font_size, color) +} diff --git a/odin/src/gui/theme.odin b/odin/src/gui/theme.odin new file mode 100644 index 0000000..7ccb0f9 --- /dev/null +++ b/odin/src/gui/theme.odin @@ -0,0 +1,210 @@ +package gui + +import rl "vendor:raylib" + +// ── Backgrounds ────────────────────────────────────────────────────────── +BG_BASE :: rl.Color{13, 13, 18, 255} +BG_SIDEBAR :: rl.Color{18, 18, 24, 255} +BG_TOPBAR :: rl.Color{18, 18, 24, 255} +BG_CARD :: rl.Color{24, 24, 32, 255} +BG_CARD_ALT :: rl.Color{28, 28, 38, 255} // slightly elevated card +BG_STRIP :: rl.Color{22, 22, 30, 255} // subtle strip background +BG_OVERLAY :: rl.Color{8, 8, 12, 180} // modal backdrop + +// ── Borders ────────────────────────────────────────────────────────────── +BORDER_CARD :: rl.Color{40, 40, 52, 255} +BORDER_SUBTLE :: rl.Color{36, 36, 48, 255} +BORDER_DIVIDER :: rl.Color{36, 36, 48, 255} + +// ── Accent (Indigo-Violet) ─────────────────────────────────────────────── +ACCENT :: rl.Color{99, 102, 241, 255} +ACCENT_HOVER :: rl.Color{120, 122, 248, 255} +ACCENT_MUTED :: rl.Color{68, 70, 180, 255} +ACCENT_SURFACE :: rl.Color{30, 30, 56, 255} +ACCENT_GLOW :: rl.Color{99, 102, 241, 80} + +// ── Text ───────────────────────────────────────────────────────────────── +TEXT_PRIMARY :: rl.Color{228, 228, 240, 255} +TEXT_SECONDARY :: rl.Color{148, 148, 168, 255} +TEXT_TERTIARY :: rl.Color{98, 98, 118, 255} +TEXT_DISABLED :: rl.Color{68, 68, 88, 255} +TEXT_BRIGHT :: rl.Color{245, 245, 255, 255} + +// ── Semantic: Success ──────────────────────────────────────────────────── +SUCCESS :: rl.Color{52, 211, 153, 255} +SUCCESS_BG :: rl.Color{16, 42, 32, 255} +SUCCESS_BORDER :: rl.Color{40, 100, 74, 255} +SUCCESS_TEXT :: rl.Color{110, 231, 183, 255} + +// ── Semantic: Warning ──────────────────────────────────────────────────── +WARNING :: rl.Color{251, 191, 36, 255} +WARNING_BG :: rl.Color{50, 38, 14, 255} +WARNING_BORDER :: rl.Color{120, 90, 30, 255} +WARNING_TEXT :: rl.Color{253, 224, 120, 255} + +// ── Semantic: Error ────────────────────────────────────────────────────── +ERROR :: rl.Color{248, 113, 113, 255} +ERROR_BG :: rl.Color{50, 18, 18, 255} +ERROR_BORDER :: rl.Color{120, 50, 50, 255} +ERROR_TEXT :: rl.Color{254, 178, 178, 255} + +// ── Semantic: Danger (destructive buttons) ─────────────────────────────── +DANGER_BG :: rl.Color{153, 50, 58, 255} +DANGER_BG_HOVER :: rl.Color{172, 60, 68, 255} +DANGER_BORDER :: rl.Color{130, 42, 48, 255} + +// ── Semantic: Warning-style buttons ────────────────────────────────────── +WARN_BTN_BG :: rl.Color{80, 64, 30, 255} +WARN_BTN_BG_HOVER :: rl.Color{95, 76, 36, 255} +WARN_BTN_BORDER :: rl.Color{110, 88, 40, 255} +WARN_BTN_TEXT :: rl.Color{253, 224, 120, 255} + +// ── Buttons ────────────────────────────────────────────────────────────── +BTN_BG :: rl.Color{32, 32, 44, 255} +BTN_BG_HOVER :: rl.Color{40, 40, 54, 255} +BTN_BORDER :: rl.Color{52, 52, 68, 255} +BTN_BORDER_HOVER :: rl.Color{80, 82, 140, 255} +BTN_TEXT :: TEXT_PRIMARY + +BTN_SOFT_BG :: rl.Color{28, 30, 48, 255} +BTN_SOFT_BG_HOVER :: rl.Color{36, 38, 58, 255} +BTN_SOFT_BORDER :: rl.Color{60, 62, 100, 255} +BTN_SOFT_TEXT :: rl.Color{160, 162, 220, 255} + +BTN_DISABLED_BG :: rl.Color{24, 24, 32, 255} +BTN_DISABLED_BORDER :: rl.Color{36, 36, 48, 255} +BTN_DISABLED_TEXT :: rl.Color{68, 68, 88, 255} + +// ── Small Buttons ──────────────────────────────────────────────────────── +SBTN_BG :: rl.Color{30, 30, 42, 255} +SBTN_BG_HOVER :: rl.Color{40, 40, 54, 255} +SBTN_BORDER :: rl.Color{50, 50, 66, 255} +SBTN_BORDER_HOVER :: rl.Color{80, 82, 140, 255} +SBTN_TEXT :: rl.Color{188, 188, 210, 255} + +// ── Navigation ─────────────────────────────────────────────────────────── +NAV_BG :: rl.Color{24, 24, 32, 255} +NAV_BG_HOVER :: rl.Color{32, 32, 44, 255} +NAV_BORDER :: rl.Color{40, 40, 52, 255} +NAV_BORDER_HOVER :: rl.Color{70, 72, 120, 255} +NAV_TEXT :: rl.Color{168, 168, 188, 255} +NAV_ACTIVE_BG :: ACCENT +NAV_ACTIVE_TEXT :: TEXT_BRIGHT +NAV_ACTIVE_BAR :: rl.Color{180, 182, 255, 255} + +// ── Input Fields ───────────────────────────────────────────────────────── +INPUT_BG :: rl.Color{16, 16, 24, 255} +INPUT_BORDER :: rl.Color{40, 40, 54, 255} +INPUT_FOCUS_BG :: rl.Color{20, 20, 30, 255} +INPUT_FOCUS_BORDER :: ACCENT +INPUT_FOCUS_RING :: ACCENT_GLOW +INPUT_TEXT :: TEXT_PRIMARY +INPUT_TEXT_FOCUS :: TEXT_BRIGHT + +// ── Chips & Pills ──────────────────────────────────────────────────────── +PILL_BG :: rl.Color{28, 28, 38, 255} +PILL_BORDER :: rl.Color{44, 44, 58, 255} +PILL_TEXT :: TEXT_SECONDARY + +PILL_ACCENT_BG :: ACCENT_SURFACE +PILL_ACCENT_BORDER :: ACCENT_MUTED +PILL_ACCENT_TEXT :: rl.Color{180, 182, 255, 255} + +CHIP_BG :: rl.Color{28, 28, 38, 255} +CHIP_BORDER :: rl.Color{44, 44, 58, 255} +CHIP_TEXT :: TEXT_SECONDARY + +CHIP_ACCENT_BG :: ACCENT_SURFACE +CHIP_ACCENT_BORDER :: ACCENT_MUTED +CHIP_ACCENT_TEXT :: PILL_ACCENT_TEXT + +// ── Status Badges ──────────────────────────────────────────────────────── +BADGE_OK_BG :: SUCCESS_BG +BADGE_OK_BORDER :: SUCCESS_BORDER +BADGE_OK_TEXT :: SUCCESS_TEXT +BADGE_BAD_BG :: ERROR_BG +BADGE_BAD_BORDER :: ERROR_BORDER +BADGE_BAD_TEXT :: ERROR_TEXT + +// ── Readiness Chips ────────────────────────────────────────────────────── +READY_BG :: SUCCESS_BG +READY_BORDER :: SUCCESS_BORDER +READY_TEXT :: SUCCESS_TEXT +UNREADY_BG :: rl.Color{28, 28, 38, 255} +UNREADY_BORDER :: rl.Color{44, 44, 58, 255} +UNREADY_TEXT :: TEXT_TERTIARY + +// ── Progress Bar ───────────────────────────────────────────────────────── +PROGRESS_TRACK :: rl.Color{28, 28, 40, 255} +PROGRESS_FILL :: ACCENT + +// ── Toast ──────────────────────────────────────────────────────────────── +TOAST_SUCCESS :: rl.Color{28, 120, 80, 235} +TOAST_WARNING :: rl.Color{140, 100, 30, 235} +TOAST_ERROR :: rl.Color{150, 50, 50, 235} +TOAST_BORDER :: rl.Color{255, 255, 255, 40} +TOAST_SHADOW :: rl.Color{0, 0, 0, 60} + +// ── Action Log ─────────────────────────────────────────────────────────── +LOG_ROW_ALT :: rl.Color{22, 22, 30, 255} +LOG_TEXT :: rl.Color{158, 158, 178, 255} + +// ── Section Titles ─────────────────────────────────────────────────────── +SECTION_TITLE_COLOR :: rl.Color{148, 150, 210, 255} +SECTION_UNDERLINE :: rl.Color{44, 44, 60, 255} + +// ── Screen Summary ─────────────────────────────────────────────────────── +SUMMARY_TITLE :: rl.Color{170, 172, 230, 255} +SUMMARY_ACCENT :: rl.Color{99, 140, 220, 255} +SUMMARY_HINT :: rl.Color{120, 90, 200, 255} +SUMMARY_SUBLINE :: rl.Color{128, 128, 148, 255} +SUMMARY_DIM :: rl.Color{98, 98, 118, 255} + +// ── Pipeline Stepper ───────────────────────────────────────────────────── +STEP_DONE_FILL :: SUCCESS +STEP_DONE_BORDER :: SUCCESS_BORDER +STEP_TODO_FILL :: rl.Color{36, 36, 48, 255} +STEP_TODO_BORDER :: rl.Color{52, 52, 68, 255} +STEP_LINE_DONE :: rl.Color{40, 100, 74, 255} +STEP_LINE_TODO :: rl.Color{40, 40, 52, 255} +STEP_LABEL_DONE :: SUCCESS_TEXT +STEP_LABEL_TODO :: TEXT_TERTIARY + +// ── Help Overlay ───────────────────────────────────────────────────────── +HELP_TITLE :: TEXT_BRIGHT +HELP_SECTION :: rl.Color{130, 132, 210, 255} +HELP_LINE :: TEXT_SECONDARY +HELP_CLOSE :: rl.Color{170, 148, 240, 255} + +// ── Confirm Overlay ────────────────────────────────────────────────────── +CONFIRM_ACCENT :: ACCENT +CONFIRM_TITLE :: TEXT_BRIGHT +CONFIRM_BODY :: TEXT_SECONDARY +CONFIRM_HINT :: rl.Color{170, 148, 240, 255} + +// ── Sidebar Shortcuts ──────────────────────────────────────────────────── +SIDEBAR_TITLE :: rl.Color{130, 132, 200, 255} +SIDEBAR_TEXT :: TEXT_TERTIARY +SIDEBAR_FOOTER :: rl.Color{80, 80, 100, 255} + +// ── Brand ──────────────────────────────────────────────────────────────── +BRAND_TITLE :: TEXT_BRIGHT +BRAND_SUBTITLE :: TEXT_TERTIARY + +// ── Roundness Constants ────────────────────────────────────────────────── +RADIUS_CARD :: f32(0.14) +RADIUS_BUTTON :: f32(0.32) +RADIUS_PILL :: f32(0.50) +RADIUS_INPUT :: f32(0.24) +RADIUS_NAV :: f32(0.28) +RADIUS_CHIP :: f32(0.42) +RADIUS_BADGE :: f32(0.42) +RADIUS_TOAST :: f32(0.40) +RADIUS_BAR :: f32(0.60) + +// ── Recommended Halo ───────────────────────────────────────────────────── +RECOMMEND_HALO_FILL :: rl.Color{50, 50, 100, 255} +RECOMMEND_HALO_BORDER :: rl.Color{120, 122, 248, 255} + +// ── DeepSeek key missing ───────────────────────────────────────────────── +KEY_MISSING_COLOR :: ERROR diff --git a/odin/src/gui/types.odin b/odin/src/gui/types.odin new file mode 100644 index 0000000..5d64405 --- /dev/null +++ b/odin/src/gui/types.odin @@ -0,0 +1,17 @@ +package gui + +Summary_View_Options :: struct { + script_show_all: bool, + script_desc: bool, + script_page_cursor: int, + panel_cursor: int, + layout_show_all: bool, + layout_desc: bool, + layout_page_cursor: int, +} + +Pending_Confirm_Action :: enum { + None, + Reset_Project, + Open_Project, +} diff --git a/odin/src/gui/widgets.odin b/odin/src/gui/widgets.odin new file mode 100644 index 0000000..c2eb67c --- /dev/null +++ b/odin/src/gui/widgets.odin @@ -0,0 +1,76 @@ +package gui + +import rl "vendor:raylib" + +draw_card :: proc(rec: rl.Rectangle) { + rl.DrawRectangleRounded(rec, RADIUS_CARD, 8, BG_CARD) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CARD, 8, 1.0, BORDER_CARD) +} + +draw_subtle_strip :: proc(rec: rl.Rectangle) { + rl.DrawRectangleRounded(rec, 0.20, 8, BG_STRIP) + rl.DrawRectangleRoundedLinesEx(rec, 0.20, 8, 1.0, BORDER_SUBTLE) +} + +draw_hint_pill :: proc(rec: rl.Rectangle, label: string, accent: bool) { + bg := PILL_BG + border := PILL_BORDER + fg := PILL_TEXT + if accent { + bg = PILL_ACCENT_BG + border = PILL_ACCENT_BORDER + fg = PILL_ACCENT_TEXT + } + rl.DrawRectangleRounded(rec, RADIUS_PILL, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_PILL, 8, 1.0, border) + draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 13, int(rec.width)-16, 7, fg) +} + +draw_topbar_chip :: proc(rec: rl.Rectangle, label: string, accent: bool) { + bg := CHIP_BG + border := CHIP_BORDER + fg := CHIP_TEXT + if accent { + bg = CHIP_ACCENT_BG + border = CHIP_ACCENT_BORDER + fg = CHIP_ACCENT_TEXT + } + rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, border) + draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-16, 8, fg) +} + +draw_status_badge :: proc(rec: rl.Rectangle, label: string, ok: bool) { + bg := BADGE_BAD_BG + border := BADGE_BAD_BORDER + fg := BADGE_BAD_TEXT + if ok { + bg = BADGE_OK_BG + border = BADGE_OK_BORDER + fg = BADGE_OK_TEXT + } + rl.DrawRectangleRounded(rec, RADIUS_BADGE, 8, bg) + rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BADGE, 8, 1.0, border) + draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 13, int(rec.width)-16, 7, fg) +} + +draw_section_title :: proc(x, y: i32, label: string) { + draw_text_fitted(label, x, y, 17, 180, 8, SECTION_TITLE_COLOR) + rl.DrawLine(x, y+20, x+180, y+20, SECTION_UNDERLINE) +} + +draw_summary_line :: proc(x, y: i32, text: string, c: rl.Color) { + fg := c + if int(c.r)+int(c.g)+int(c.b) < 260 { + fg = TEXT_PRIMARY + } + draw_text_fitted(text, x, y, 18, 438, 8, fg) +} + +draw_summary_subline :: proc(x, y: i32, text: string, c: rl.Color) { + fg := c + if int(c.r)+int(c.g)+int(c.b) < 260 { + fg = TEXT_SECONDARY + } + draw_text_fitted(text, x, y, 16, 438, 7, fg) +} diff --git a/odin/src/shared/config.odin b/odin/src/shared/config.odin new file mode 100644 index 0000000..67f6e67 --- /dev/null +++ b/odin/src/shared/config.odin @@ -0,0 +1,29 @@ +package shared + +import "core:os" + +Config :: struct { + deepseek_api_key: string, + deepseek_base_url: string, + fal_api_key: string, + project_root: string, +} + +load_config :: proc() -> Config { + cfg := Config{ + deepseek_api_key = os.get_env("DEEPSEEK_API_KEY", context.temp_allocator), + deepseek_base_url = os.get_env("DEEPSEEK_BASE_URL", context.temp_allocator), + fal_api_key = os.get_env("FAL_API_KEY", context.temp_allocator), + project_root = ".", + } + + if len(cfg.deepseek_base_url) == 0 { + cfg.deepseek_base_url = "https://api.deepseek.com" + } + + return cfg +} + +load_config_stub :: proc() -> Config { + return load_config() +} diff --git a/odin/src/shared/errors.odin b/odin/src/shared/errors.odin new file mode 100644 index 0000000..059f9ee --- /dev/null +++ b/odin/src/shared/errors.odin @@ -0,0 +1,60 @@ +package shared + +App_Error_Code :: enum { + None, + Config, + Network, + Rate_Limit, + Validation, + Generation, + Export, + Storage, +} + +App_Error :: struct { + code: App_Error_Code, + message: string, + recoverable: bool, +} + +ok :: proc() -> App_Error { + return App_Error{code = .None, message = "", recoverable = false} +} + +is_ok :: proc(err: App_Error) -> bool { + return err.code == .None +} + +new_error :: proc(code: App_Error_Code, message: string, recoverable: bool) -> App_Error { + return App_Error{code = code, message = message, recoverable = recoverable} +} + +config_error :: proc(message: string) -> App_Error { + return new_error(.Config, message, false) +} + +network_error :: proc(message: string) -> App_Error { + return new_error(.Network, message, true) +} + +rate_limit_error :: proc(message: string) -> App_Error { + return new_error(.Rate_Limit, message, true) +} + +validation_error :: proc(message: string) -> App_Error { + return new_error(.Validation, message, false) +} + +generation_error :: proc(message: string) -> App_Error { + return new_error(.Generation, message, true) +} + +should_retry :: proc(err: App_Error) -> bool { + if err.code == .None { + return false + } + if err.code == .Rate_Limit || err.code == .Network { + return true + } + return err.recoverable +} diff --git a/odin/src/ui/controller.odin b/odin/src/ui/controller.odin new file mode 100644 index 0000000..dc2f9f0 --- /dev/null +++ b/odin/src/ui/controller.odin @@ -0,0 +1,76 @@ +package ui + +import "../core" +import "../shared" + +App_Controller :: struct { + state: core.Comic_State, + active_screen: App_Screen, + jobs: Job_Manager, +} + +new_controller :: proc(state: core.Comic_State) -> App_Controller { + screen := screen_from_workflow(state.workflow.current_step) + return App_Controller{ + state = state, + active_screen = screen, + jobs = new_job_manager(), + } +} + +navigate_to_screen :: proc(c: ^App_Controller, target: App_Screen) -> shared.App_Error { + if !can_open_screen(c.state, target) { + return shared.new_error(.Validation, "screen blocked by workflow guards", false) + } + c.active_screen = target + return shared.ok() +} + +set_workflow_step :: proc(c: ^App_Controller, next: core.Workflow_Step) -> shared.App_Error { + curr := c.state.workflow.current_step + if !core.can_transition(curr, next) && curr != next { + return shared.new_error(.Validation, "invalid workflow transition", false) + } + core.set_workflow_step(&c.state, next) + c.active_screen = screen_from_workflow(next) + return shared.ok() +} + +start_background_job :: proc(c: ^App_Controller, t: Job_Type, message: string) -> int { + id := submit_job(&c.jobs, t, message) + c.state.workflow.is_generating = true + c.state.workflow.generation_progress = 0 + c.state.workflow.error_message = "" + return id +} + +set_generation_progress :: proc(c: ^App_Controller, progress: f32) { + c.state.workflow.generation_progress = progress +} + +finish_background_job :: proc(c: ^App_Controller, id: int, failed_message: string) -> shared.App_Error { + if len(failed_message) > 0 { + _ = mark_job_failed(&c.jobs, id, failed_message) + c.state.workflow.error_message = failed_message + c.state.workflow.is_generating = false + return shared.new_error(.Generation, failed_message, true) + } + + _ = mark_job_completed(&c.jobs, id) + if active_jobs_count(c.jobs) == 0 { + c.state.workflow.is_generating = false + c.state.workflow.generation_progress = 100 + } + return shared.ok() +} + +cancel_background_job :: proc(c: ^App_Controller, id: int) -> shared.App_Error { + err := request_job_cancel(&c.jobs, id) + if !shared.is_ok(err) { + return err + } + if active_jobs_count(c.jobs) == 0 { + c.state.workflow.is_generating = false + } + return shared.ok() +} diff --git a/odin/src/ui/dispose.odin b/odin/src/ui/dispose.odin new file mode 100644 index 0000000..0a759b0 --- /dev/null +++ b/odin/src/ui/dispose.odin @@ -0,0 +1,19 @@ +package ui + +import "../core" + +dispose_job_manager :: proc(m: ^Job_Manager) { + delete(m.jobs) + m.jobs = nil + m.next_id = 1 +} + +dispose_controller :: proc(c: ^App_Controller) { + dispose_job_manager(&c.jobs) + core.dispose_state(&c.state) +} + +dispose_controller_owned :: proc(c: ^App_Controller) { + dispose_job_manager(&c.jobs) + core.dispose_state_owned(&c.state) +} diff --git a/odin/src/ui/jobs.odin b/odin/src/ui/jobs.odin new file mode 100644 index 0000000..8a491a3 --- /dev/null +++ b/odin/src/ui/jobs.odin @@ -0,0 +1,105 @@ +package ui + +import "../shared" + +Job_Type :: enum { + Generate_Script, + Generate_Character, + Generate_Panel, + Export, +} + +Job_Status :: enum { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +Background_Job :: struct { + id: int, + type: Job_Type, + status: Job_Status, + message: string, + cancel_requested: bool, +} + +Job_Manager :: struct { + next_id: int, + jobs: [dynamic]Background_Job, +} + +new_job_manager :: proc() -> Job_Manager { + return Job_Manager{next_id = 1} +} + +submit_job :: proc(m: ^Job_Manager, t: Job_Type, message: string) -> int { + id := m.next_id + m.next_id += 1 + append(&m.jobs, Background_Job{id = id, type = t, status = .Queued, message = message}) + return id +} + +job_index_by_id :: proc(m: ^Job_Manager, id: int) -> int { + for j, i in m.jobs { + if j.id == id { + return i + } + } + return -1 +} + +mark_job_running :: proc(m: ^Job_Manager, id: int) -> shared.App_Error { + idx := job_index_by_id(m, id) + if idx < 0 { + return shared.new_error(.Generation, "job not found", true) + } + m.jobs[idx].status = .Running + return shared.ok() +} + +mark_job_completed :: proc(m: ^Job_Manager, id: int) -> shared.App_Error { + idx := job_index_by_id(m, id) + if idx < 0 { + return shared.new_error(.Generation, "job not found", true) + } + if m.jobs[idx].cancel_requested { + m.jobs[idx].status = .Cancelled + } else { + m.jobs[idx].status = .Completed + } + return shared.ok() +} + +mark_job_failed :: proc(m: ^Job_Manager, id: int, message: string) -> shared.App_Error { + idx := job_index_by_id(m, id) + if idx < 0 { + return shared.new_error(.Generation, "job not found", true) + } + m.jobs[idx].status = .Failed + m.jobs[idx].message = message + return shared.ok() +} + +request_job_cancel :: proc(m: ^Job_Manager, id: int) -> shared.App_Error { + idx := job_index_by_id(m, id) + if idx < 0 { + return shared.new_error(.Generation, "job not found", true) + } + m.jobs[idx].cancel_requested = true + if m.jobs[idx].status != .Completed && m.jobs[idx].status != .Failed { + m.jobs[idx].status = .Cancelled + } + return shared.ok() +} + +active_jobs_count :: proc(m: Job_Manager) -> int { + count := 0 + for j in m.jobs { + if j.status == .Queued || j.status == .Running { + count += 1 + } + } + return count +} diff --git a/odin/src/ui/navigation.odin b/odin/src/ui/navigation.odin new file mode 100644 index 0000000..f5164a7 --- /dev/null +++ b/odin/src/ui/navigation.odin @@ -0,0 +1,39 @@ +package ui + +import "../core" + +can_open_screen :: proc(state: core.Comic_State, target: App_Screen) -> bool { + switch target { + case .Story: + return true + case .Script: + return len(state.script.pages) > 0 + case .Characters: + return len(state.script.characters) > 0 + case .Panels: + return len(state.script.pages) > 0 && len(state.characters) > 0 + case .Layout: + return len(state.panel_images) > 0 + case .Bubbles: + return len(state.page_layouts) > 0 + case .Export: + return len(state.page_layouts) > 0 + case .Community: + return true + } + return false +} + +next_step_for_screen :: proc(screen: App_Screen) -> core.Workflow_Step { + switch screen { + case .Story: return .Story_Input + case .Script: return .Script_Review + case .Characters: return .Character_Setup + case .Panels: return .Generating_Panels + case .Layout: return .Layout + case .Bubbles: return .Speech_Bubbles + case .Export: return .Complete + case .Community: return .Complete + } + return .Story_Input +} diff --git a/odin/src/ui/runtime.odin b/odin/src/ui/runtime.odin new file mode 100644 index 0000000..4ffe4ac --- /dev/null +++ b/odin/src/ui/runtime.odin @@ -0,0 +1,60 @@ +package ui + +import "../core" +import "../shared" + +UI_Command_Kind :: enum { + Navigate, + Set_Workflow, + Start_Generate, + Set_Progress, + Complete_Job, + Fail_Job, + Cancel_Job, +} + +UI_Command :: struct { + kind: UI_Command_Kind, + screen: App_Screen, + workflow_step: core.Workflow_Step, + job_type: Job_Type, + job_id: int, + progress: f32, + message: string, +} + +UI_Runtime_Result :: struct { + job_id: int, + err: shared.App_Error, +} + +apply_command :: proc(c: ^App_Controller, cmd: UI_Command) -> UI_Runtime_Result { + res := UI_Runtime_Result{job_id = 0, err = shared.ok()} + switch cmd.kind { + case .Navigate: + res.err = navigate_to_screen(c, cmd.screen) + case .Set_Workflow: + res.err = set_workflow_step(c, cmd.workflow_step) + case .Start_Generate: + res.job_id = start_background_job(c, cmd.job_type, cmd.message) + case .Set_Progress: + set_generation_progress(c, cmd.progress) + case .Complete_Job: + res.err = finish_background_job(c, cmd.job_id, "") + case .Fail_Job: + res.err = finish_background_job(c, cmd.job_id, cmd.message) + case .Cancel_Job: + res.err = cancel_background_job(c, cmd.job_id) + } + return res +} + +apply_commands :: proc(c: ^App_Controller, cmds: []UI_Command) -> shared.App_Error { + for cmd in cmds { + res := apply_command(c, cmd) + if !shared.is_ok(res.err) { + return res.err + } + } + return shared.ok() +} diff --git a/odin/src/ui/screens.odin b/odin/src/ui/screens.odin new file mode 100644 index 0000000..30d6855 --- /dev/null +++ b/odin/src/ui/screens.odin @@ -0,0 +1,48 @@ +package ui + +import "../core" + +App_Screen :: enum { + Story, + Script, + Characters, + Panels, + Layout, + Bubbles, + Export, + Community, +} + +screen_from_workflow :: proc(step: core.Workflow_Step) -> App_Screen { + switch step { + case .Story_Input, .Generating_Script: + return .Story + case .Script_Review: + return .Script + case .Character_Setup: + return .Characters + case .Generating_Panels: + return .Panels + case .Layout: + return .Layout + case .Speech_Bubbles: + return .Bubbles + case .Complete: + return .Export + } + return .Story +} + +screen_name :: proc(s: App_Screen) -> string { + switch s { + case .Story: return "Story" + case .Script: return "Script" + case .Characters: return "Characters" + case .Panels: return "Panels" + case .Layout: return "Layout" + case .Bubbles: return "Speech" + case .Export: return "Export" + case .Community: return "Community" + } + return "Unknown" +} diff --git a/odin/src/ui/views.odin b/odin/src/ui/views.odin new file mode 100644 index 0000000..384d5cb --- /dev/null +++ b/odin/src/ui/views.odin @@ -0,0 +1,91 @@ +package ui + +import "core:fmt" +import "core:strings" +import "../core" + +render_header :: proc(c: App_Controller) -> string { + mode := "Casual" + if c.state.user_mode == .Professional { + mode = "Professional" + } + return fmt.aprintf("[comic-odin] screen=%s step=%v mode=%s", screen_name(c.active_screen), c.state.workflow.current_step, mode) +} + +render_progress :: proc(state: core.Comic_State) -> string { + if !state.workflow.is_generating { + return "idle" + } + return fmt.aprintf("generating %.0f%%", state.workflow.generation_progress) +} + +render_story_view :: proc(state: core.Comic_State) -> string { + idea := state.story_idea + if len(idea) == 0 { + idea = "(no story idea yet)" + } + return fmt.aprintf("Story\n- idea: %s\n- genre: %s\n- audience: %s", idea, state.story_genre, state.target_audience) +} + +render_script_view :: proc(state: core.Comic_State) -> string { + return fmt.aprintf("Script\n- title: %s\n- pages: %d\n- characters: %d", state.script.title, len(state.script.pages), len(state.script.characters)) +} + +render_characters_view :: proc(state: core.Comic_State) -> string { + return fmt.aprintf("Characters\n- count: %d", len(state.characters)) +} + +render_panels_view :: proc(state: core.Comic_State) -> string { + return fmt.aprintf("Panels\n- generated images: %d", len(state.panel_images)) +} + +render_layout_view :: proc(state: core.Comic_State) -> string { + return fmt.aprintf("Layout\n- pages: %d", len(state.page_layouts)) +} + +render_bubbles_view :: proc(state: core.Comic_State) -> string { + return fmt.aprintf("Speech\n- panels with bubbles: %d", len(state.speech_bubbles)) +} + +render_export_view :: proc(state: core.Comic_State) -> string { + return fmt.aprintf("Export\n- page layouts: %d\n- format: %v", len(state.page_layouts), state.export_format) +} + +render_screen_body :: proc(c: App_Controller) -> string { + switch c.active_screen { + case .Story: + return render_story_view(c.state) + case .Script: + return render_script_view(c.state) + case .Characters: + return render_characters_view(c.state) + case .Panels: + return render_panels_view(c.state) + case .Layout: + return render_layout_view(c.state) + case .Bubbles: + return render_bubbles_view(c.state) + case .Export: + return render_export_view(c.state) + case .Community: + return "Community\n- coming soon" + } + return "Unknown screen" +} + +render_status_bar :: proc(c: App_Controller) -> string { + status := render_progress(c.state) + if len(c.state.workflow.error_message) > 0 { + status = fmt.aprintf("error: %s", c.state.workflow.error_message) + } + return fmt.aprintf("jobs=%d | %s", active_jobs_count(c.jobs), status) +} + +render_app_to_string :: proc(c: App_Controller) -> string { + parts := [3]string{render_header(c), render_screen_body(c), render_status_bar(c)} + out := strings.join(parts[:], "\n\n", context.allocator) + for p in parts { + delete(p) + } + return out +} diff --git a/odin/tests/adapters_phase2.odin b/odin/tests/adapters_phase2.odin new file mode 100644 index 0000000..3372fab --- /dev/null +++ b/odin/tests/adapters_phase2.odin @@ -0,0 +1,103 @@ +package tests + +import "core:testing" +import "../src/adapters" +import "../src/core" +import "../src/shared" + +deepseek_calls: int + +phase2_deepseek_transport :: proc(cfg: shared.Config, request_json: string) -> (string, int, shared.App_Error) { + _ = cfg + _ = request_json + deepseek_calls += 1 + if deepseek_calls == 1 { + return "", 429, shared.ok() + } + return "{\"choices\":[{\"message\":{\"content\":\"{\\\"title\\\":\\\"Test\\\",\\\"synopsis\\\":\\\"A hero rises\\\",\\\"characters\\\":[{\\\"id\\\":\\\"char_001\\\",\\\"name\\\":\\\"Hero\\\",\\\"role\\\":\\\"protagonist\\\",\\\"description\\\":\\\"Determined\\\",\\\"firstAppearancePanel\\\":\\\"panel_001_001\\\"}],\\\"pages\\\":[{\\\"pageNumber\\\":1,\\\"layoutType\\\":\\\"grid\\\",\\\"panels\\\":[{\\\"panelId\\\":\\\"panel_001_001\\\",\\\"panelNumber\\\":1,\\\"shotType\\\":\\\"medium\\\",\\\"description\\\":\\\"Hero stands\\\",\\\"charactersPresent\\\":[\\\"char_001\\\"],\\\"dialogue\\\":[{\\\"speakerId\\\":\\\"char_001\\\",\\\"text\\\":\\\"Let's go!\\\",\\\"bubbleType\\\":\\\"normal\\\",\\\"emotion\\\":\\\"determined\\\"}],\\\"caption\\\":\\\"\\\",\\\"soundEffects\\\":[],\\\"transitionFromPrevious\\\":\\\"none\\\"}]}]}\"}}]}", 200, shared.ok() +} + +fal_calls: int + +phase2_fal_transport :: proc(cfg: shared.Config, endpoint, prompt: string, seed: i64) -> (string, int, shared.App_Error) { + _ = cfg + _ = endpoint + _ = prompt + _ = seed + fal_calls += 1 + if fal_calls == 1 { + return "", 0, shared.network_error("temporary network issue") + } + return "https://example.com/image.png", 200, shared.ok() +} + +@test +deepseek_retries_then_succeeds :: proc(t: ^testing.T) { + deepseek_calls = 0 + + cfg := shared.Config{ + deepseek_api_key = "test-key", + deepseek_base_url = "https://api.deepseek.com", + } + client := adapters.new_deepseek_client() + client.transport = phase2_deepseek_transport + client.max_retries = 3 + + opts := adapters.Generate_Script_Options{ + story_idea = "A hero rises", + genre = "action", + art_style = "manga", + num_pages = 4, + audience = "general", + } + + script, err := adapters.generate_comic_script(client, cfg, opts) + defer core.dispose_script_owned(&script) + testing.expect(t, shared.is_ok(err), "deepseek request should eventually succeed") + testing.expect(t, deepseek_calls == 2, "expected one retry before success") + testing.expect(t, len(script.pages) > 0, "generated script should contain pages") +} + +@test +fal_queue_enforces_cap :: proc(t: ^testing.T) { + q := adapters.new_fal_queue(1) + + first := adapters.try_acquire_slot(&q) + second := adapters.try_acquire_slot(&q) + adapters.release_slot(&q) + third := adapters.try_acquire_slot(&q) + + testing.expect(t, first, "first slot acquisition should succeed") + testing.expect(t, !second, "second slot acquisition should fail when saturated") + testing.expect(t, third, "slot acquisition should succeed after release") +} + +@test +fal_panel_generation_retries_network_error :: proc(t: ^testing.T) { + fal_calls = 0 + + cfg := shared.Config{fal_api_key = "test-key"} + q := adapters.new_fal_queue(2) + client := adapters.new_fal_client(&q) + client.transport = phase2_fal_transport + 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") + defer delete(img.prompt) + + testing.expect(t, shared.is_ok(err), "fal panel generation should eventually succeed") + testing.expect(t, fal_calls == 2, "expected one retry for network error") + testing.expect(t, len(img.url) > 0, "image url should be set") +} + +@test +fal_typed_response_parsing :: proc(t: ^testing.T) { + body := "{\"images\":[{\"url\":\"https://example.com/a.png\",\"width\":1024,\"height\":1024}]}" + resp, err := adapters.fal_parse_response_body(body) + defer adapters.dispose_fal_response(&resp) + + testing.expect(t, shared.is_ok(err), "typed parse should succeed") + testing.expect(t, len(resp.images) == 1, "expected one image") + testing.expect(t, resp.images[0].url == "https://example.com/a.png", "expected parsed URL") +} diff --git a/odin/tests/app_cli_phase6.odin b/odin/tests/app_cli_phase6.odin new file mode 100644 index 0000000..7082421 --- /dev/null +++ b/odin/tests/app_cli_phase6.odin @@ -0,0 +1,526 @@ +package tests + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:testing" +import app "../src/app" +import "../src/core" +import "../src/shared" +import "../src/ui" + +@test +cli_parse_commands :: proc(t: ^testing.T) { + c1 := app.parse_cli_command(nil) + testing.expect(t, c1.kind == .Demo, "no args should map to demo") + + a2 := [1]string{"status"} + c2 := app.parse_cli_command(a2[:]) + testing.expect(t, c2.kind == .Status, "status should parse") + + a3 := [2]string{"save", "x.json"} + c3 := app.parse_cli_command(a3[:]) + testing.expect(t, c3.kind == .Save, "save should parse") + testing.expect(t, c3.path == "x.json", "save path should parse") + + a4 := [1]string{"tui"} + c4 := app.parse_cli_command(a4[:]) + testing.expect(t, c4.kind == .Tui, "tui should parse") + + a5 := [1]string{"gui"} + c5 := app.parse_cli_command(a5[:]) + testing.expect(t, c5.kind == .Gui, "gui should parse") + + testing.expect(t, app.normalize_tui_command("q") == "quit", "q alias should expand") + testing.expect(t, app.normalize_tui_command("1") == "goto story", "1 alias should map to story") + testing.expect(t, app.normalize_tui_command("?") == "doctor", "? alias should map to doctor") + testing.expect(t, app.normalize_tui_command("r") == "ready", "r alias should map to ready") + testing.expect(t, app.normalize_tui_command("n") == "next", "n alias should map to next") + testing.expect(t, app.normalize_tui_command("p") == "plan", "p alias should map to plan") + testing.expect(t, app.normalize_tui_command("x") == "auto", "x alias should map to auto") + + pages, matched, perr := app.parse_generate_script_pages("generate script 6") + testing.expect(t, shared.is_ok(perr), "generate script pages parse should succeed") + testing.expect(t, matched, "generate script pages should match") + testing.expect(t, pages == 6, "generate script pages should parse value") + + local_pages, lmatched, lerr := app.parse_generate_script_local_pages("generate script local 3") + testing.expect(t, shared.is_ok(lerr), "generate script local parse should succeed") + testing.expect(t, lmatched, "generate script local should match") + testing.expect(t, local_pages == 3, "generate script local should parse value") + + page, pmatch, pperr := app.parse_generate_panels_page("generate panels page 2") + testing.expect(t, shared.is_ok(pperr), "generate panels page parse should succeed") + testing.expect(t, pmatch, "generate panels page should match") + testing.expect(t, page == 2, "generate panels page should parse value") + + lpage, lpmatch, lperr := app.parse_generate_panels_local_page("generate panels local page 2") + testing.expect(t, shared.is_ok(lperr), "generate panels local page parse should succeed") + testing.expect(t, lpmatch, "generate panels local page should match") + testing.expect(t, lpage == 2, "generate panels local page should parse value") + + fmt_kind, export_path, ematch, eerr := app.parse_export_command("export cbz ./out.cbz") + testing.expect(t, shared.is_ok(eerr), "export parse should succeed") + testing.expect(t, ematch, "export parse should match") + testing.expect(t, fmt_kind == .CBZ, "export format should parse") + testing.expect(t, export_path == "./out.cbz", "export path should parse") + + qfmt, qpath, qpages, qmatch, qerr := app.parse_quick_local_command("quick local pdf ./quick.pdf 3") + testing.expect(t, shared.is_ok(qerr), "quick local parse should succeed") + testing.expect(t, qmatch, "quick local parse should match") + testing.expect(t, qfmt == .PDF, "quick local format should parse") + testing.expect(t, qpath == "./quick.pdf", "quick local path should parse") + testing.expect(t, qpages == 3, "quick local pages should parse") + + proj, qafmt, qaout, qapages, qamatch, qaerr := app.parse_quick_local_all_command("quick local all ./p.comic.json cbz ./q.cbz 4") + testing.expect(t, shared.is_ok(qaerr), "quick local all parse should succeed") + testing.expect(t, qamatch, "quick local all parse should match") + testing.expect(t, proj == "./p.comic.json", "quick local all project path should parse") + testing.expect(t, qafmt == .CBZ, "quick local all format should parse") + testing.expect(t, qaout == "./q.cbz", "quick local all export path should parse") + testing.expect(t, qapages == 4, "quick local all pages should parse") + + aafmt, aapath, aamatch, aaerr := app.parse_auto_all_command("auto all pdf ./auto.pdf") + testing.expect(t, shared.is_ok(aaerr), "auto all parse should succeed") + testing.expect(t, aamatch, "auto all parse should match") + testing.expect(t, aafmt == .PDF, "auto all format should parse") + testing.expect(t, aapath == "./auto.pdf", "auto all path should parse") + + aalfmt, aalpath, aalpages, aalmatch, aalerr := app.parse_auto_all_local_command("auto all local cbz ./auto.cbz 3") + testing.expect(t, shared.is_ok(aalerr), "auto all local parse should succeed") + testing.expect(t, aalmatch, "auto all local parse should match") + testing.expect(t, aalfmt == .CBZ, "auto all local format should parse") + testing.expect(t, aalpath == "./auto.cbz", "auto all local path should parse") + testing.expect(t, aalpages == 3, "auto all local pages should parse") +} + +@test +cli_save_and_load_roundtrip :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-cli-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + + path := fmt.aprintf("%s/project.comic.json", tmp_dir) + defer delete(path) + + state := core.new_initial_state() + state.story_idea = "cli story" + + save_out, save_err := app.run_cli_command(app.Parsed_CLI_Command{kind = .Save, path = path}, &state) + testing.expect(t, shared.is_ok(save_err), "save command should succeed") + testing.expect(t, strings.contains(save_out, "Saved project"), "save output should mention save") + delete(save_out) + + state.story_idea = "changed" + load_out, load_err := app.run_cli_command(app.Parsed_CLI_Command{kind = .Load, path = path}, &state) + testing.expect(t, shared.is_ok(load_err), "load command should succeed") + testing.expect(t, strings.contains(load_out, "Loaded project"), "load output should mention load") + testing.expect(t, state.story_idea == "cli story", "load should restore story") + delete(load_out) + + core.dispose_state_owned(&state) +} + +@test +cli_tui_generate_script_requires_key :: proc(t: ^testing.T) { + prev := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator) + defer { + if len(prev) > 0 { + _ = os.set_env("DEEPSEEK_API_KEY", prev) + } else { + _ = os.unset_env("DEEPSEEK_API_KEY") + } + } + _ = os.unset_env("DEEPSEEK_API_KEY") + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + last_job := 0 + _, out, err := app.run_tui_command(&controller, "generate script 6", &last_job) + testing.expect(t, !shared.is_ok(err), "generate script should fail without configured key") + testing.expect(t, len(out) == 0, "error path should not return output") +} + +@test +cli_tui_generate_panels_page_requires_script :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + last_job := 0 + _, out, err := app.run_tui_command(&controller, "generate panels page 2", &last_job) + testing.expect(t, !shared.is_ok(err), "generate panels page should fail with empty script") + testing.expect(t, len(out) == 0, "error path should not return output") +} + +@test +cli_tui_generate_script_local_succeeds_without_key :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + last_job := 0 + _, out, err := app.run_tui_command(&controller, "generate script local 2", &last_job) + testing.expect(t, shared.is_ok(err), "generate script local should succeed without key") + testing.expect(t, strings.contains(out, "local script generated"), "local generation should return success message") + delete(out) + testing.expect(t, len(controller.state.script.pages) == 2, "local script should create requested pages") +} + +@test +cli_tui_layout_and_export_require_data :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + last_job := 0 + _, out1, err1 := app.run_tui_command(&controller, "layout auto", &last_job) + testing.expect(t, !shared.is_ok(err1), "layout auto should fail without script") + testing.expect(t, len(out1) == 0, "layout error path should not return output") + + _, out2, err2 := app.run_tui_command(&controller, "export pdf ./tmp.pdf", &last_job) + testing.expect(t, !shared.is_ok(err2), "export should fail without layouts") + testing.expect(t, len(out2) == 0, "export error path should not return output") +} + +@test +cli_tui_local_panels_and_export_pdf :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-cli-local-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + + out_pdf := fmt.aprintf("%s/local.pdf", tmp_dir) + defer delete(out_pdf) + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer { + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = nil + ui.dispose_controller(&controller) + } + + last_job := 0 + _, out1, err1 := app.run_tui_command(&controller, "generate script local 2", &last_job) + testing.expect(t, shared.is_ok(err1), "local script should succeed") + delete(out1) + + _, out2, err2 := app.run_tui_command(&controller, "generate panels local", &last_job) + testing.expect(t, shared.is_ok(err2), "local panels should succeed") + delete(out2) + + _, out3, err3 := app.run_tui_command(&controller, "layout auto", &last_job) + testing.expect(t, shared.is_ok(err3), "layout auto should succeed") + delete(out3) + + export_cmd := fmt.aprintf("export pdf %s", out_pdf) + defer delete(export_cmd) + _, out4, err4 := app.run_tui_command(&controller, export_cmd, &last_job) + testing.expect(t, shared.is_ok(err4), "export should succeed") + delete(out4) + testing.expect(t, os.exists(out_pdf), "exported pdf should exist") +} + +@test +cli_tui_quick_local_export_pdf :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-cli-quick-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + + out_pdf := fmt.aprintf("%s/quick.pdf", tmp_dir) + defer delete(out_pdf) + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer { + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = nil + ui.dispose_controller(&controller) + } + + cmd := fmt.aprintf("quick local pdf %s 3", out_pdf) + defer delete(cmd) + last_job := 0 + _, out, err := app.run_tui_command(&controller, cmd, &last_job) + testing.expect(t, shared.is_ok(err), "quick local should succeed") + testing.expect(t, strings.contains(out, "quick local exported"), "quick local should return success output") + delete(out) + testing.expect(t, len(controller.state.script.pages) == 3, "quick local should build requested page count") + testing.expect(t, os.exists(out_pdf), "quick-local exported pdf should exist") +} + +@test +cli_tui_quick_local_all_saves_and_exports :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-cli-quick-all-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + + project_path := fmt.aprintf("%s/project.comic.json", tmp_dir) + export_path := fmt.aprintf("%s/quick.cbz", tmp_dir) + defer delete(project_path) + defer delete(export_path) + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer { + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = nil + ui.dispose_controller(&controller) + } + + cmd := fmt.aprintf("quick local all %s cbz %s 2", project_path, export_path) + defer delete(cmd) + last_job := 0 + _, out, err := app.run_tui_command(&controller, cmd, &last_job) + testing.expect(t, shared.is_ok(err), "quick local all should succeed") + testing.expect(t, strings.contains(out, "quick local all saved"), "quick local all should return success output") + delete(out) + testing.expect(t, os.exists(project_path), "quick local all should save project") + testing.expect(t, os.exists(export_path), "quick local all should export artifact") +} + +@test +cli_tui_doctor_reports_status :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + last_job := 0 + _, out, err := app.run_tui_command(&controller, "doctor", &last_job) + testing.expect(t, shared.is_ok(err), "doctor should succeed") + testing.expect(t, strings.contains(out, "Doctor"), "doctor output should include header") + testing.expect(t, strings.contains(out, "deepseek key:"), "doctor output should include deepseek key status") + testing.expect(t, strings.contains(out, "curl:"), "doctor output should include curl status") + delete(out) +} + +@test +cli_tui_ready_reports_status :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + last_job := 0 + _, out, err := app.run_tui_command(&controller, "ready", &last_job) + testing.expect(t, shared.is_ok(err), "ready should succeed") + testing.expect(t, strings.contains(out, "Ready"), "ready output should include header") + testing.expect(t, strings.contains(out, "script generated:"), "ready output should include script status") + testing.expect(t, strings.contains(out, "export ready:"), "ready output should include export status") + delete(out) +} + +@test +cli_tui_next_recommends_action :: proc(t: ^testing.T) { + prev_deep := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator) + prev_fal := os.get_env("FAL_API_KEY", context.temp_allocator) + defer { + if len(prev_deep) > 0 { _ = os.set_env("DEEPSEEK_API_KEY", prev_deep) } else { _ = os.unset_env("DEEPSEEK_API_KEY") } + if len(prev_fal) > 0 { _ = os.set_env("FAL_API_KEY", prev_fal) } else { _ = os.unset_env("FAL_API_KEY") } + } + _ = os.unset_env("DEEPSEEK_API_KEY") + _ = os.unset_env("FAL_API_KEY") + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + last_job := 0 + + _, out1, err1 := app.run_tui_command(&controller, "next", &last_job) + testing.expect(t, shared.is_ok(err1), "next should succeed") + testing.expect(t, strings.contains(out1, "generate script local"), "next should recommend local script generation") + delete(out1) + + _, out2, err2 := app.run_tui_command(&controller, "generate script local 1", &last_job) + testing.expect(t, shared.is_ok(err2), "generate script local should succeed") + delete(out2) + + _, out3, err3 := app.run_tui_command(&controller, "next", &last_job) + testing.expect(t, shared.is_ok(err3), "next should succeed after script") + testing.expect(t, strings.contains(out3, "generate panels local"), "next should recommend local panel generation") + delete(out3) +} + +@test +cli_tui_plan_reports_progress :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + last_job := 0 + + _, out1, err1 := app.run_tui_command(&controller, "plan", &last_job) + testing.expect(t, shared.is_ok(err1), "plan should succeed") + testing.expect(t, strings.contains(out1, "Plan"), "plan output should include header") + testing.expect(t, strings.contains(out1, "1) Script"), "plan output should include script step") + delete(out1) + + _, out2, err2 := app.run_tui_command(&controller, "generate script local 1", &last_job) + testing.expect(t, shared.is_ok(err2), "local script should succeed") + delete(out2) + + _, out3, err3 := app.run_tui_command(&controller, "plan", &last_job) + testing.expect(t, shared.is_ok(err3), "plan should succeed after script") + testing.expect(t, strings.contains(out3, "next:"), "plan output should include next hint") + delete(out3) +} + +@test +cli_tui_auto_runs_next_step :: proc(t: ^testing.T) { + prev_deep := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator) + prev_fal := os.get_env("FAL_API_KEY", context.temp_allocator) + defer { + if len(prev_deep) > 0 { _ = os.set_env("DEEPSEEK_API_KEY", prev_deep) } else { _ = os.unset_env("DEEPSEEK_API_KEY") } + if len(prev_fal) > 0 { _ = os.set_env("FAL_API_KEY", prev_fal) } else { _ = os.unset_env("FAL_API_KEY") } + } + _ = os.unset_env("DEEPSEEK_API_KEY") + _ = os.unset_env("FAL_API_KEY") + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + last_job := 0 + + _, out, err := app.run_tui_command(&controller, "auto", &last_job) + testing.expect(t, shared.is_ok(err), "auto should succeed") + testing.expect(t, strings.contains(out, "auto ran:"), "auto output should include executed command") + delete(out) + testing.expect(t, len(controller.state.script.pages) > 0, "auto should progress by generating script") +} + +@test +cli_tui_auto_all_runs_to_export :: proc(t: ^testing.T) { + prev_deep := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator) + prev_fal := os.get_env("FAL_API_KEY", context.temp_allocator) + defer { + if len(prev_deep) > 0 { _ = os.set_env("DEEPSEEK_API_KEY", prev_deep) } else { _ = os.unset_env("DEEPSEEK_API_KEY") } + if len(prev_fal) > 0 { _ = os.set_env("FAL_API_KEY", prev_fal) } else { _ = os.unset_env("FAL_API_KEY") } + } + _ = os.unset_env("DEEPSEEK_API_KEY") + _ = os.unset_env("FAL_API_KEY") + + tmp_dir, terr := os.make_directory_temp("", "comic-cli-auto-all-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + out_pdf := fmt.aprintf("%s/auto_all.pdf", tmp_dir) + defer delete(out_pdf) + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer { + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = nil + ui.dispose_controller(&controller) + } + last_job := 0 + + cmd := fmt.aprintf("auto all pdf %s", out_pdf) + defer delete(cmd) + _, out, err := app.run_tui_command(&controller, cmd, &last_job) + testing.expect(t, shared.is_ok(err), "auto all should succeed") + testing.expect(t, strings.contains(out, "auto all exported"), "auto all output should include success") + delete(out) + testing.expect(t, os.exists(out_pdf), "auto all should produce export file") +} + +@test +cli_tui_auto_all_local_runs_to_export :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-cli-auto-all-local-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + out_cbz := fmt.aprintf("%s/auto_all_local.cbz", tmp_dir) + defer delete(out_cbz) + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer { + for _, img in controller.state.panel_images { + delete(img.url) + delete(img.prompt) + } + delete(controller.state.panel_images) + controller.state.panel_images = nil + ui.dispose_controller(&controller) + } + last_job := 0 + + cmd := fmt.aprintf("auto all local cbz %s 2", out_cbz) + defer delete(cmd) + _, out, err := app.run_tui_command(&controller, cmd, &last_job) + testing.expect(t, shared.is_ok(err), "auto all local should succeed") + testing.expect(t, strings.contains(out, "auto all local exported"), "auto all local output should include success") + delete(out) + testing.expect(t, os.exists(out_cbz), "auto all local should produce export file") +} + +@test +cli_tui_open_and_saveas_aliases :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-cli-alias-*", context.temp_allocator) + if terr != nil { + testing.expect(t, false, "failed to create temp dir") + return + } + defer os.remove_all(tmp_dir) + + path := fmt.aprintf("%s/alias.comic.json", tmp_dir) + defer delete(path) + + state := core.new_initial_state() + controller := ui.new_controller(state) + + controller.state.story_idea = "alias story" + last_job := 0 + save_cmd := fmt.aprintf("saveas %s", path) + defer delete(save_cmd) + _, out1, err1 := app.run_tui_command(&controller, save_cmd, &last_job) + testing.expect(t, shared.is_ok(err1), "saveas should succeed") + delete(out1) + + controller.state.story_idea = "changed" + open_cmd := fmt.aprintf("open %s", path) + defer delete(open_cmd) + _, out2, err2 := app.run_tui_command(&controller, open_cmd, &last_job) + testing.expect(t, shared.is_ok(err2), "open should succeed") + delete(out2) + testing.expect(t, controller.state.story_idea == "alias story", "open should restore saved state") + + if shared.is_ok(err2) { + ui.dispose_controller_owned(&controller) + } else { + ui.dispose_controller(&controller) + } +} diff --git a/odin/tests/core_phase1.odin b/odin/tests/core_phase1.odin new file mode 100644 index 0000000..3ac139e --- /dev/null +++ b/odin/tests/core_phase1.odin @@ -0,0 +1,84 @@ +package tests + +import "core:testing" +import "../src/core" + +@test +seed_is_deterministic :: proc(t: ^testing.T) { + a := core.generate_panel_seed("proj_a", 1, 2, "panel_001") + b := core.generate_panel_seed("proj_a", 1, 2, "panel_001") + c := core.generate_panel_seed("proj_a", 1, 3, "panel_001") + + testing.expect(t, a == b, "seed should be stable for same input") + testing.expect(t, a != c, "seed should change for different panel number") +} + +@test +layout_packs_panels_into_pages :: proc(t: ^testing.T) { + panels_arr := [5]core.Panel{ + {panel_id = "p1", panel_number = 1}, + {panel_id = "p2", panel_number = 2}, + {panel_id = "p3", panel_number = 3}, + {panel_id = "p4", panel_number = 4}, + {panel_id = "p5", panel_number = 5}, + } + layouts := core.auto_layout_pages(panels_arr[:], .A4, "action", "grid-2x2") + defer { + for l in layouts { + delete(l.panels) + } + delete(layouts) + } + + testing.expect(t, len(layouts) == 1, "5 panels should fit action-dynamic in a single page") + testing.expect(t, layouts[0].pattern_id == "action-dynamic", "expected smallest suitable action pattern") + testing.expect(t, len(layouts[0].panels) == 5, "single page should contain all panels") +} + +@test +bubble_autoplacement_creates_entries :: proc(t: ^testing.T) { + dialogue_arr := [1]core.Dialogue{ + {speaker_id = "char_1", text = "Hello there", bubble_type = .Normal, emotion = "neutral"}, + } + chars_arr := [1]string{"char_1"} + panel := core.Panel{ + panel_id = "panel_1", + dialogue = dialogue_arr[:], + characters_present = chars_arr[:], + caption = "Narration line", + } + + bubbles := core.auto_place_panel_bubbles(panel, 800, 600) + defer { + for b in bubbles { + delete(b.id) + } + delete(bubbles) + } + testing.expect(t, len(bubbles) == 2, "dialogue + caption should produce 2 bubbles") +} + +@test +script_normalization_fills_ids :: proc(t: ^testing.T) { + panels_arr := [1]core.Panel{{description = "A scene"}} + pages_arr := [1]core.Page{{panels = panels_arr[:]}} + chars_arr := [1]core.Character{{name = "Hero"}} + + raw := core.Comic_Script{ + title = "", + synopsis = "", + characters = chars_arr[:], + pages = pages_arr[:], + } + + norm := core.normalize_script(raw) + defer { + delete(norm.title) + delete(norm.synopsis) + delete(norm.characters[0].id) + delete(norm.pages[0].panels[0].panel_id) + } + testing.expect(t, len(norm.title) > 0, "title should be filled") + testing.expect(t, len(norm.characters[0].id) > 0, "character id should be filled") + testing.expect(t, len(norm.pages[0].panels[0].panel_id) > 0, "panel id should be filled") +} diff --git a/odin/tests/core_smoke.odin b/odin/tests/core_smoke.odin new file mode 100644 index 0000000..f0aa2da --- /dev/null +++ b/odin/tests/core_smoke.odin @@ -0,0 +1,10 @@ +package tests + +import "core:testing" +import "../src/core" + +@test +state_initial_step :: proc(t: ^testing.T) { + state := core.new_initial_state() + testing.expect(t, state.workflow.current_step == .Story_Input, "expected Story_Input initial step") +} diff --git a/odin/tests/export_phase3.odin b/odin/tests/export_phase3.odin new file mode 100644 index 0000000..cff95d0 --- /dev/null +++ b/odin/tests/export_phase3.odin @@ -0,0 +1,96 @@ +package tests + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:testing" +import "../src/adapters" +import "../src/core" +import "../src/shared" + +join2 :: proc(a, b: string) -> string { + return fmt.aprintf("%s/%s", a, b) +} + +dispose_export_fixture :: proc(layouts: ^[]core.Page_Layout, panel_images: ^map[string]core.Panel_Image) { + for _, img in panel_images^ { + delete(img.url) + } + delete(panel_images^) + for l in layouts^ { + delete(l.panels) + } + delete(layouts^) +} + +setup_export_fixture :: proc(t: ^testing.T) -> (tmp_dir: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image) { + err: os.Error + tmp_dir, err = os.make_directory_temp("", "comic-export-test-*", context.temp_allocator) + if err != nil { + testing.expect(t, false, "failed to create temp dir") + return "", nil, nil + } + + src_img := join2(tmp_dir, "src_panel.png") + img_bytes := []byte{137, 80, 78, 71, 13, 10, 26, 10} + werr := os.write_entire_file(src_img, img_bytes) + testing.expect(t, werr == nil, "failed to write source image") + + panel_id := "panel_1" + layout_panel := core.Page_Layout_Panel{panel_id = panel_id, panel_number = 1, layout_cell = core.Layout_Cell{x = 0, y = 0, w = 1, h = 1}} + layout_panels_dyn: [dynamic]core.Page_Layout_Panel + append(&layout_panels_dyn, layout_panel) + + layout := core.Page_Layout{page_number = 1, pattern_id = "grid-2x2", panels = layout_panels_dyn[:], width = 1000, height = 1400} + layouts_dyn: [dynamic]core.Page_Layout + append(&layouts_dyn, layout) + + panel_images = make(map[string]core.Panel_Image) + panel_images[panel_id] = core.Panel_Image{url = fmt.aprintf("file://%s", src_img), width = 100, height = 100, seed = 1, prompt = ""} + delete(src_img) + + return tmp_dir, layouts_dyn[:], panel_images +} + +@test +export_png_and_cbz_create_files :: proc(t: ^testing.T) { + tmp_dir, layouts, panel_images := setup_export_fixture(t) + defer os.remove_all(tmp_dir) + defer dispose_export_fixture(&layouts, &panel_images) + + png_out := join2(tmp_dir, "out_png.zip") + defer delete(png_out) + cbz_out := join2(tmp_dir, "out_cbz.cbz") + defer delete(cbz_out) + + png_err := adapters.export_comic(png_out, layouts, panel_images, adapters.Export_Options{format = .PNG, page_size = .A4, dpi = 300, quality = 90}) + testing.expect(t, shared.is_ok(png_err), "png export failed") + testing.expect(t, os.exists(png_out), "png archive output should exist") + png_data, prerr := os.read_entire_file(png_out, context.temp_allocator) + testing.expect(t, prerr == nil, "failed reading png zip") + testing.expect(t, len(png_data) >= 2 && string(png_data[:2]) == "PK", "png export should be a zip archive") + + cbz_err := adapters.export_comic(cbz_out, layouts, panel_images, adapters.Export_Options{format = .CBZ, page_size = .A4, dpi = 300, quality = 90}) + testing.expect(t, shared.is_ok(cbz_err), "cbz export failed") + testing.expect(t, os.exists(cbz_out), "cbz output should exist") + cbz_data, crerr := os.read_entire_file(cbz_out, context.temp_allocator) + testing.expect(t, crerr == nil, "failed reading cbz") + testing.expect(t, len(cbz_data) >= 2 && string(cbz_data[:2]) == "PK", "cbz should be a zip archive") +} + +@test +export_pdf_creates_pdf_file :: proc(t: ^testing.T) { + tmp_dir, layouts, panel_images := setup_export_fixture(t) + defer os.remove_all(tmp_dir) + defer dispose_export_fixture(&layouts, &panel_images) + + pdf_out := join2(tmp_dir, "out.pdf") + defer delete(pdf_out) + pdf_err := adapters.export_comic(pdf_out, layouts, panel_images, adapters.Export_Options{format = .PDF, page_size = .A4, dpi = 300, quality = 90}) + testing.expect(t, shared.is_ok(pdf_err), "pdf export failed") + testing.expect(t, os.exists(pdf_out), "pdf output should exist") + + data, rerr := os.read_entire_file(pdf_out, context.temp_allocator) + testing.expect(t, rerr == nil, "failed to read pdf output") + testing.expect(t, strings.has_prefix(string(data), "%PDF-"), "pdf output should start with %PDF-") +} diff --git a/odin/tests/gui_helpers_phase28.odin b/odin/tests/gui_helpers_phase28.odin new file mode 100644 index 0000000..ffec41e --- /dev/null +++ b/odin/tests/gui_helpers_phase28.odin @@ -0,0 +1,695 @@ +package tests + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:testing" +import "../src/adapters" +import "../src/core" +import "../src/gui" +import "../src/shared" +import "../src/ui" + +@test +gui_parse_autosave_interval_bounds :: proc(t: ^testing.T) { + testing.expect(t, gui.parse_autosave_interval("42", 20) == 42, "valid interval should parse") + testing.expect(t, gui.parse_autosave_interval("", 20) == 20, "empty interval should fallback to default") + testing.expect(t, gui.parse_autosave_interval("1", 20) == 5, "interval should clamp to min") + testing.expect(t, gui.parse_autosave_interval("999", 20) == 300, "interval should clamp to max") +} + +@test +gui_fit_text_for_width_truncates_with_ellipsis :: proc(t: ^testing.T) { + truncated := gui.fit_text_for_width("ABCDE", 1, 10) + testing.expect(t, truncated == "ABC…", "fit_text_for_width should enforce 4-char minimum width rule and truncate with ellipsis") +} + +@test +gui_fit_text_for_width_passthrough_cases :: proc(t: ^testing.T) { + testing.expect(t, gui.fit_text_for_width("ABCD", 1, 10) == "ABCD", "text at min width should pass through") + testing.expect(t, gui.fit_text_for_width("ABCDE", 80, 0) == "ABCDE", "non-positive px_per_char should bypass truncation") +} + +@test +gui_set_autosave_interval_text_clamps_and_formats :: proc(t: ^testing.T) { + interval_text := "" + msg := gui.set_autosave_interval_text(&interval_text, 3) + defer delete(interval_text) + defer delete(msg) + testing.expect(t, interval_text == "5", "text should clamp to minimum interval") + testing.expect(t, strings.contains(msg, "5s"), "message should include clamped seconds") +} + +@test +gui_log_view_toggle_helpers :: proc(t: ^testing.T) { + lines: i32 = 6 + msg1 := gui.toggle_log_lines_with_message(&lines) + defer delete(msg1) + testing.expect(t, lines == 4, "log lines should toggle from 6 to 4") + testing.expect(t, msg1 == "Log lines: 4", "log lines message should match") + + msg2 := gui.toggle_log_lines_with_message(&lines) + defer delete(msg2) + testing.expect(t, lines == 6, "log lines should toggle back to 6") + testing.expect(t, msg2 == "Log lines: 6", "log lines message should match") + + oldest_first := false + msg3 := gui.toggle_log_order_with_message(&oldest_first) + defer delete(msg3) + testing.expect(t, oldest_first, "log order should toggle to oldest-first") + testing.expect(t, strings.contains(msg3, "oldest"), "log order message should mention oldest") + + msg4 := gui.toggle_log_order_with_message(&oldest_first) + defer delete(msg4) + testing.expect(t, !oldest_first, "log order should toggle back to newest-first") + testing.expect(t, strings.contains(msg4, "newest"), "log order message should mention newest") +} + +@test +gui_export_format_helper_updates_path_and_dirty :: proc(t: ^testing.T) { + export_format: core.Export_Format = .PDF + export_path := "./comic.pdf" + is_dirty := false + + msg := gui.set_export_format_with_message(&export_format, &export_path, .CBZ, &is_dirty) + defer delete(export_path) + defer delete(msg) + testing.expect(t, export_format == .CBZ, "format should update to CBZ") + testing.expect(t, is_dirty, "format switch should mark state dirty") + testing.expect(t, strings.has_suffix(export_path, ".cbz"), "export path should match selected format") + testing.expect(t, strings.has_prefix(msg, "Export format: CBZ"), "status should include format label") +} + +@test +gui_diagnostics_context_builder_maps_fields :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + action_log: gui.Action_Log + ctx := gui.make_diagnostics_action_context(&controller, &action_log, true, false, true, false, 30, "./p.comic.json", "./e.cbz", 4, true) + + testing.expect(t, ctx.controller == &controller, "context should hold controller pointer") + testing.expect(t, ctx.action_log == &action_log, "context should hold action-log pointer") + testing.expect(t, ctx.is_dirty, "context should map dirty flag") + testing.expect(t, !ctx.autosave_enabled, "context should map autosave flag") + testing.expect(t, ctx.project_ok && !ctx.export_ok, "context should map path health flags") + testing.expect(t, ctx.autosave_secs == 30, "context should map autosave seconds") + testing.expect(t, ctx.project_path == "./p.comic.json" && ctx.export_path == "./e.cbz", "context should map paths") + testing.expect(t, ctx.log_show_lines == 4 && ctx.log_oldest_first, "context should map log settings") +} + +@test +gui_project_path_normalization_variants :: proc(t: ^testing.T) { + p1 := gui.normalize_project_path("") + testing.expect(t, p1 == "./gui_project.comic.json", "empty path should use default project path") + + p2 := gui.normalize_project_path("my_story") + defer delete(p2) + testing.expect(t, p2 == "my_story.comic.json", "bare name should append .comic.json") + + p3 := gui.normalize_project_path("my_story.json") + defer delete(p3) + testing.expect(t, p3 == "my_story.comic.json", "json path should be converted to .comic.json") + + p4 := gui.normalize_project_path("already.comic.json") + testing.expect(t, p4 == "already.comic.json", "already-normalized path should remain unchanged") +} + +@test +gui_fix_all_paths_normalizes_project_and_export :: proc(t: ^testing.T) { + project_path := "project" + export_path := "./out" + defer delete(project_path) + defer delete(export_path) + + gui.fix_all_paths(&project_path, &export_path, .PNG) + testing.expect(t, strings.has_suffix(project_path, ".comic.json"), "fix_all_paths should normalize project suffix") + testing.expect(t, strings.has_suffix(export_path, ".zip"), "PNG export should normalize to .zip suffix") +} + +@test +gui_summary_toggle_supported_screen_guard :: proc(t: ^testing.T) { + opts := gui.Summary_View_Options{} + + msg1 := gui.toggle_summary_show_if_supported(.Story, &opts) + testing.expect(t, len(msg1) == 0, "show toggle should no-op on unsupported screen") + + msg2 := gui.toggle_summary_sort_if_supported(.Story, &opts) + testing.expect(t, len(msg2) == 0, "sort toggle should no-op on unsupported screen") + + msg3 := gui.toggle_summary_show_if_supported(.Script, &opts) + defer delete(msg3) + testing.expect(t, strings.has_prefix(msg3, "Script summary show-all"), "show toggle should produce script status message") +} + +@test +gui_push_dirty_status_sets_dirty_and_logs :: proc(t: ^testing.T) { + is_dirty := false + status_msg := fmt.aprintf("") + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + defer delete(status_msg) + + gui.push_dirty_status(&is_dirty, &status_msg, &action_log, "Changed value") + testing.expect(t, is_dirty, "push_dirty_status should set dirty flag") + testing.expect(t, status_msg == "Changed value", "push_dirty_status should set status message") + testing.expect(t, action_log.count == 1, "push_dirty_status should append one action-log entry") +} + +@test +gui_push_status_if_nonempty_only_pushes_for_content :: proc(t: ^testing.T) { + status_msg := fmt.aprintf("initial") + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + defer delete(status_msg) + + gui.push_status_if_nonempty(&status_msg, &action_log, "") + testing.expect(t, status_msg == "initial", "empty optional status should not overwrite status message") + testing.expect(t, action_log.count == 0, "empty optional status should not push to action log") + + gui.push_status_if_nonempty(&status_msg, &action_log, "non-empty") + testing.expect(t, status_msg == "non-empty", "non-empty optional status should update status message") + testing.expect(t, action_log.count == 1, "non-empty optional status should append one action-log entry") +} + +@test +gui_confirmation_request_sets_overlay_state :: proc(t: ^testing.T) { + show_confirm_overlay := false + show_help_overlay := true + pending_action: gui.Pending_Confirm_Action = .None + + msg := gui.request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_action, .Open_Project, "Confirm open?") + testing.expect(t, msg == "Confirm open?", "request_confirmation should return prompt message") + testing.expect(t, show_confirm_overlay, "request_confirmation should enable confirm overlay") + testing.expect(t, !show_help_overlay, "request_confirmation should close help overlay") + testing.expect(t, pending_action == .Open_Project, "request_confirmation should set pending action") +} + +@test +gui_autosave_tick_noop_when_disabled_or_clean :: proc(t: ^testing.T) { + state := core.new_initial_state() + project_path := "./tmp-test.comic.json" + is_dirty := true + last_autosave_at: f64 = 0 + last_save_at: f64 = -1 + + msg1 := gui.autosave_tick_with_message(&project_path, state, false, &is_dirty, &last_autosave_at, &last_save_at, 1) + testing.expect(t, len(msg1) == 0, "autosave tick should no-op when autosave is disabled") + testing.expect(t, is_dirty, "autosave disabled should not mutate dirty flag") + + is_dirty = false + msg2 := gui.autosave_tick_with_message(&project_path, state, true, &is_dirty, &last_autosave_at, &last_save_at, 1) + testing.expect(t, len(msg2) == 0, "autosave tick should no-op when state is clean") +} + +@test +gui_log_clear_and_reset_helpers :: proc(t: ^testing.T) { + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + status_msg := fmt.aprintf("") + defer delete(status_msg) + + gui.push_status(&status_msg, &action_log, "first") + gui.push_status(&status_msg, &action_log, "second") + testing.expect(t, action_log.count == 2, "setup should add log entries") + + clear_msg := gui.clear_action_log_with_message(&action_log) + testing.expect(t, clear_msg == "Action log cleared", "clear helper should return expected message") + testing.expect(t, action_log.count == 0, "clear helper should reset action log count") + + lines: i32 = 4 + oldest_first := true + reset_msg := gui.reset_log_view_with_message(&lines, &oldest_first) + testing.expect(t, reset_msg == "Reset log view", "reset helper should return expected message") + testing.expect(t, lines == 6, "reset helper should restore default line count") + testing.expect(t, !oldest_first, "reset helper should restore newest-first ordering") +} + +@test +gui_confirm_resolve_none_returns_message :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + project_path := "./p.comic.json" + export_path := "./e.pdf" + is_dirty := false + last_autosave_at: f64 = 0 + + msg := gui.resolve_confirm_action_with_message(.None, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at) + testing.expect(t, msg == "No pending destructive action", "resolve helper should report none-pending message") +} + +@test +gui_help_overlay_toggle_and_close_helpers :: proc(t: ^testing.T) { + show_help_overlay := false + gui.toggle_help_overlay(&show_help_overlay) + testing.expect(t, show_help_overlay, "toggle helper should enable help overlay") + + gui.close_help_overlay_if_open(&show_help_overlay) + testing.expect(t, !show_help_overlay, "close helper should disable help overlay when open") + + gui.close_help_overlay_if_open(&show_help_overlay) + testing.expect(t, !show_help_overlay, "close helper should be a no-op when already closed") +} + +@test +gui_export_path_preset_and_derivation_helpers :: proc(t: ^testing.T) { + export_path := "" + msg1 := gui.set_export_preset_with_message(&export_path, .PNG) + defer delete(msg1) + testing.expect(t, strings.has_suffix(export_path, ".zip"), "preset helper should set PNG-compatible export path") + testing.expect(t, strings.has_prefix(msg1, "Preset export path:"), "preset helper should return status message") + + delete(export_path) + project_path := "./work/my.comic.json" + msg2 := gui.set_export_path_from_project_with_message(&export_path, project_path, .CBZ) + defer delete(export_path) + defer delete(msg2) + testing.expect(t, strings.has_suffix(export_path, ".cbz"), "project-derivation helper should set CBZ-compatible export path") + testing.expect(t, strings.contains(msg2, "Export path from project dir:"), "project-derivation helper should return status message") + + msg3 := gui.set_project_path_from_export_with_message(&project_path, export_path) + defer delete(project_path) + defer delete(msg3) + testing.expect(t, strings.has_suffix(project_path, "gui_project.comic.json"), "export-derivation helper should set project filename") + testing.expect(t, strings.contains(msg3, "Project path from export dir:"), "export-derivation helper should return status message") +} + +@test +gui_reset_project_session_resets_dirty_and_screen :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.story_idea = "changed" + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + controller.active_screen = .Export + is_dirty := true + last_autosave_at: f64 = 123 + + msg := gui.reset_project_session(&controller, &is_dirty, &last_autosave_at, false) + testing.expect(t, msg == "Reset project", "reset helper should return expected status") + testing.expect(t, !is_dirty, "reset helper should clear dirty flag") + testing.expect(t, controller.active_screen == .Story, "reset helper should sync active screen to initial workflow") + testing.expect(t, last_autosave_at == 123, "reset helper should not touch autosave timestamp when touch_time is false") +} + +@test +gui_toggle_autosave_with_message_flips_state :: proc(t: ^testing.T) { + autosave_enabled := true + msg1 := gui.toggle_autosave_with_message(&autosave_enabled) + defer delete(msg1) + testing.expect(t, !autosave_enabled, "autosave toggle should flip from enabled to disabled") + testing.expect(t, strings.contains(msg1, "no"), "autosave toggle message should report disabled state") + + msg2 := gui.toggle_autosave_with_message(&autosave_enabled) + defer delete(msg2) + testing.expect(t, autosave_enabled, "autosave toggle should flip back to enabled") + testing.expect(t, strings.contains(msg2, "yes"), "autosave toggle message should report enabled state") +} + +@test +gui_reset_helper_fields_with_message_sets_defaults :: proc(t: ^testing.T) { + export_path := "./custom.cbz" + local_script_pages := "9" + autosave_interval_text := "90" + defer delete(export_path) + + msg := gui.reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, .PDF) + testing.expect(t, msg == "Reset helper fields to defaults", "reset helpers should return expected status") + testing.expect(t, export_path == "./comic.pdf", "reset helpers should restore format-based export default") + testing.expect(t, local_script_pages == "2", "reset helpers should restore default local script pages") + testing.expect(t, autosave_interval_text == "20", "reset helpers should restore default autosave interval text") +} + +@test +gui_clear_selected_field_with_message_behaviors :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.story_idea = "idea" + export_path := "./comic.pdf" + local_script_pages := "3" + project_path := "./p.comic.json" + autosave_interval_text := "30" + is_dirty := false + + msg1 := gui.clear_selected_field_with_message(0, &state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty) + testing.expect(t, msg1 == "Cleared selected field", "clear helper should report clear when field had content") + testing.expect(t, len(state.story_idea) == 0, "clear helper should clear selected text field") + testing.expect(t, is_dirty, "clear helper should mark dirty when a field was cleared") + + is_dirty = false + msg2 := gui.clear_selected_field_with_message(0, &state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty) + testing.expect(t, msg2 == "Selected field already empty", "clear helper should report already-empty state") + testing.expect(t, !is_dirty, "clear helper should not mark dirty when nothing changed") +} + +@test +gui_path_health_hint_variants :: proc(t: ^testing.T) { + testing.expect(t, gui.path_health_hint(true, true) == "", "path-health hint should be empty when both paths are healthy") + testing.expect(t, strings.contains(gui.path_health_hint(false, false), "Fix paths"), "path-health hint should mention fixing both paths") + testing.expect(t, strings.contains(gui.path_health_hint(false, true), "Fix project path"), "path-health hint should mention project path fix") + testing.expect(t, strings.contains(gui.path_health_hint(true, false), "Fix export path"), "path-health hint should mention export path fix") +} + +@test +gui_path_health_predicates :: proc(t: ^testing.T) { + testing.expect(t, gui.project_path_is_normalized("./x.comic.json"), "project path predicate should accept normalized path") + testing.expect(t, !gui.project_path_is_normalized("./x.json"), "project path predicate should reject non-comic suffix") + + testing.expect(t, gui.export_path_matches_format("./x.pdf", .PDF), "export path predicate should accept PDF suffix") + testing.expect(t, !gui.export_path_matches_format("./x.cbz", .PDF), "export path predicate should reject wrong format suffix") +} + +@test +gui_screen_label_and_navigation_status :: proc(t: ^testing.T) { + testing.expect(t, gui.screen_status_label(.Panels) == "Panels", "screen label helper should map enum to name") + + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + msg := gui.navigate_screen_with_status(&controller, .Story) + defer delete(msg) + testing.expect(t, msg == "Screen: Story", "navigate helper should return status message for successful navigation") +} + +@test +gui_action_log_ring_buffer_retains_recent_entries :: proc(t: ^testing.T) { + status_msg := fmt.aprintf("") + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + defer delete(status_msg) + + for i in 0..<10 { + msg := "" + switch i { + case 0: msg = "entry_00" + case 1: msg = "entry_01" + case 2: msg = "entry_02" + case 3: msg = "entry_03" + case 4: msg = "entry_04" + case 5: msg = "entry_05" + case 6: msg = "entry_06" + case 7: msg = "entry_07" + case 8: msg = "entry_08" + case 9: msg = "entry_09" + } + gui.push_status(&status_msg, &action_log, msg) + } + + testing.expect(t, action_log.count == 10, "action log should track total pushes") + snapshot := gui.build_action_log_snapshot(action_log) + defer delete(snapshot) + testing.expect(t, strings.contains(snapshot, "entry_09"), "snapshot should include newest entry") + testing.expect(t, !strings.contains(snapshot, "entry_00"), "snapshot should drop entries outside ring capacity") +} + +@test +gui_action_log_clear_resets_count_and_snapshot :: proc(t: ^testing.T) { + status_msg := fmt.aprintf("") + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + defer delete(status_msg) + + gui.push_status(&status_msg, &action_log, "a") + gui.push_status(&status_msg, &action_log, "b") + testing.expect(t, action_log.count == 2, "setup should create action-log entries") + action_log.last_push_at = 42 + + _ = gui.clear_action_log_with_message(&action_log) + testing.expect(t, action_log.count == 0, "clear helper should reset count") + testing.expect(t, action_log.last_push_at == 0, "clear helper should reset last-push timestamp") + snapshot := gui.build_action_log_snapshot(action_log) + defer delete(snapshot) + testing.expect(t, snapshot == "(action log empty)", "snapshot should report empty log after clear") +} + +@test +gui_set_status_replaces_owned_string :: proc(t: ^testing.T) { + status_msg := fmt.aprintf("start") + defer delete(status_msg) + + gui.set_status(&status_msg, "updated") + testing.expect(t, status_msg == "updated", "set_status should replace existing status text") +} + +@test +gui_open_project_session_missing_file_returns_error_and_keeps_state :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.story_idea = "original" + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + project_path := "./definitely_missing_project" + export_path := "./comic.pdf" + is_dirty := true + last_autosave_at: f64 = 777 + + msg := gui.open_project_session(&controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at) + defer delete(project_path) + testing.expect(t, strings.contains(msg, "project file does not exist"), "open helper should surface missing-file error") + testing.expect(t, strings.has_suffix(project_path, ".comic.json"), "open helper should normalize missing-file project path") + testing.expect(t, controller.state.story_idea == "original", "failed open should keep existing controller state") + testing.expect(t, is_dirty, "failed open should preserve dirty flag") + testing.expect(t, last_autosave_at == 777, "failed open should preserve autosave timestamp") +} + +@test +gui_resolve_confirm_action_reset_branch :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.story_idea = "before" + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + project_path := "./unused.comic.json" + export_path := "./unused.pdf" + is_dirty := true + last_autosave_at: f64 = 42 + + msg := gui.resolve_confirm_action_with_message(.Reset_Project, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at) + testing.expect(t, msg == "Reset project", "resolve helper should dispatch reset action") + testing.expect(t, !is_dirty, "reset dispatch should clear dirty flag") + testing.expect(t, controller.active_screen == .Story, "reset dispatch should sync active screen") + testing.expect(t, last_autosave_at >= 0, "reset dispatch should set a non-negative autosave timestamp") + testing.expect(t, last_autosave_at != 42, "reset dispatch should refresh autosave timestamp") +} + +@test +gui_open_project_session_success_updates_state_and_paths :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-gui-open-*", context.temp_allocator) + testing.expect(t, terr == nil, "temp dir should be created") + if terr != nil { + return + } + defer os.remove_all(tmp_dir) + + project_path := fmt.aprintf("%s/session.comic.json", tmp_dir) + defer delete(project_path) + + saved := core.new_initial_state() + saved.story_idea = "loaded-story" + err := adapters.save_project(project_path, saved) + testing.expect(t, shared.is_ok(err), "save_project should succeed for open-session test") + if !shared.is_ok(err) { + return + } + + controller := ui.new_controller(core.new_initial_state()) + defer ui.dispose_controller_owned(&controller) + controller.state.story_idea = "original-story" + + export_path := "./placeholder.pdf" + is_dirty := true + last_autosave_at: f64 = 17 + + msg := gui.open_project_session(&controller, &project_path, &export_path, .CBZ, &is_dirty, &last_autosave_at) + defer delete(msg) + defer delete(export_path) + + testing.expect(t, strings.has_prefix(msg, "Opened project:"), "open helper should return opened-project status") + testing.expect(t, controller.state.story_idea == "loaded-story", "open helper should replace controller state from loaded project") + testing.expect(t, !is_dirty, "open helper should clear dirty flag on success") + testing.expect(t, strings.has_suffix(export_path, ".cbz"), "open helper should sync export path suffix to selected format") + testing.expect(t, strings.contains(export_path, tmp_dir), "open helper should sync export path into the project directory") + testing.expect(t, last_autosave_at != 17, "open helper should refresh autosave timestamp on success") +} + +@test +gui_resolve_confirm_action_open_branch_success :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-gui-confirm-open-*", context.temp_allocator) + testing.expect(t, terr == nil, "temp dir should be created") + if terr != nil { + return + } + defer os.remove_all(tmp_dir) + + project_path := fmt.aprintf("%s/session.comic.json", tmp_dir) + defer delete(project_path) + + saved := core.new_initial_state() + saved.story_idea = "confirm-open-loaded" + err := adapters.save_project(project_path, saved) + testing.expect(t, shared.is_ok(err), "save_project should succeed for resolve-open test") + if !shared.is_ok(err) { + return + } + + controller := ui.new_controller(core.new_initial_state()) + defer ui.dispose_controller_owned(&controller) + controller.state.story_idea = "before-open" + + export_path := "./placeholder.pdf" + is_dirty := true + last_autosave_at: f64 = 99 + + msg := gui.resolve_confirm_action_with_message(.Open_Project, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at) + defer delete(msg) + defer delete(export_path) + + testing.expect(t, strings.has_prefix(msg, "Opened project:"), "resolve helper should dispatch open action") + testing.expect(t, controller.state.story_idea == "confirm-open-loaded", "resolve open dispatch should load target state") + testing.expect(t, !is_dirty, "resolve open dispatch should clear dirty flag") + testing.expect(t, strings.has_suffix(export_path, ".pdf"), "resolve open dispatch should sync export path to PDF suffix") + testing.expect(t, strings.contains(export_path, tmp_dir), "resolve open dispatch should sync export path into project directory") + testing.expect(t, last_autosave_at != 99, "resolve open dispatch should refresh autosave timestamp") +} + +@test +gui_resolve_confirm_action_open_branch_missing_file_preserves_state :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.story_idea = "still-here" + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + project_path := "./missing-confirm-open" + export_path := "./out.pdf" + is_dirty := true + last_autosave_at: f64 = 55 + + msg := gui.resolve_confirm_action_with_message(.Open_Project, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at) + defer delete(project_path) + testing.expect(t, strings.contains(msg, "project file does not exist"), "resolve open branch should surface missing-file error") + testing.expect(t, controller.state.story_idea == "still-here", "resolve open failure should preserve existing state") + testing.expect(t, is_dirty, "resolve open failure should preserve dirty flag") + testing.expect(t, last_autosave_at == 55, "resolve open failure should preserve autosave timestamp") +} + +@test +gui_autosave_tick_success_writes_project_and_clears_dirty :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-gui-autosave-*", context.temp_allocator) + testing.expect(t, terr == nil, "temp dir should be created") + if terr != nil { + return + } + defer os.remove_all(tmp_dir) + + project_path := fmt.aprintf("%s/autosave_target.comic.json", tmp_dir) + defer delete(project_path) + state := core.new_initial_state() + is_dirty := true + last_autosave_at: f64 = -1 + last_save_at: f64 = -1 + + msg := gui.autosave_tick_with_message(&project_path, state, true, &is_dirty, &last_autosave_at, &last_save_at, 0) + defer delete(msg) + testing.expect(t, strings.has_prefix(msg, "Autosaved:"), "autosave tick should report success when project write works") + testing.expect(t, strings.has_suffix(project_path, ".comic.json"), "autosave tick should normalize project path suffix") + testing.expect(t, os.exists(project_path), "autosave tick should write project file") + testing.expect(t, !is_dirty, "autosave tick should clear dirty flag after successful save") + testing.expect(t, last_save_at >= 0, "autosave tick should set last-save timestamp on success") +} + + +@test +gui_write_diagnostics_with_message_creates_file :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-gui-diag-*", context.temp_allocator) + testing.expect(t, terr == nil, "temp dir should be created") + if terr != nil { + return + } + defer os.remove_all(tmp_dir) + + project_path := fmt.aprintf("%s/project.comic.json", tmp_dir) + defer delete(project_path) + export_path := fmt.aprintf("%s/out.pdf", tmp_dir) + defer delete(export_path) + + controller := ui.new_controller(core.new_initial_state()) + defer ui.dispose_controller(&controller) + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + ctx := gui.make_diagnostics_action_context(&controller, &action_log, false, true, true, true, 20, project_path, export_path, 6, false) + + msg := gui.write_diagnostics_with_message(ctx) + defer delete(msg) + testing.expect(t, strings.has_prefix(msg, "Wrote diagnostics file:"), "diagnostics write helper should report written file") + + diag_path := fmt.aprintf("%s/gui_diagnostics.txt", tmp_dir) + defer delete(diag_path) + testing.expect(t, os.exists(diag_path), "diagnostics write helper should create diagnostics file") +} + +@test +gui_write_session_report_with_message_creates_file :: proc(t: ^testing.T) { + tmp_dir, terr := os.make_directory_temp("", "comic-gui-report-*", context.temp_allocator) + testing.expect(t, terr == nil, "temp dir should be created") + if terr != nil { + return + } + defer os.remove_all(tmp_dir) + + project_path := fmt.aprintf("%s/project.comic.json", tmp_dir) + defer delete(project_path) + export_path := fmt.aprintf("%s/out.cbz", tmp_dir) + defer delete(export_path) + + controller := ui.new_controller(core.new_initial_state()) + defer ui.dispose_controller(&controller) + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + status_msg := fmt.aprintf("") + defer delete(status_msg) + gui.push_status(&status_msg, &action_log, "seed log entry") + + ctx := gui.make_diagnostics_action_context(&controller, &action_log, true, true, true, true, 30, project_path, export_path, 4, true) + msg := gui.write_session_report_with_message(ctx) + defer delete(msg) + testing.expect(t, strings.has_prefix(msg, "Wrote session report:"), "session report write helper should report written file") + + report_path := fmt.aprintf("%s/gui_session_report.txt", tmp_dir) + defer delete(report_path) + testing.expect(t, os.exists(report_path), "session report helper should create report file") +} + +@test +gui_write_diagnostics_with_message_failure_for_missing_dir :: proc(t: ^testing.T) { + controller := ui.new_controller(core.new_initial_state()) + defer ui.dispose_controller(&controller) + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + + project_path := "./missing-dir/sub/project.comic.json" + export_path := "./out.pdf" + ctx := gui.make_diagnostics_action_context(&controller, &action_log, false, true, true, true, 20, project_path, export_path, 6, false) + + msg := gui.write_diagnostics_with_message(ctx) + defer delete(msg) + testing.expect(t, msg == "Failed writing diagnostics file", "diagnostics write helper should report failure for missing directory") +} + +@test +gui_write_session_report_with_message_failure_for_missing_dir :: proc(t: ^testing.T) { + controller := ui.new_controller(core.new_initial_state()) + defer ui.dispose_controller(&controller) + action_log: gui.Action_Log + defer gui.action_log_dispose(&action_log) + + project_path := "./missing-dir/sub/project.comic.json" + export_path := "./out.pdf" + ctx := gui.make_diagnostics_action_context(&controller, &action_log, false, true, true, true, 20, project_path, export_path, 6, false) + + msg := gui.write_session_report_with_message(ctx) + defer delete(msg) + testing.expect(t, msg == "Failed writing session report", "session report write helper should report failure for missing directory") +} diff --git a/odin/tests/hardening_phase5.odin b/odin/tests/hardening_phase5.odin new file mode 100644 index 0000000..5430591 --- /dev/null +++ b/odin/tests/hardening_phase5.odin @@ -0,0 +1,58 @@ +package tests + +import "core:testing" +import "../src/core" +import "../src/ui" + +@test +core_dispose_state_clears_collections :: proc(t: ^testing.T) { + state := core.new_initial_state() + + sheet_urls_dyn: [dynamic]string + append(&sheet_urls_dyn, "x") + + chars_dyn: [dynamic]core.Character + append(&chars_dyn, core.Character{name = "A", character_sheet_urls = sheet_urls_dyn[:]}) + state.characters = chars_dyn[:] + + state.panel_images = make(map[string]core.Panel_Image) + state.panel_images["p1"] = core.Panel_Image{url = "u"} + + layout_panels_dyn: [dynamic]core.Page_Layout_Panel + append(&layout_panels_dyn, core.Page_Layout_Panel{panel_id = "p1"}) + layouts_dyn: [dynamic]core.Page_Layout + append(&layouts_dyn, core.Page_Layout{panels = layout_panels_dyn[:]}) + state.page_layouts = layouts_dyn[:] + + state.speech_bubbles = make(map[string][]core.Speech_Bubble) + bubbles_dyn: [dynamic]core.Speech_Bubble + append(&bubbles_dyn, core.Speech_Bubble{id = "b1"}) + state.speech_bubbles["p1"] = bubbles_dyn[:] + + steps_dyn: [dynamic]core.Workflow_Step + append(&steps_dyn, core.Workflow_Step.Story_Input) + state.workflow.completed_steps = steps_dyn[:] + + core.dispose_state(&state) + + testing.expect(t, len(state.characters) == 0, "characters should be cleared") + testing.expect(t, len(state.panel_images) == 0, "panel_images should be cleared") + testing.expect(t, len(state.page_layouts) == 0, "page_layouts should be cleared") + testing.expect(t, len(state.speech_bubbles) == 0, "speech_bubbles should be cleared") + testing.expect(t, len(state.workflow.completed_steps) == 0, "completed_steps should be cleared") +} + +@test +ui_dispose_controller_clears_jobs_and_state :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.panel_images = make(map[string]core.Panel_Image) + state.panel_images["p1"] = core.Panel_Image{url = "u"} + + controller := ui.new_controller(state) + _ = ui.submit_job(&controller.jobs, .Generate_Script, "job") + + ui.dispose_controller(&controller) + + testing.expect(t, len(controller.jobs.jobs) == 0, "jobs should be cleared") + testing.expect(t, len(controller.state.panel_images) == 0, "state maps should be cleared") +} diff --git a/odin/tests/storage_phase3.odin b/odin/tests/storage_phase3.odin new file mode 100644 index 0000000..211f7c7 --- /dev/null +++ b/odin/tests/storage_phase3.odin @@ -0,0 +1,56 @@ +package tests + +import "core:fmt" +import "core:os" +import "core:testing" +import "../src/adapters" +import "../src/core" +import "../src/shared" + +make_temp_project_path :: proc(t: ^testing.T) -> (string, string) { + tmp_dir, err := os.make_directory_temp("", "comic-odin-*", context.temp_allocator) + if err != nil { + testing.expect(t, false, "failed to create temp directory") + return "", "" + } + project_path := fmt.aprintf("%s/project.comic.json", tmp_dir) + return tmp_dir, project_path +} + +@test +storage_save_load_roundtrip :: proc(t: ^testing.T) { + tmp_dir, project_path := make_temp_project_path(t) + defer os.remove_all(tmp_dir) + defer delete(project_path) + + state := core.new_initial_state() + state.story_idea = "An inventor discovers a portal" + state.story_genre = "scifi" + state.user_mode = .Professional + + err := adapters.save_project(project_path, state) + testing.expect(t, shared.is_ok(err), "save_project should succeed") + + loaded, lerr := adapters.load_project(project_path) + defer core.dispose_state_owned(&loaded) + testing.expect(t, shared.is_ok(lerr), "load_project should succeed") + testing.expect(t, loaded.story_idea == state.story_idea, "story_idea should round-trip") + testing.expect(t, loaded.story_genre == state.story_genre, "story_genre should round-trip") + testing.expect(t, loaded.user_mode == state.user_mode, "user_mode should round-trip") +} + +@test +storage_creates_asset_cache_dir :: proc(t: ^testing.T) { + tmp_dir, project_path := make_temp_project_path(t) + defer os.remove_all(tmp_dir) + defer delete(project_path) + + state := core.new_initial_state() + err := adapters.save_project(project_path, state) + testing.expect(t, shared.is_ok(err), "save_project should succeed") + + asset_dir, derr := adapters.derive_asset_cache_dir(project_path) + testing.expect(t, shared.is_ok(derr), "derive_asset_cache_dir should succeed") + testing.expect(t, os.exists(asset_dir), "asset cache directory should exist") + testing.expect(t, os.is_dir(asset_dir), "asset cache directory should be a directory") +} diff --git a/odin/tests/ui_phase4.odin b/odin/tests/ui_phase4.odin new file mode 100644 index 0000000..5529d19 --- /dev/null +++ b/odin/tests/ui_phase4.odin @@ -0,0 +1,61 @@ +package tests + +import "core:testing" +import "../src/core" +import "../src/shared" +import "../src/ui" + +@test +ui_navigation_guard_blocks_locked_screen :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + err := ui.navigate_to_screen(&controller, .Layout) + testing.expect(t, !shared.is_ok(err), "layout should be blocked before panels exist") +} + +@test +ui_navigation_guard_allows_after_prereqs :: proc(t: ^testing.T) { + state := core.new_initial_state() + state.panel_images = make(map[string]core.Panel_Image) + state.panel_images["p1"] = core.Panel_Image{url = "https://example.com/p1.png", width = 1, height = 1} + + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + err := ui.navigate_to_screen(&controller, .Layout) + testing.expect(t, shared.is_ok(err), "layout should be allowed once panel images exist") +} + +@test +ui_background_job_lifecycle_and_cancel :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + job1 := ui.start_background_job(&controller, .Generate_Script, "job1") + testing.expect(t, controller.state.workflow.is_generating, "generation should be on after starting a job") + + job2 := ui.start_background_job(&controller, .Generate_Panel, "job2") + _ = ui.mark_job_running(&controller.jobs, job1) + _ = ui.mark_job_running(&controller.jobs, job2) + + cerr := ui.cancel_background_job(&controller, job2) + testing.expect(t, shared.is_ok(cerr), "cancel should succeed") + + _ = ui.finish_background_job(&controller, job1, "") + testing.expect(t, !controller.state.workflow.is_generating, "generation should be off after active jobs are done") +} + +@test +ui_workflow_transition_guard :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + + err := ui.set_workflow_step(&controller, .Layout) + testing.expect(t, !shared.is_ok(err), "should block invalid direct transition") + + err2 := ui.set_workflow_step(&controller, .Generating_Script) + testing.expect(t, shared.is_ok(err2), "should allow valid transition to generating script") +} diff --git a/odin/tests/ui_render_phase4.odin b/odin/tests/ui_render_phase4.odin new file mode 100644 index 0000000..5036b3e --- /dev/null +++ b/odin/tests/ui_render_phase4.odin @@ -0,0 +1,45 @@ +package tests + +import "core:strings" +import "core:testing" +import "../src/core" +import "../src/shared" +import "../src/ui" + +@test +ui_render_contains_header_and_status :: proc(t: ^testing.T) { + state := core.new_initial_state() + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + out := ui.render_app_to_string(controller) + defer delete(out) + + testing.expect(t, strings.contains(out, "[comic-odin]"), "render should contain app header") + testing.expect(t, strings.contains(out, "jobs=0"), "render should show job count") + testing.expect(t, strings.contains(out, "Story"), "default screen should be story") +} + +@test +ui_runtime_apply_commands_flow :: proc(t: ^testing.T) { + state := core.new_initial_state() + pages_dyn: [dynamic]core.Page + append(&pages_dyn, core.Page{page_number = 1}) + chars_dyn: [dynamic]core.Character + append(&chars_dyn, core.Character{name = "A"}) + state.script = core.Comic_Script{title = "Script", pages = pages_dyn[:], characters = chars_dyn[:]} + state.characters = chars_dyn[:] + state.panel_images = make(map[string]core.Panel_Image) + state.panel_images["p1"] = core.Panel_Image{url = "u", width = 1, height = 1} + + controller := ui.new_controller(state) + defer ui.dispose_controller(&controller) + cmds := [2]ui.UI_Command{ + {kind = .Navigate, screen = .Layout}, + {kind = .Start_Generate, job_type = .Generate_Panel, message = "panel"}, + } + + err := ui.apply_commands(&controller, cmds[:]) + testing.expect(t, shared.is_ok(err), "command sequence should succeed") + testing.expect(t, controller.active_screen == .Layout, "expected layout screen") + testing.expect(t, controller.state.workflow.is_generating, "generation should be active") +} diff --git a/package-lock.json b/package-lock.json index 1317a31..d38e557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6625,6 +6625,28 @@ "license": "ISC", "optional": true }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",