check point

This commit is contained in:
echo 2026-05-21 06:10:32 +02:00
parent ae1cae967b
commit 1e85df5193
72 changed files with 11452 additions and 0 deletions

44
.github/workflows/odin-ci.yml vendored Normal file
View File

@ -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

2
odin/.env Normal file
View File

@ -0,0 +1,2 @@
DEEPSEEK_API_KEY=sk-c6e67b9d125448f593f202a5891eb123
FAL_API_KEY=d6eda9df-62ca-4934-8a61-4e7e659411e2:731fc05a520e6aeb1f3b68d74d0515aa

5
odin/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
bin/
*.ll
*.o
*.obj
*.pdb

33
odin/README.md Normal file
View File

@ -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.

5
odin/build.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p bin
odin build src/app -out:bin/comic_odin -debug

BIN
odin/comic-odin Executable file

Binary file not shown.

27
odin/comic.pdf Normal file
View File

@ -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

1027
odin/docs/PORT_BACKLOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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": ""
}
}
}

View File

@ -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

View File

@ -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": ""
}
}
}

27
odin/gui_export.pdf Normal file
View File

@ -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

387
odin/gui_project.comic.json Normal file
View File

@ -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": ""
}
}
}

BIN
odin/gui_session_report.txt Normal file

Binary file not shown.

BIN
odin/local.cbz Normal file

Binary file not shown.

27
odin/local.pdf Normal file
View File

@ -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

View File

@ -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": ""
}
}
}

BIN
odin/quick_3.cbz Normal file

Binary file not shown.

27
odin/quick_local.pdf Normal file
View File

@ -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

View File

@ -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"] }
}
}
}
}

11
odin/scratch.odin Normal file
View File

@ -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")
}

BIN
odin/screenshot000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
odin/screenshot001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

32
odin/scripts/package.sh Executable file
View File

@ -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"

View File

@ -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)
}

View File

@ -0,0 +1,251 @@
package adapters
import "core:fmt"
import "core:os"
import filepath "core:path/filepath"
import "core:strings"
import "../core"
import "../shared"
Export_Options :: struct {
format: core.Export_Format,
page_size: core.Page_Size_Name,
dpi: int,
quality: int,
}
Ordered_Panel :: struct {
page_number: int,
panel_number: int,
panel_id: string,
}
collect_ordered_panels :: proc(layouts: []core.Page_Layout) -> []Ordered_Panel {
panels: [dynamic]Ordered_Panel
for page in layouts {
for p in page.panels {
append(&panels, Ordered_Panel{
page_number = page.page_number,
panel_number = p.panel_number,
panel_id = p.panel_id,
})
}
}
return panels[:]
}
path_join2 :: proc(a, b: string) -> (string, shared.App_Error) {
parts := [2]string{a, b}
out, err := filepath.join(parts[:], context.allocator)
if err != nil {
msg := fmt.aprintf("path join failed: %v", err)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
}
return out, shared.ok()
}
run_command :: proc(args: []string) -> shared.App_Error {
desc := os.Process_Desc{command = args}
state, _, stderr, err := os.process_exec(desc, context.temp_allocator)
if err != nil {
msg := fmt.aprintf("command failed to execute: %v", err)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
if !state.exited || state.exit_code != 0 {
err_text := string(stderr)
msg := fmt.aprintf("command failed: %s", err_text)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
return shared.ok()
}
file_ext_from_url :: proc(url: string) -> string {
base := url
if q := strings.index(base, "?"); q >= 0 {
base = base[:q]
}
ext := filepath.ext(base)
if len(ext) == 0 {
return ".png"
}
return ext
}
stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error {
staged_count := 0
for p, idx in ordered {
img := panel_images[p.panel_id]
if len(img.url) == 0 {
continue
}
ext := file_ext_from_url(img.url)
filename := fmt.aprintf("%03d_page%03d_panel%03d%s", idx+1, p.page_number, p.panel_number, ext)
out_path, jerr := path_join2(temp_dir, filename)
delete(filename)
if !shared.is_ok(jerr) {
return jerr
}
if strings.has_prefix(img.url, "file://") {
src_path := img.url[len("file://"):]
if cerr := os.copy_file(out_path, src_path); cerr != nil {
msg := fmt.aprintf("failed to copy local panel image %s: %v", p.panel_id, cerr)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
delete(out_path)
return err_out
}
} else {
cmd := [6]string{"curl", "-L", "-sS", "-o", out_path, img.url}
cerr := run_command(cmd[:])
if !shared.is_ok(cerr) {
msg := fmt.aprintf("failed to download panel %s: %s", p.panel_id, cerr.message)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
delete(out_path)
return err_out
}
}
delete(out_path)
staged_count += 1
}
if staged_count == 0 {
return shared.new_error(.Export, "no panel images available for export", false)
}
return shared.ok()
}
write_comic_info_xml :: proc(temp_dir: string, page_count: int) -> shared.App_Error {
comic_info := fmt.aprintf("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<ComicInfo>\n <Title>Generated Comic</Title>\n <PageCount>%d</PageCount>\n</ComicInfo>\n", page_count)
defer delete(comic_info)
path, jerr := path_join2(temp_dir, "ComicInfo.xml")
if !shared.is_ok(jerr) {
return jerr
}
defer delete(path)
if err := os.write_entire_file(path, comic_info); err != nil {
msg := fmt.aprintf("failed to write ComicInfo.xml: %v", err)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
return shared.ok()
}
zip_directory_with_python :: proc(output_path, source_dir: string, include_comic_info: bool) -> shared.App_Error {
script := "import os,sys,zipfile\nout=sys.argv[1]; src=sys.argv[2]; inc=sys.argv[3]=='1'\nwith zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as z:\n for n in sorted(os.listdir(src)):\n if (not inc) and n=='ComicInfo.xml':\n continue\n p=os.path.join(src,n)\n if os.path.isfile(p):\n z.write(p,arcname=n)"
inc := "0"
if include_comic_info {
inc = "1"
}
cmd := [6]string{"python3", "-c", script, output_path, source_dir, inc}
return run_command(cmd[:])
}
write_simple_pdf :: proc(output_path: string, ordered: []Ordered_Panel) -> shared.App_Error {
text := fmt.aprintf("Comic Export - Panels: %d", len(ordered))
defer delete(text)
content := fmt.aprintf("BT /F1 12 Tf 50 780 Td (%s) Tj ET", text)
defer delete(content)
stream := fmt.aprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)
defer delete(stream)
obj1 := "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
obj2 := "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
obj3 := "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n"
obj4 := fmt.aprintf("4 0 obj\n%s\nendobj\n", stream)
defer delete(obj4)
obj5 := "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
xref_start := len("%PDF-1.4\n") + len(obj1) + len(obj2) + len(obj3) + len(obj4) + len(obj5)
pdf := fmt.aprintf(
"%%PDF-1.4\n%s%s%s%s%sxref\n0 6\n0000000000 65535 f \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
obj1, obj2, obj3, obj4, obj5, xref_start,
)
defer delete(pdf)
if err := os.write_entire_file(output_path, pdf); err != nil {
msg := fmt.aprintf("failed to write pdf: %v", err)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
return shared.ok()
}
ensure_export_output_parent_dir :: proc(output_path: string) -> shared.App_Error {
dir, _ := filepath.split(output_path)
if len(dir) == 0 {
return shared.ok()
}
if err := os.mkdir_all(dir); err != nil && err != .Exist {
msg := fmt.aprintf("failed to create export output directory: %v", err)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
return shared.ok()
}
export_comic :: proc(output_path: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image, opts: Export_Options) -> shared.App_Error {
if len(output_path) == 0 {
return shared.new_error(.Export, "output path is empty", false)
}
if len(layouts) == 0 {
return shared.new_error(.Export, "no layouts available for export", false)
}
if derr := ensure_export_output_parent_dir(output_path); !shared.is_ok(derr) {
return derr
}
ordered := collect_ordered_panels(layouts)
defer delete(ordered)
if len(ordered) == 0 {
return shared.new_error(.Export, "no panels available for export", false)
}
switch opts.format {
case .PDF:
return write_simple_pdf(output_path, ordered)
case .CBZ, .PNG:
temp_dir, terr := os.make_directory_temp("", "comic-export-*", context.temp_allocator)
if terr != nil {
msg := fmt.aprintf("failed to create temp dir: %v", terr)
err_out := shared.new_error(.Export, msg, true)
delete(msg)
return err_out
}
defer os.remove_all(temp_dir)
if serr := stage_panel_images(temp_dir, ordered, panel_images); !shared.is_ok(serr) {
return serr
}
include_comic_info := opts.format == .CBZ
if include_comic_info {
if ierr := write_comic_info_xml(temp_dir, len(layouts)); !shared.is_ok(ierr) {
return ierr
}
}
if zerr := zip_directory_with_python(output_path, temp_dir, include_comic_info); !shared.is_ok(zerr) {
return zerr
}
return shared.ok()
}
return shared.new_error(.Export, "unknown export format", false)
}
export_comic_stub :: proc(output_path: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image, opts: Export_Options) -> shared.App_Error {
return export_comic(output_path, layouts, panel_images, opts)
}

299
odin/src/adapters/fal.odin Normal file
View File

@ -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)
}

View File

@ -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)
}

1166
odin/src/app/cli.odin Normal file

File diff suppressed because it is too large Load Diff

25
odin/src/app/main.odin Normal file
View File

@ -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)
}

163
odin/src/core/bubble.odin Normal file
View File

@ -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[:]
}

View File

@ -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
}

140
odin/src/core/dispose.odin Normal file
View File

@ -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
}

264
odin/src/core/layout.odin Normal file
View File

@ -0,0 +1,264 @@
package core
Page_Size :: struct {
name: string,
width: int,
height: int,
dpi: int,
}
get_page_size :: proc(name: Page_Size_Name) -> Page_Size {
switch name {
case .A4:
return Page_Size{"A4", 2480, 3508, 300}
case .Letter:
return Page_Size{"US Letter", 2550, 3300, 300}
case .Manga:
return Page_Size{"B5 Manga", 2158, 3035, 300}
case .Webtoon:
return Page_Size{"Webtoon", 800, 1280, 72}
case .Square:
return Page_Size{"Square", 1080, 1080, 72}
}
return Page_Size{"A4", 2480, 3508, 300}
}
GRID_2X2_CELLS : [4]Layout_Cell = [4]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.47, h = 0.47},
{x = 0.51, y = 0.02, w = 0.47, h = 0.47},
{x = 0.02, y = 0.51, w = 0.47, h = 0.47},
{x = 0.51, y = 0.51, w = 0.47, h = 0.47},
}
MANGA_3_TIER_CELLS : [6]Layout_Cell = [6]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.3, h = 0.3},
{x = 0.35, y = 0.02, w = 0.3, h = 0.3},
{x = 0.68, y = 0.02, w = 0.3, h = 0.3},
{x = 0.02, y = 0.35, w = 0.47, h = 0.3},
{x = 0.51, y = 0.35, w = 0.47, h = 0.3},
{x = 0.02, y = 0.68, w = 0.96, h = 0.3},
}
ACTION_DYNAMIC_CELLS : [5]Layout_Cell = [5]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.6, h = 0.35},
{x = 0.64, y = 0.02, w = 0.34, h = 0.35},
{x = 0.02, y = 0.4, w = 0.34, h = 0.28},
{x = 0.38, y = 0.4, w = 0.6, h = 0.28},
{x = 0.02, y = 0.71, w = 0.96, h = 0.27},
}
SPLASH_CELLS : [1]Layout_Cell = [1]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.96, h = 0.96},
}
WEBTOON_CELLS : [3]Layout_Cell = [3]Layout_Cell{
{x = 0.02, y = 0.01, w = 0.96, h = 0.32},
{x = 0.02, y = 0.34, w = 0.96, h = 0.32},
{x = 0.02, y = 0.67, w = 0.96, h = 0.32},
}
WESTERN_3X3_CELLS : [9]Layout_Cell = [9]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.3, h = 0.29},
{x = 0.35, y = 0.02, w = 0.3, h = 0.29},
{x = 0.68, y = 0.02, w = 0.3, h = 0.29},
{x = 0.02, y = 0.35, w = 0.3, h = 0.29},
{x = 0.35, y = 0.35, w = 0.3, h = 0.29},
{x = 0.68, y = 0.35, w = 0.3, h = 0.29},
{x = 0.02, y = 0.68, w = 0.3, h = 0.29},
{x = 0.35, y = 0.68, w = 0.3, h = 0.29},
{x = 0.68, y = 0.68, w = 0.3, h = 0.29},
}
DIALOGUE_HEAVY_CELLS : [8]Layout_Cell = [8]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.47, h = 0.22},
{x = 0.51, y = 0.02, w = 0.47, h = 0.22},
{x = 0.02, y = 0.26, w = 0.47, h = 0.22},
{x = 0.51, y = 0.26, w = 0.47, h = 0.22},
{x = 0.02, y = 0.5, w = 0.47, h = 0.22},
{x = 0.51, y = 0.5, w = 0.47, h = 0.22},
{x = 0.02, y = 0.74, w = 0.47, h = 0.22},
{x = 0.51, y = 0.74, w = 0.47, h = 0.22},
}
CINEMATIC_CELLS : [4]Layout_Cell = [4]Layout_Cell{
{x = 0.02, y = 0.02, w = 0.96, h = 0.22},
{x = 0.02, y = 0.26, w = 0.96, h = 0.22},
{x = 0.02, y = 0.5, w = 0.96, h = 0.22},
{x = 0.02, y = 0.74, w = 0.96, h = 0.22},
}
pattern_max_panels :: proc(id: string) -> int {
switch id {
case "grid-2x2":
return 4
case "manga-3-tier":
return 6
case "action-dynamic":
return 5
case "splash-page":
return 1
case "webtoon-scroll":
return 3
case "western-3x3":
return 9
case "dialogue-heavy":
return 8
case "cinematic-widescreen":
return 4
}
return 4
}
pattern_matches_genre :: proc(id: string, genre: string) -> bool {
if len(genre) == 0 {
return true
}
switch id {
case "grid-2x2":
return true
case "manga-3-tier":
return genre == "manga" || genre == "action"
case "action-dynamic":
return genre == "action" || genre == "superhero"
case "splash-page":
return true
case "webtoon-scroll":
return genre == "webtoon" || genre == "slice-of-life"
case "western-3x3":
return genre == "western-comic"
case "dialogue-heavy":
return genre == "drama" || genre == "slice-of-life" || genre == "romance"
case "cinematic-widescreen":
return genre == "action" || genre == "scifi" || genre == "noir"
}
return true
}
get_layout_pattern_by_id :: proc(id: string) -> Layout_Pattern {
switch id {
case "grid-2x2":
return Layout_Pattern{id = id, name = "Classic Grid", description = "2x2 equal panels", genres = nil, max_panels = 4, cells = GRID_2X2_CELLS[:]}
case "manga-3-tier":
return Layout_Pattern{id = id, name = "Manga 3-Tier", description = "Three horizontal tiers", genres = nil, max_panels = 6, cells = MANGA_3_TIER_CELLS[:]}
case "action-dynamic":
return Layout_Pattern{id = id, name = "Action Dynamic", description = "Varied action panels", genres = nil, max_panels = 5, cells = ACTION_DYNAMIC_CELLS[:]}
case "splash-page":
return Layout_Pattern{id = id, name = "Splash Page", description = "Single full-page panel", genres = nil, max_panels = 1, cells = SPLASH_CELLS[:]}
case "webtoon-scroll":
return Layout_Pattern{id = id, name = "Webtoon Scroll", description = "Vertical mobile panels", genres = nil, max_panels = 3, cells = WEBTOON_CELLS[:]}
case "western-3x3":
return Layout_Pattern{id = id, name = "Western 3x3", description = "Classic 3x3", genres = nil, max_panels = 9, cells = WESTERN_3X3_CELLS[:]}
case "dialogue-heavy":
return Layout_Pattern{id = id, name = "Dialogue Heavy", description = "Conversation-focused", genres = nil, max_panels = 8, cells = DIALOGUE_HEAVY_CELLS[:]}
case "cinematic-widescreen":
return Layout_Pattern{id = id, name = "Cinematic Widescreen", description = "Wide cinematic panels", genres = nil, max_panels = 4, cells = CINEMATIC_CELLS[:]}
}
return Layout_Pattern{id = "grid-2x2", name = "Classic Grid", description = "2x2 equal panels", genres = nil, max_panels = 4, cells = GRID_2X2_CELLS[:]}
}
select_best_pattern :: proc(panel_count: int, genre: string, preference: string) -> Layout_Pattern {
pattern_ids := [8]string{
"grid-2x2",
"manga-3-tier",
"action-dynamic",
"splash-page",
"webtoon-scroll",
"western-3x3",
"dialogue-heavy",
"cinematic-widescreen",
}
if len(preference) > 0 {
if pattern_max_panels(preference) >= panel_count {
return get_layout_pattern_by_id(preference)
}
}
best_genre_id := ""
best_genre_max := 1 << 30
for id in pattern_ids {
max_panels := pattern_max_panels(id)
if max_panels < panel_count {
continue
}
if !pattern_matches_genre(id, genre) {
continue
}
if max_panels < best_genre_max {
best_genre_max = max_panels
best_genre_id = id
}
}
if len(best_genre_id) > 0 {
return get_layout_pattern_by_id(best_genre_id)
}
best_id := ""
best_max := 1 << 30
for id in pattern_ids {
max_panels := pattern_max_panels(id)
if max_panels < panel_count {
continue
}
if max_panels < best_max {
best_max = max_panels
best_id = id
}
}
if len(best_id) > 0 {
return get_layout_pattern_by_id(best_id)
}
return get_layout_pattern_by_id("grid-2x2")
}
auto_layout_pages :: proc(panels: []Panel, page_size: Page_Size_Name, genre: string, pattern_preference: string) -> []Page_Layout {
if len(panels) == 0 {
return nil
}
size := get_page_size(page_size)
pages: [dynamic]Page_Layout
panel_index := 0
page_number := 1
for panel_index < len(panels) {
remaining := len(panels) - panel_index
pattern := select_best_pattern(remaining, genre, pattern_preference)
take := pattern.max_panels
if take > remaining {
take = remaining
}
layout_panels: [dynamic]Page_Layout_Panel
for i in 0..<take {
p := panels[panel_index+i]
cell_index := i
if cell_index >= len(pattern.cells) {
cell_index = len(pattern.cells) - 1
}
append(&layout_panels, Page_Layout_Panel{
panel_id = p.panel_id,
panel_number = p.panel_number,
layout_cell = pattern.cells[cell_index],
})
}
append(&pages, Page_Layout{
page_number = page_number,
pattern_id = pattern.id,
panels = layout_panels[:],
width = size.width,
height = size.height,
})
panel_index += take
page_number += 1
}
return pages[:]
}

56
odin/src/core/script.odin Normal file
View File

@ -0,0 +1,56 @@
package core
import "core:fmt"
script_is_valid_minimal :: proc(script: Comic_Script) -> bool {
if len(script.title) == 0 {
return false
}
if len(script.synopsis) == 0 {
return false
}
if len(script.pages) == 0 {
return false
}
return true
}
normalize_script :: proc(script: Comic_Script) -> Comic_Script {
normalized := script
if len(normalized.title) == 0 {
normalized.title = fmt.aprintf("Untitled Comic")
}
if len(normalized.synopsis) == 0 {
normalized.synopsis = fmt.aprintf("Generated comic synopsis")
}
for idx in 0..<len(normalized.characters) {
if len(normalized.characters[idx].id) == 0 {
normalized.characters[idx].id = fmt.aprintf("char_%03d", idx+1)
}
if len(normalized.characters[idx].name) == 0 {
normalized.characters[idx].name = fmt.aprintf("Character %d", idx+1)
}
if normalized.characters[idx].seed == 0 {
normalized.characters[idx].seed = derive_seed_from_string(normalized.characters[idx].name)
}
}
for page_idx in 0..<len(normalized.pages) {
if normalized.pages[page_idx].page_number <= 0 {
normalized.pages[page_idx].page_number = page_idx + 1
}
for panel_idx in 0..<len(normalized.pages[page_idx].panels) {
if len(normalized.pages[page_idx].panels[panel_idx].panel_id) == 0 {
normalized.pages[page_idx].panels[panel_idx].panel_id = fmt.aprintf("panel_%03d_%03d", page_idx+1, panel_idx+1)
}
if normalized.pages[page_idx].panels[panel_idx].panel_number <= 0 {
normalized.pages[page_idx].panels[panel_idx].panel_number = panel_idx + 1
}
}
}
return normalized
}

51
odin/src/core/state.odin Normal file
View File

@ -0,0 +1,51 @@
package core
new_initial_state :: proc() -> Comic_State {
iso := ""
return Comic_State{
project = Project_Metadata{
project_id = "proj_todo",
project_name = "Untitled Comic",
created_at_iso = iso,
last_modified_iso = iso,
},
user_mode = .Casual,
story_idea = "",
story_genre = "action",
target_audience = "general",
art_style = "manga",
export_format = .PDF,
page_size = .A4,
color_profile = .RGB,
workflow = Workflow_State{
current_step = .Story_Input,
completed_steps = nil,
is_generating = false,
generation_progress = 0,
error_message = "",
},
panel_images = nil,
panel_errors = nil,
speech_bubbles = nil,
}
}
set_workflow_step :: proc(state: ^Comic_State, step: Workflow_Step) {
state.workflow.current_step = step
for s in state.workflow.completed_steps {
if s == step {
return
}
}
old_steps := state.workflow.completed_steps
steps: [dynamic]Workflow_Step
for s in old_steps {
append(&steps, s)
}
append(&steps, step)
state.workflow.completed_steps = steps[:]
delete(old_steps)
}

251
odin/src/core/types.odin Normal file
View File

@ -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,
}

View File

@ -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
}

429
odin/src/gui/actions.odin Normal file
View File

@ -0,0 +1,429 @@
package gui
import "core:fmt"
import "core:strconv"
import "core:strings"
import rl "vendor:raylib"
import "../adapters"
import "../core"
import "../shared"
import "../ui"
action_generate_local_script :: proc(controller: ^ui.App_Controller, pages: int) -> string {
story := controller.state.story_idea
if len(story) == 0 {
story = "A local GUI adventure"
}
script := build_local_script(story, pages)
core.dispose_script(&controller.state.script)
controller.state.script = script
controller.state.characters = controller.state.script.characters
controller.active_screen = .Script
controller.state.workflow.current_step = .Script_Review
return "Generated local script"
}
action_generate_deepseek_script :: proc(controller: ^ui.App_Controller, pages: int) -> string {
cfg := shared.load_config()
if len(cfg.deepseek_api_key) == 0 {
return "DeepSeek key missing (set DEEPSEEK_API_KEY)"
}
opts := adapters.Generate_Script_Options{
story_idea = controller.state.story_idea,
genre = controller.state.story_genre,
art_style = controller.state.art_style,
num_pages = pages,
audience = controller.state.target_audience,
}
script, gerr := adapters.generate_comic_script_stub(cfg, opts)
if !shared.is_ok(gerr) {
return fmt.aprintf("DeepSeek script failed: %s", gerr.message)
}
core.dispose_script(&controller.state.script)
controller.state.script = script
controller.state.characters = controller.state.script.characters
controller.active_screen = .Script
controller.state.workflow.current_step = .Script_Review
return "Generated DeepSeek script"
}
action_generate_local_panels :: proc(controller: ^ui.App_Controller) -> string {
panels := collect_script_panels(controller.state.script)
defer delete(panels)
if len(panels) == 0 {
return "No script panels available"
}
images, ierr := build_local_panel_images(panels)
if !shared.is_ok(ierr) {
return ierr.message
}
for _, img in controller.state.panel_images {
delete(img.url)
delete(img.prompt)
}
delete(controller.state.panel_images)
controller.state.panel_images = images
controller.active_screen = .Panels
return "Generated local panels"
}
action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string {
panels := collect_script_panels(controller.state.script)
defer delete(panels)
target_panel: core.Panel
found := false
for p in panels {
if p.panel_id == panel_id {
target_panel = p
found = true
break
}
}
if !found {
return "Panel not found in script"
}
single := make([]core.Panel, 1)
single[0] = target_panel
defer delete(single)
images, ierr := build_local_panel_images(single)
if !shared.is_ok(ierr) {
if controller.state.panel_errors == nil {
controller.state.panel_errors = make(map[string]string)
}
controller.state.panel_errors[panel_id] = strings.clone(ierr.message)
return "Panel generation failed"
}
if img, has := images[panel_id]; has {
if old, exists := controller.state.panel_images[panel_id]; exists {
delete(old.url)
delete(old.prompt)
}
if controller.state.panel_images == nil {
controller.state.panel_images = make(map[string]core.Panel_Image)
}
controller.state.panel_images[panel_id] = img
if err_msg, err_exists := controller.state.panel_errors[panel_id]; err_exists {
delete(err_msg)
delete_key(&controller.state.panel_errors, panel_id)
}
}
delete(images) // free the map shell returned by build_local_panel_images
return "Regenerated panel"
}
action_layout_auto :: proc(controller: ^ui.App_Controller) -> string {
panels := collect_script_panels(controller.state.script)
defer delete(panels)
if len(panels) == 0 {
return "No script panels for layout"
}
core.dispose_page_layouts(&controller.state.page_layouts)
controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "")
controller.active_screen = .Layout
controller.state.workflow.current_step = .Layout
return "Auto layout generated"
}
export_format_name :: proc(f: core.Export_Format) -> string {
switch f {
case .PDF: return "PDF"
case .PNG: return "PNG"
case .CBZ: return "CBZ"
}
return "PDF"
}
parse_pages_or_default :: proc(s: string, def: int) -> int {
v, ok := strconv.parse_int(strings.trim_space(s))
if !ok || v <= 0 {
return def
}
return v
}
parse_autosave_interval :: proc(s: string, def: int) -> int {
v, ok := strconv.parse_int(strings.trim_space(s))
if !ok {
return def
}
if v < 5 {
return 5
}
if v > 300 {
return 300
}
return v
}
set_autosave_interval_text :: proc(dst: ^string, seconds: int) -> string {
v := seconds
if v < 5 {
v = 5
}
if v > 300 {
v = 300
}
dst^ = fmt.aprintf("%d", v)
return fmt.aprintf("Autosave interval: %ds", v)
}
yn :: proc(v: bool) -> string {
if v {
return "yes"
}
return "no"
}
toggle_summary_show :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
#partial switch active_screen {
case .Script:
opts.script_show_all = !opts.script_show_all
return fmt.aprintf("Script summary show-all: %s", yn(opts.script_show_all))
case .Layout:
opts.layout_show_all = !opts.layout_show_all
return fmt.aprintf("Layout summary show-all: %s", yn(opts.layout_show_all))
}
return "Summary show toggle unavailable on this screen"
}
toggle_summary_sort :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
#partial switch active_screen {
case .Script:
opts.script_desc = !opts.script_desc
sort_name := "asc"
if opts.script_desc {
sort_name = "desc"
}
return fmt.aprintf("Script summary sort: %s", sort_name)
case .Layout:
opts.layout_desc = !opts.layout_desc
sort_name := "asc"
if opts.layout_desc {
sort_name = "desc"
}
return fmt.aprintf("Layout summary sort: %s", sort_name)
}
return "Summary sort toggle unavailable on this screen"
}
toggle_summary_show_if_supported :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
if active_screen == .Script || active_screen == .Layout {
return toggle_summary_show(active_screen, opts)
}
return ""
}
toggle_summary_sort_if_supported :: proc(active_screen: ui.App_Screen, opts: ^Summary_View_Options) -> string {
if active_screen == .Script || active_screen == .Layout {
return toggle_summary_sort(active_screen, opts)
}
return ""
}
reset_project_session :: proc(controller: ^ui.App_Controller, is_dirty: ^bool, last_autosave_at: ^f64, touch_time: bool) -> string {
core.dispose_state(&controller.state)
controller.state = core.new_initial_state()
controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step)
is_dirty^ = false
if touch_time {
last_autosave_at^ = rl.GetTime()
}
return "Reset project"
}
open_project_session :: proc(controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, is_dirty: ^bool, last_autosave_at: ^f64) -> string {
normalize_project_path_field(project_path)
loaded, lerr := adapters.load_project(project_path^)
if !shared.is_ok(lerr) {
return lerr.message
}
core.dispose_state(&controller.state)
controller.state = loaded
controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step)
sync_export_path_to_project_dir(project_path^, export_path, export_format)
is_dirty^ = false
last_autosave_at^ = rl.GetTime()
return fmt.aprintf("Opened project: %s (export -> %s)", project_path^, export_path^)
}
resolve_confirm_action_with_message :: proc(action: Pending_Confirm_Action, controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, is_dirty: ^bool, last_autosave_at: ^f64) -> string {
switch action {
case .Reset_Project:
return reset_project_session(controller, is_dirty, last_autosave_at, true)
case .Open_Project:
return open_project_session(controller, project_path, export_path, export_format, is_dirty, last_autosave_at)
case .None:
return "No pending destructive action"
}
return "No pending destructive action"
}
save_project_session_with_message :: proc(project_path: ^string, state: core.Comic_State, is_dirty: ^bool, last_autosave_at, last_save_at: ^f64, success_prefix: string) -> string {
normalize_project_path_field(project_path)
err := adapters.save_project(project_path^, state)
if !shared.is_ok(err) {
return err.message
}
is_dirty^ = false
last_autosave_at^ = rl.GetTime()
last_save_at^ = last_autosave_at^
return fmt.aprintf("%s: %s", success_prefix, project_path^)
}
action_export :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format) -> string {
opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90}
err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts)
if !shared.is_ok(err) {
return err.message
}
controller.active_screen = .Export
controller.state.workflow.current_step = .Complete
controller.state.export_format = export_format
return fmt.aprintf("Exported %s", export_format_name(export_format))
}
gui_next_hint_with_source :: proc(controller: ui.App_Controller, use_deepseek_script: bool) -> string {
if len(controller.state.script.pages) == 0 {
if use_deepseek_script {
return "generate script"
}
return "generate script local"
}
if len(controller.state.panel_images) == 0 {
return "generate panels local"
}
if len(controller.state.page_layouts) == 0 {
return "layout auto"
}
return "export pdf"
}
gui_next_hint :: proc(controller: ui.App_Controller) -> string {
return gui_next_hint_with_source(controller, false)
}
action_run_next :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int, use_deepseek_script: bool) -> string {
hint := gui_next_hint_with_source(controller^, use_deepseek_script)
switch hint {
case "generate script":
return action_generate_deepseek_script(controller, script_pages)
case "generate script local":
return action_generate_local_script(controller, script_pages)
case "generate panels local":
return action_generate_local_panels(controller)
case "layout auto":
return action_layout_auto(controller)
case "export pdf":
return action_export(controller, export_path, export_format)
}
return "No next action"
}
action_run_auto_all_local :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format, script_pages: int, use_deepseek_script: bool) -> string {
for _ in 0..<4 {
msg := action_run_next(controller, export_path, export_format, script_pages, use_deepseek_script)
if controller.active_screen == .Export {
return fmt.aprintf("Auto-all complete: %s", msg)
}
}
return "Auto-all could not complete"
}
run_script_action :: proc(controller: ^ui.App_Controller, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool) -> string {
is_dirty^ = true
if use_deepseek_script {
return action_generate_deepseek_script(controller, pages_count)
}
return action_generate_local_script(controller, pages_count)
}
run_panels_action :: proc(controller: ^ui.App_Controller, can_generate_panels: bool, is_dirty: ^bool) -> string {
if !can_generate_panels {
return "Generate script before panels"
}
is_dirty^ = true
return action_generate_local_panels(controller)
}
run_layout_action :: proc(controller: ^ui.App_Controller, can_layout: bool, is_dirty: ^bool) -> string {
if !can_layout {
return "Generate panels before layout"
}
is_dirty^ = true
return action_layout_auto(controller)
}
run_export_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, can_export: bool, is_dirty: ^bool, last_export_at: ^f64) -> string {
normalize_export_path_field(export_path, export_format)
if !can_export {
return "Export blocked: generate panels + layout first"
}
msg := action_export(controller, export_path^, export_format)
is_dirty^ = true
if strings.has_prefix(msg, "Exported ") {
last_export_at^ = rl.GetTime()
}
return msg
}
run_next_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool, last_export_at: ^f64) -> string {
normalize_export_path_field(export_path, export_format)
msg := action_run_next(controller, export_path^, export_format, pages_count, use_deepseek_script)
is_dirty^ = true
if controller.active_screen == .Export {
last_export_at^ = rl.GetTime()
}
return msg
}
run_auto_all_action :: proc(controller: ^ui.App_Controller, export_path: ^string, export_format: core.Export_Format, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool, last_export_at: ^f64) -> string {
normalize_export_path_field(export_path, export_format)
msg := action_run_auto_all_local(controller, export_path^, export_format, pages_count, use_deepseek_script)
is_dirty^ = true
if controller.active_screen == .Export {
last_export_at^ = rl.GetTime()
}
return msg
}
run_auto_all_save_action :: proc(controller: ^ui.App_Controller, project_path, export_path: ^string, export_format: core.Export_Format, pages_count: int, use_deepseek_script: bool, is_dirty: ^bool, last_export_at, last_autosave_at, last_save_at: ^f64) -> string {
normalize_export_path_field(export_path, export_format)
msg := action_run_auto_all_local(controller, export_path^, export_format, pages_count, use_deepseek_script)
if controller.active_screen == .Export {
last_export_at^ = rl.GetTime()
return save_project_session_with_message(project_path, controller.state, is_dirty, last_autosave_at, last_save_at, "Auto-all + saved")
}
return msg
}
set_export_format_with_message :: proc(export_format: ^core.Export_Format, export_path: ^string, next: core.Export_Format, is_dirty: ^bool) -> string {
export_format^ = next
export_path^ = export_path_for_format(export_path^, export_format^)
is_dirty^ = true
return fmt.aprintf("Export format: %s (%s)", export_format_name(export_format^), export_path^)
}
autosave_tick_with_message :: proc(project_path: ^string, state: core.Comic_State, autosave_enabled: bool, is_dirty: ^bool, last_autosave_at: ^f64, last_save_at: ^f64, autosave_interval_s: f64) -> string {
if !autosave_enabled || !is_dirty^ {
return ""
}
now := rl.GetTime()
if now-last_autosave_at^ < autosave_interval_s {
return ""
}
normalize_project_path_field(project_path)
err := adapters.save_project(project_path^, state)
last_autosave_at^ = now
if shared.is_ok(err) {
is_dirty^ = false
last_save_at^ = now
return fmt.aprintf("Autosaved: %s", project_path^)
}
return fmt.aprintf("Autosave failed: %s", err.message)
}

177
odin/src/gui/controls.odin Normal file
View File

@ -0,0 +1,177 @@
package gui
import "core:fmt"
import rl "vendor:raylib"
button_clicked :: proc(rec: rl.Rectangle) -> bool {
if !rl.IsMouseButtonPressed(.LEFT) {
return false
}
return rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
}
draw_button :: proc(rec: rl.Rectangle, label: string) {
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
bg := BTN_BG
border := BTN_BORDER
text := BTN_TEXT
if hover {
bg = BTN_BG_HOVER
border = BTN_BORDER_HOVER
}
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
}
draw_button_primary :: proc(rec: rl.Rectangle, label: string) {
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
bg := ACCENT
border := ACCENT_MUTED
if hover {
bg = ACCENT_HOVER
border = ACCENT
}
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT)
}
draw_button_danger :: proc(rec: rl.Rectangle, label: string) {
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
bg := DANGER_BG
border := DANGER_BORDER
if hover {
bg = DANGER_BG_HOVER
border = DANGER_BORDER
}
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT)
}
draw_button_warning :: proc(rec: rl.Rectangle, label: string) {
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
bg := WARN_BTN_BG
border := WARN_BTN_BORDER
text := WARN_BTN_TEXT
if hover {
bg = WARN_BTN_BG_HOVER
border = WARN_BTN_BORDER
}
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
}
draw_button_soft_accent :: proc(rec: rl.Rectangle, label: string) {
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
bg := BTN_SOFT_BG
border := BTN_SOFT_BORDER
text := BTN_SOFT_TEXT
if hover {
bg = BTN_SOFT_BG_HOVER
border = BTN_SOFT_BORDER
}
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
}
draw_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) {
if !enabled {
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, BTN_DISABLED_BG)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, BTN_DISABLED_BORDER)
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, BTN_DISABLED_TEXT)
return
}
draw_button(rec, label)
}
draw_small_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) {
if !enabled {
rl.DrawRectangleRounded(rec, 0.20, 6, BTN_DISABLED_BG)
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, BTN_DISABLED_BORDER)
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, BTN_DISABLED_TEXT)
return
}
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
bg := SBTN_BG
border := SBTN_BORDER
if hover {
bg = SBTN_BG_HOVER
border = SBTN_BORDER_HOVER
}
rl.DrawRectangleRounded(rec, 0.20, 6, bg)
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, border)
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, SBTN_TEXT)
}
draw_small_button :: proc(rec: rl.Rectangle, label: string) {
draw_small_button_state(rec, label, true)
}
button_readiness_hint :: proc(mouse: rl.Vector2, panels_btn, layout_btn, export_btn: rl.Rectangle, can_generate_panels, can_layout, can_export: bool) -> string {
if rl.CheckCollisionPointRec(mouse, panels_btn) && !can_generate_panels {
return "Panels requires a generated script"
}
if rl.CheckCollisionPointRec(mouse, layout_btn) && !can_layout {
return "Layout requires generated panels"
}
if rl.CheckCollisionPointRec(mouse, export_btn) && !can_export {
return "Export requires panels + layout"
}
return ""
}
draw_button_recommended :: proc(rec: rl.Rectangle, label: string) {
halo := rl.Rectangle{x = rec.x-2, y = rec.y-2, width = rec.width+4, height = rec.height+4}
rl.DrawRectangleRounded(halo, RADIUS_BUTTON, 8, RECOMMEND_HALO_FILL)
rl.DrawRectangleRoundedLinesEx(halo, RADIUS_BUTTON, 8, 1.4, RECOMMEND_HALO_BORDER)
draw_button(rec, label)
}
draw_nav_item :: proc(rec: rl.Rectangle, label: string, active: bool) {
bg := NAV_BG
border := NAV_BORDER
text := NAV_TEXT
if active {
bg = NAV_ACTIVE_BG
border = NAV_ACTIVE_BG
text = NAV_ACTIVE_TEXT
} else if rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) {
bg = NAV_BG_HOVER
border = NAV_BORDER_HOVER
}
rl.DrawRectangleRounded(rec, RADIUS_NAV, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_NAV, 8, 1.0, border)
if active {
rl.DrawRectangleRounded(rl.Rectangle{x = rec.x+2, y = rec.y+4, width = 4, height = rec.height-8}, 0.5, 8, NAV_ACTIVE_BAR)
}
label_x := i32(rec.x) + 8
label_w := int(rec.width) - 16
if active {
label_x = i32(rec.x) + 14
label_w = int(rec.width) - 22
}
draw_text_fitted(label, label_x, i32(rec.y)+6, 18, label_w, 8, text)
}
draw_input_field :: proc(rec: rl.Rectangle, value: string, selected: bool) {
bg := INPUT_BG
border := INPUT_BORDER
if selected {
halo := rl.Rectangle{x = rec.x - 2, y = rec.y - 2, width = rec.width + 4, height = rec.height + 4}
rl.DrawRectangleRounded(halo, RADIUS_INPUT, 8, INPUT_FOCUS_RING)
rl.DrawRectangleRoundedLinesEx(halo, RADIUS_INPUT, 8, 1.0, INPUT_FOCUS_BORDER)
bg = INPUT_FOCUS_BG
border = INPUT_FOCUS_BORDER
}
rl.DrawRectangleRounded(rec, RADIUS_INPUT, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_INPUT, 8, 1.2, border)
if !selected {
draw_text_fitted(value, i32(rec.x)+8, i32(rec.y)+6, 18, int(rec.width)-16, 8, INPUT_TEXT)
return
}
rl.DrawText(fmt.ctprintf("%s", value), i32(rec.x)+8, i32(rec.y)+6, 18, INPUT_TEXT_FOCUS)
}

View File

@ -0,0 +1,159 @@
package gui
import "core:fmt"
import "core:os"
import "core:path/filepath"
import rl "vendor:raylib"
import "../ui"
build_diagnostics_snapshot :: proc(controller: ui.App_Controller, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string {
log_order := "newest"
if log_oldest_first {
log_order = "oldest"
}
return fmt.aprintf("screen=%s workflow=%v next=%s dirty=%s autosave=%s(%ds) content=pages:%d,panels:%d,layouts:%d,chars:%d paths=P:%s,E:%s project=%s export=%s log=%d,%s uptime=%.1fs", ui.screen_name(controller.active_screen), controller.state.workflow.current_step, gui_next_hint(controller), yn(is_dirty), yn(autosave_enabled), autosave_secs, len(controller.state.script.pages), len(controller.state.panel_images), len(controller.state.page_layouts), len(controller.state.characters), yn(project_ok), yn(export_ok), project_path, export_path, log_show_lines, log_order, rl.GetTime())
}
build_action_log_snapshot :: proc(log: Action_Log) -> string {
if log.count == 0 {
return fmt.aprintf("(action log empty)")
}
now := rl.GetTime()
max_lines := len(log.entries)
if log.count < max_lines {
max_lines = log.count
}
out := ""
for line in 0..<max_lines {
idx := (log.count - 1 - line) % len(log.entries)
if idx < 0 {
idx += len(log.entries)
}
entry := fmt.aprintf("[%2.0fs] %s", now-log.entry_times[idx], log.entries[idx])
if line == 0 {
out = entry
} else {
prev := out
out = fmt.aprintf("%s\n%s", prev, entry)
delete(prev)
delete(entry)
}
}
return out
}
build_session_report :: proc(controller: ui.App_Controller, log: Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string {
diag := build_diagnostics_snapshot(controller, is_dirty, autosave_enabled, project_ok, export_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
log_text := build_action_log_snapshot(log)
report := fmt.aprintf("# comic-odin gui session report\n\n[meta]\ngenerated_uptime=%.1fs\n\n[diagnostics]\n%s\n\n[action_log]\n%s\n", rl.GetTime(), diag, log_text)
delete(diag)
delete(log_text)
return report
}
Diagnostics_Action_Context :: struct {
controller: ^ui.App_Controller,
action_log: ^Action_Log,
is_dirty: bool,
autosave_enabled: bool,
project_ok: bool,
export_ok: bool,
autosave_secs: int,
project_path: string,
export_path: string,
log_show_lines: i32,
log_oldest_first: bool,
}
make_diagnostics_action_context :: proc(controller: ^ui.App_Controller, action_log: ^Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> Diagnostics_Action_Context {
return Diagnostics_Action_Context{
controller = controller,
action_log = action_log,
is_dirty = is_dirty,
autosave_enabled = autosave_enabled,
project_ok = project_ok,
export_ok = export_ok,
autosave_secs = autosave_secs,
project_path = project_path,
export_path = export_path,
log_show_lines = log_show_lines,
log_oldest_first = log_oldest_first,
}
}
diagnostics_path_for_project :: proc(project_path: string) -> string {
dir, _ := filepath.split(project_path)
if len(dir) == 0 {
dir = "./"
}
parts := []string{dir, "gui_diagnostics.txt"}
joined, err := filepath.join(parts)
if err != nil {
return "./gui_diagnostics.txt"
}
return joined
}
session_report_path_for_project :: proc(project_path: string) -> string {
dir, _ := filepath.split(project_path)
if len(dir) == 0 {
dir = "./"
}
parts := []string{dir, "gui_session_report.txt"}
joined, err := filepath.join(parts)
if err != nil {
return "./gui_session_report.txt"
}
return joined
}
write_diagnostics_file :: proc(project_path, diag: string) -> string {
diag_path := diagnostics_path_for_project(project_path)
defer delete(diag_path)
if err := os.write_entire_file(diag_path, diag); err == nil {
return fmt.aprintf("Wrote diagnostics file: %s", diag_path)
}
return fmt.aprintf("Failed writing diagnostics file")
}
write_session_report_file :: proc(project_path, report: string) -> string {
report_path := session_report_path_for_project(project_path)
defer delete(report_path)
if err := os.write_entire_file(report_path, report); err == nil {
return fmt.aprintf("Wrote session report: %s", report_path)
}
return fmt.aprintf("Failed writing session report")
}
write_session_report_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
report := build_session_report(ctx.controller^, ctx.action_log^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
msg := write_session_report_file(ctx.project_path, report)
delete(report)
return msg
}
write_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
msg := write_diagnostics_file(ctx.project_path, diag)
delete(diag)
return msg
}
copy_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
msg := copy_text_with_status(diag, "Copied diagnostics snapshot")
delete(diag)
return msg
}
copy_action_log_snapshot_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
log_text := build_action_log_snapshot(ctx.action_log^)
msg := copy_text_with_status(log_text, "Copied action log snapshot")
delete(log_text)
return msg
}
copy_text_with_status :: proc(text, status: string) -> string {
rl.SetClipboardText(fmt.ctprintf("%s", text))
return status
}

View File

@ -0,0 +1,124 @@
package gui
import "core:fmt"
import "core:os"
import "../core"
import "../shared"
collect_script_panels :: proc(script: core.Comic_Script) -> []core.Panel {
out: [dynamic]core.Panel
for p in script.pages {
for pan in p.panels {
append(&out, pan)
}
}
return out[:]
}
count_script_panels :: proc(script: core.Comic_Script) -> int {
count := 0
for p in script.pages {
count += len(p.panels)
}
return count
}
local_panel_id_by_index :: proc(i: int) -> string {
switch i {
case 0: return "panel_local_001"
case 1: return "panel_local_002"
case 2: return "panel_local_003"
case 3: return "panel_local_004"
case 4: return "panel_local_005"
case 5: return "panel_local_006"
}
return "panel_local_overflow"
}
build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script {
out_pages: [dynamic]core.Page
for i in 0..<pages {
chars_present := make([]string, 1)
chars_present[0] = "char_001"
dialogue := make([]core.Dialogue, 1)
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = "neutral"}
panel := core.Panel{
panel_id = local_panel_id_by_index(i),
panel_number = 1,
shot_type = .Medium,
description = story_idea,
characters_present = chars_present,
dialogue = dialogue,
caption = "",
sound_effects = nil,
transition_from_previous = .None,
}
panels := make([]core.Panel, 1)
panels[0] = panel
append(&out_pages, core.Page{page_number = i + 1, layout_type = .Grid, panels = panels})
}
chars := make([]core.Character, 1)
chars[0] = core.Character{id = "char_001", name = "Protagonist", role = .Protagonist, description = "Main character"}
return core.Comic_Script{title = "Local Script", synopsis = story_idea, characters = chars, pages = out_pages[:]}
}
build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel_Image, shared.App_Error) {
tmp_dir, terr := os.make_directory_temp("", "comic-gui-local-panels-*", context.temp_allocator)
if terr != nil {
return nil, shared.new_error(.Generation, "failed to create local panel temp dir", true)
}
images := make(map[string]core.Panel_Image)
for p, idx in panels {
name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id)
out_path := fmt.aprintf("%s/%s", tmp_dir, name)
delete(name)
if werr := os.write_entire_file(out_path, "LOCAL PANEL IMAGE"); werr != nil {
delete(out_path)
return nil, shared.new_error(.Generation, "failed writing local panel image", true)
}
url := fmt.aprintf("file://%s", out_path)
prompt := fmt.aprintf("local")
images[p.panel_id] = core.Panel_Image{url = url, width = 1024, height = 1024, seed = i64(idx + 1), prompt = prompt}
delete(out_path)
}
return images, shared.ok()
}
append_char :: proc(dst: ^string, ch: rune) {
dst^ = fmt.aprintf("%s%c", dst^, ch)
}
pop_char :: proc(dst: ^string) {
if len(dst^) == 0 {
return
}
dst^ = dst^[:len(dst^)-1]
}
recommended_label_from_hint :: proc(hint: string) -> string {
switch hint {
case "generate script":
return "Generate Script"
case "generate script local":
return "Generate Script Local"
case "generate panels local":
return "Generate Panels Local"
case "layout auto":
return "Layout"
case "export pdf":
return "Export"
}
return "Next"
}
pending_action_name :: proc(a: Pending_Confirm_Action) -> string {
switch a {
case .Reset_Project: return "reset project"
case .Open_Project: return "open project"
case .None: return "continue"
}
return "continue"
}

151
odin/src/gui/overlays.odin Normal file
View File

@ -0,0 +1,151 @@
package gui
import "core:fmt"
import "core:strings"
import rl "vendor:raylib"
draw_action_log :: proc(log: Action_Log, x, y, max_visible: i32, oldest_first: bool) {
now := rl.GetTime()
max_lines := len(log.entries)
if log.count < max_lines {
max_lines = log.count
}
if max_visible > 0 && int(max_visible) < max_lines {
max_lines = int(max_visible)
}
for line in 0..<max_lines {
idx := 0
if oldest_first {
start := log.count - max_lines
idx = (start + line) % len(log.entries)
} else {
idx = (log.count - 1 - line) % len(log.entries)
}
if idx < 0 {
idx += len(log.entries)
}
row_y := y + i32(line*22)
if line % 2 == 0 {
rl.DrawRectangleRounded(rl.Rectangle{x = f32(x-4), y = f32(row_y-2), width = 442, height = 20}, 0.2, 6, LOG_ROW_ALT)
}
age := now - log.entry_times[idx]
line_text := fmt.tprintf("[%2.0fs] %s", age, log.entries[idx])
draw_text_fitted(line_text, x, row_y, 15, 430, 7, LOG_TEXT)
}
}
is_error_message :: proc(msg: string) -> bool {
return strings.contains(msg, "failed") || strings.contains(msg, "blocked") || strings.contains(msg, "No script")
}
is_warning_message :: proc(msg: string) -> bool {
return strings.contains(msg, "Unsaved") || strings.contains(msg, "Confirm") || strings.contains(msg, "requires") || strings.contains(msg, "before") || strings.contains(msg, "Cancelled")
}
status_text_color :: proc(msg: string) -> rl.Color {
if is_error_message(msg) {
return ERROR
}
if is_warning_message(msg) {
return WARNING
}
return SUCCESS
}
toast_bg_color :: proc(msg: string) -> rl.Color {
if is_error_message(msg) {
return TOAST_ERROR
}
if is_warning_message(msg) {
return TOAST_WARNING
}
return TOAST_SUCCESS
}
draw_toast :: proc(log: Action_Log, x, y, w: i32) {
if log.count == 0 {
return
}
age := rl.GetTime() - log.last_push_at
if age > 2.8 {
return
}
idx := (log.count - 1) % len(log.entries)
if idx < 0 {
idx += len(log.entries)
}
msg := log.entries[idx]
bg := toast_bg_color(msg)
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 34}
shadow := rl.Rectangle{x = f32(x), y = f32(y + 2), width = f32(w), height = 34}
rl.DrawRectangleRounded(shadow, RADIUS_TOAST, 8, TOAST_SHADOW)
rl.DrawRectangleRounded(rec, RADIUS_TOAST, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_TOAST, 8, 1.0, TOAST_BORDER)
draw_text_fitted(msg, x+10, y+8, 16, int(w-20), 8, TEXT_BRIGHT)
}
draw_help_line :: proc(x, y: i32, text: string) {
draw_text_fitted(text, x, y, 16, 820, 8, HELP_LINE)
}
draw_help_overlay :: proc() {
sw := rl.GetScreenWidth()
sh := rl.GetScreenHeight()
rec := rl.Rectangle{x = f32((sw-860)/2), y = f32((sh-642)/2), width = 860, height = 642}
rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY)
draw_card(rec)
x := i32(rec.x) + 30
y := i32(rec.y) + 28
rl.DrawText("Keyboard Shortcuts", x, y, 28, HELP_TITLE)
rl.DrawText("Navigation", x, y+44, 20, HELP_SECTION)
draw_help_line(x, y+72, "1..8 screens | TAB fields | click to focus | F11 pages | F12 project")
rl.DrawText("Core Actions", x, y+106, 20, HELP_SECTION)
draw_help_line(x, y+134, "F5 script F6 panels F7 layout F8 export F9 next F10 auto-all")
draw_help_line(x, y+160, "Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary")
rl.DrawText("Clipboard + Logs", x, y+196, 20, HELP_SECTION)
draw_help_line(x, y+224, "Ctrl+L clear log | Ctrl+Shift+L copy log | Ctrl+Shift+T/B log view | Ctrl+Shift+Z reset")
draw_help_line(x, y+248, "Ctrl+Shift+C status")
draw_help_line(x, y+272, "Ctrl+Shift+Y diag copy | Ctrl+Shift+R diag file | Ctrl+Shift+W report")
draw_help_line(x, y+296, "Ctrl+0 reset helpers | Ctrl+V paste | Ctrl+Shift+I copy | Ctrl+Backspace clear")
rl.DrawText("Paths", x, y+332, 20, HELP_SECTION)
draw_help_line(x, y+360, "Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export")
draw_help_line(x, y+386, "Ctrl+Shift+J fix project | F fix export | K/M quick-fix P/E | U fix all")
rl.DrawText("Autosave", x, y+422, 20, HELP_SECTION)
draw_help_line(x, y+450, "Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60")
rl.DrawText("Safety", x, y+486, 20, HELP_SECTION)
draw_help_line(x, y+514, "Dirty guard: Shift-click New/Open | Keyboard confirm: Ctrl+Shift+N / Ctrl+Shift+O")
rl.DrawText("Close help: Esc or /", x, y+542, 18, HELP_CLOSE)
}
draw_sidebar_shortcut_line :: proc(x, y: i32, text: string, c: rl.Color) {
draw_text_fitted(text, x, y, 14, 220, 7, c)
}
draw_sidebar_shortcuts :: proc(screen_h: i32) {
base_y := screen_h - 280
if base_y < 120 {
base_y = 120
}
draw_card(rl.Rectangle{x = 14, y = f32(base_y), width = 236, height = 210})
rl.DrawText("Quick Keys", 26, base_y+12, 18, SIDEBAR_TITLE)
draw_sidebar_shortcut_line(26, base_y+36, "F5/F6/F7/F8 generate/layout/export", SIDEBAR_TEXT)
draw_sidebar_shortcut_line(26, base_y+54, "Ctrl+S save Ctrl+O open", SIDEBAR_TEXT)
draw_sidebar_shortcut_line(26, base_y+72, "Ctrl+N new", SIDEBAR_TEXT)
draw_sidebar_shortcut_line(26, base_y+90, "F9 next F10 auto-all", SIDEBAR_TEXT)
draw_sidebar_shortcut_line(26, base_y+108, "/ full shortcut help", SIDEBAR_TEXT)
draw_sidebar_shortcut_line(26, base_y+140, "Press / for all shortcuts", SIDEBAR_FOOTER)
}
draw_confirm_overlay :: proc(action: Pending_Confirm_Action) {
sw := rl.GetScreenWidth()
sh := rl.GetScreenHeight()
rec := rl.Rectangle{x = f32((sw-520)/2), y = f32((sh-230)/2), width = 520, height = 230}
rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY)
draw_card(rec)
rl.DrawRectangleRounded(rl.Rectangle{x = rec.x, y = rec.y, width = rec.width, height = 8}, 0.08, 8, CONFIRM_ACCENT)
x := i32(rec.x) + 30
y := i32(rec.y) + 34
rl.DrawText("Confirm destructive action", x, y, 26, CONFIRM_TITLE)
draw_text_fitted(fmt.tprintf("You have unsaved changes. Do you want to %s?", pending_action_name(action)), x, y+42, 18, 470, 8, CONFIRM_BODY)
rl.DrawText("Enter/Y confirm • Esc/N cancel", x, y+72, 16, CONFIRM_HINT)
}

View File

@ -0,0 +1,165 @@
package gui
import "core:fmt"
import "core:path/filepath"
import "core:strings"
import "../core"
format_suffix :: proc(f: core.Export_Format) -> string {
switch f {
case .PDF: return ".pdf"
case .PNG: return ".zip"
case .CBZ: return ".cbz"
}
return ".pdf"
}
trim_known_export_suffix :: proc(path: string) -> string {
if strings.has_suffix(path, ".pdf") { return path[:len(path)-4] }
if strings.has_suffix(path, ".zip") { return path[:len(path)-4] }
if strings.has_suffix(path, ".cbz") { return path[:len(path)-4] }
if strings.has_suffix(path, ".png") { return path[:len(path)-4] }
return path
}
export_path_for_format :: proc(path: string, f: core.Export_Format) -> string {
base := trim_known_export_suffix(path)
return fmt.aprintf("%s%s", base, format_suffix(f))
}
default_export_filename_for_format :: proc(f: core.Export_Format) -> string {
switch f {
case .PDF: return "comic.pdf"
case .PNG: return "comic_png.zip"
case .CBZ: return "comic.cbz"
}
return "comic.pdf"
}
default_export_path_for_format :: proc(f: core.Export_Format) -> string {
return fmt.aprintf("./%s", default_export_filename_for_format(f))
}
export_path_in_project_dir :: proc(project_path: string, f: core.Export_Format) -> string {
dir, _ := filepath.split(project_path)
if len(dir) == 0 {
dir = "./"
}
parts := []string{dir, default_export_filename_for_format(f)}
joined, err := filepath.join(parts)
if err != nil {
return default_export_path_for_format(f)
}
return joined
}
reset_helper_fields :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) {
export_path^ = default_export_path_for_format(f)
local_script_pages^ = "2"
autosave_interval_text^ = "20"
}
sync_export_path_to_project_dir :: proc(project_path: string, export_path: ^string, f: core.Export_Format) {
export_path^ = export_path_in_project_dir(project_path, f)
}
project_path_in_export_dir :: proc(export_path: string) -> string {
dir, _ := filepath.split(export_path)
if len(dir) == 0 {
dir = "./"
}
parts := []string{dir, "gui_project.comic.json"}
joined, err := filepath.join(parts)
if err != nil {
return "./gui_project.comic.json"
}
return joined
}
normalize_project_path :: proc(path: string) -> string {
trimmed := strings.trim_space(path)
if len(trimmed) == 0 {
return "./gui_project.comic.json"
}
if strings.has_suffix(trimmed, ".comic.json") {
return trimmed
}
if strings.has_suffix(trimmed, ".json") {
return fmt.aprintf("%s.comic.json", trimmed[:len(trimmed)-5])
}
return fmt.aprintf("%s.comic.json", trimmed)
}
normalize_project_path_field :: proc(path: ^string) {
path^ = normalize_project_path(path^)
}
normalize_export_path_field :: proc(path: ^string, f: core.Export_Format) {
trimmed := strings.trim_space(path^)
if len(trimmed) == 0 {
path^ = default_export_path_for_format(f)
return
}
path^ = export_path_for_format(trimmed, f)
}
fix_all_paths :: proc(project_path, export_path: ^string, f: core.Export_Format) {
normalize_project_path_field(project_path)
normalize_export_path_field(export_path, f)
}
set_export_preset_with_message :: proc(export_path: ^string, f: core.Export_Format) -> string {
export_path^ = default_export_path_for_format(f)
return fmt.aprintf("Preset export path: %s", export_path^)
}
set_export_path_from_project_with_message :: proc(export_path: ^string, project_path: string, f: core.Export_Format) -> string {
export_path^ = export_path_in_project_dir(project_path, f)
return fmt.aprintf("Export path from project dir: %s", export_path^)
}
set_project_path_from_export_with_message :: proc(project_path: ^string, export_path: string) -> string {
project_path^ = project_path_in_export_dir(export_path)
return fmt.aprintf("Project path from export dir: %s", project_path^)
}
normalize_project_path_with_message :: proc(project_path: ^string) -> string {
normalize_project_path_field(project_path)
return fmt.aprintf("Normalized project path: %s", project_path^)
}
normalize_export_path_with_message :: proc(export_path: ^string, f: core.Export_Format) -> string {
normalize_export_path_field(export_path, f)
return fmt.aprintf("Normalized export path: %s", export_path^)
}
fix_all_paths_with_message :: proc(project_path, export_path: ^string, f: core.Export_Format) -> string {
fix_all_paths(project_path, export_path, f)
return fmt.aprintf("Normalized paths: P=%s E=%s", project_path^, export_path^)
}
project_path_is_normalized :: proc(path: string) -> bool {
trimmed := strings.trim_space(path)
return len(trimmed) > 0 && strings.has_suffix(trimmed, ".comic.json")
}
export_path_matches_format :: proc(path: string, f: core.Export_Format) -> bool {
trimmed := strings.trim_space(path)
if len(trimmed) == 0 {
return false
}
return strings.has_suffix(trimmed, format_suffix(f))
}
path_health_hint :: proc(project_ok, export_ok: bool) -> string {
if project_ok && export_ok {
return ""
}
if !project_ok && !export_ok {
return "Fix paths: P/E/PE buttons or Ctrl+Shift+U"
}
if !project_ok {
return "Fix project path: P button or Ctrl+Shift+K"
}
return "Fix export path: E button or Ctrl+Shift+M"
}

973
odin/src/gui/runtime.odin Normal file
View File

@ -0,0 +1,973 @@
package gui
import "core:fmt"
import "core:strings"
import rl "vendor:raylib"
import "../core"
import "../shared"
import "../ui"
run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
controller := ui.new_controller(state^)
defer ui.dispose_job_manager(&controller.jobs)
rl.SetConfigFlags({.WINDOW_RESIZABLE, .WINDOW_UNDECORATED})
rl.InitWindow(1240, 820, "comic-odin gui")
defer rl.CloseWindow()
monitor := rl.GetCurrentMonitor()
monitor_pos := rl.GetMonitorPosition(monitor)
rl.SetWindowPosition(i32(monitor_pos.x), i32(monitor_pos.y))
rl.SetWindowSize(rl.GetMonitorWidth(monitor), rl.GetMonitorHeight(monitor))
rl.SetWindowState({.BORDERLESS_WINDOWED_MODE})
rl.SetTargetFPS(60)
selected_field := 0 // 0 idea, 1 genre, 2 audience, 3 export path, 4 local pages, 5 project path, 6 autosave interval
export_path := "./gui_export.pdf"
project_path := "./gui_project.comic.json"
local_script_pages := "2"
autosave_interval_text := "20"
export_format: core.Export_Format = .PDF
use_deepseek_script := false
status_msg := fmt.aprintf("GUI ready")
is_dirty := false
autosave_enabled := true
autosave_interval_s: f64 = 20
last_autosave_at := rl.GetTime()
last_save_at: f64 = -1
last_export_at: f64 = -1
action_log: Action_Log
log_show_lines: i32 = 6
log_oldest_first := false
summary_opts := Summary_View_Options{}
show_help_overlay := false
show_confirm_overlay := false
pending_confirm_action: Pending_Confirm_Action = .None
push_status(&status_msg, &action_log, status_msg)
defer action_log_dispose(&action_log)
defer delete(status_msg)
for !rl.WindowShouldClose() {
screen_w_loop := rl.GetScreenWidth()
screen_h_loop := rl.GetScreenHeight()
compact_mode := screen_h_loop < 860
cfg_loop := shared.load_config()
has_deepseek_key := len(cfg_loop.deepseek_api_key) > 0
main_w_loop := screen_w_loop - 282 - 20
if main_w_loop < 960 {
main_w_loop = 960
}
status_w_loop := (main_w_loop - 2) / 2
log_x_loop := 282 + status_w_loop + 2
lower_y_loop := screen_h_loop - 252
if lower_y_loop < 450 {
lower_y_loop = 450
}
idea_rec := rl.Rectangle{x = 420, y = 90, width = f32(main_w_loop-460), height = 36}
genre_rec := rl.Rectangle{x = 420, y = 146, width = f32(main_w_loop-460), height = 36}
audience_rec := rl.Rectangle{x = 420, y = 202, width = f32(main_w_loop-460), height = 36}
export_rec := rl.Rectangle{x = 420, y = 258, width = f32(main_w_loop-460), height = 36}
project_rec := rl.Rectangle{x = 420, y = 314, width = f32(main_w_loop-460), height = 36}
pages_rec := rl.Rectangle{x = f32(screen_w_loop - 120), y = 22, width = 100, height = 28}
fmt_pdf_btn := rl.Rectangle{x = 420, y = 400, width = 80, height = 30}
fmt_png_btn := rl.Rectangle{x = 510, y = 400, width = 80, height = 30}
fmt_cbz_btn := rl.Rectangle{x = 600, y = 400, width = 80, height = 30}
script_src_local_btn := rl.Rectangle{x = 800, y = 400, width = 92, height = 30}
script_src_deepseek_btn := rl.Rectangle{x = 898, y = 400, width = 120, height = 30}
// Main Actions Row 1 (y=470)
new_btn := rl.Rectangle{x = 290, y = 470, width = 140, height = 38}
script_btn := rl.Rectangle{x = 440, y = 470, width = 230, height = 38}
panels_btn := rl.Rectangle{x = 680, y = 470, width = 230, height = 38}
layout_btn := rl.Rectangle{x = 920, y = 470, width = 140, height = 38}
export_btn := rl.Rectangle{x = 1070, y = 470, width = 140, height = 38}
// Secondary Actions Row 2 (y=518)
save_btn := rl.Rectangle{x = 290, y = 518, width = 160, height = 38}
open_btn := rl.Rectangle{x = 460, y = 518, width = 160, height = 38}
next_btn := rl.Rectangle{x = 630, y = 518, width = 160, height = 38}
auto_btn := rl.Rectangle{x = 800, y = 518, width = 160, height = 38}
auto_save_btn := rl.Rectangle{x = 970, y = 518, width = 240, height = 38}
// Utility Strip Row 3 (y=566)
autosave_btn := rl.Rectangle{x = 290, y = 566, width = 160, height = 34}
autosave_rec := rl.Rectangle{x = 600, y = 568, width = 70, height = 30}
autosave_15_btn := rl.Rectangle{x = 680, y = 568, width = 44, height = 30}
autosave_30_btn := rl.Rectangle{x = 730, y = 568, width = 44, height = 30}
autosave_60_btn := rl.Rectangle{x = 780, y = 568, width = 44, height = 30}
help_btn := rl.Rectangle{x = 850, y = 566, width = 110, height = 34}
clear_field_btn := rl.Rectangle{x = 970, y = 566, width = 110, height = 34}
reset_helpers_btn := rl.Rectangle{x = 1090, y = 566, width = 120, height = 34}
// Path Fixes (now below project inputs)
export_copy_btn := rl.Rectangle{x = 420, y = 360, width = 110, height = 24}
export_preset_btn := rl.Rectangle{x = 540, y = 360, width = 110, height = 24}
path_fix_btn := rl.Rectangle{x = 660, y = 360, width = 110, height = 24}
project_fix_btn := rl.Rectangle{x = 780, y = 360, width = 110, height = 24}
project_from_export_btn := rl.Rectangle{x = 900, y = 360, width = 110, height = 24}
export_project_btn := rl.Rectangle{x = 1020, y = 360, width = 110, height = 24}
log_reset_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 68, height = 26}
report_file_btn := rl.Rectangle{x = f32(log_x_loop + 92), y = f32(lower_y_loop + 2), width = 68, height = 26}
log_copy_btn := rl.Rectangle{x = f32(log_x_loop + 166), y = f32(lower_y_loop + 2), width = 68, height = 26}
script_copy_page_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 96, height = 26}
script_copy_all_btn := rl.Rectangle{x = f32(log_x_loop + 120), y = f32(lower_y_loop + 2), width = 86, height = 26}
diag_file_btn := rl.Rectangle{x = f32(log_x_loop + 240), y = f32(lower_y_loop + 2), width = 68, height = 26}
status_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 2), width = 68, height = 26}
log_clear_btn := rl.Rectangle{x = f32(log_x_loop + 388), y = f32(lower_y_loop + 2), width = 68, height = 26}
diag_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 32), width = 68, height = 26}
confirm_base_x := (screen_w_loop - 520) / 2
confirm_base_y := (screen_h_loop - 230) / 2
confirm_yes_btn := rl.Rectangle{x = f32(confirm_base_x + 180), y = f32(confirm_base_y + 154), width = 140, height = 34}
confirm_no_btn := rl.Rectangle{x = f32(confirm_base_x + 330), y = f32(confirm_base_y + 154), width = 140, height = 34}
path_fix_project_status_btn := rl.Rectangle{x = 638, y = 556, width = 32, height = 20}
path_fix_export_status_btn := rl.Rectangle{x = 674, y = 556, width = 32, height = 20}
path_fix_all_status_btn := rl.Rectangle{x = 710, y = 556, width = 34, height = 20}
summary_show_btn := rl.Rectangle{x = f32(282 + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24}
summary_sort_btn := rl.Rectangle{x = f32(282 + status_w_loop - 94), y = f32(lower_y_loop + 18), width = 78, height = 24}
summary_prev_btn := rl.Rectangle{x = f32(300), y = f32(lower_y_loop + 18), width = 52, height = 24}
summary_next_btn := rl.Rectangle{x = f32(358), y = f32(lower_y_loop + 18), width = 52, height = 24}
if rl.IsKeyPressed(.SLASH) {
toggle_help_overlay(&show_help_overlay)
}
if rl.IsKeyPressed(.ESCAPE) {
close_help_overlay_if_open(&show_help_overlay)
}
interaction_locked := show_help_overlay || show_confirm_overlay
nav_story := rl.Rectangle{x = 16, y = 90, width = 228, height = 32}
nav_script := rl.Rectangle{x = 16, y = 126, width = 228, height = 32}
nav_chars := rl.Rectangle{x = 16, y = 162, width = 228, height = 32}
nav_panels := rl.Rectangle{x = 16, y = 198, width = 228, height = 32}
nav_layout := rl.Rectangle{x = 16, y = 234, width = 228, height = 32}
nav_bubbles := rl.Rectangle{x = 16, y = 270, width = 228, height = 32}
nav_export := rl.Rectangle{x = 16, y = 306, width = 228, height = 32}
nav_community := rl.Rectangle{x = 16, y = 342, width = 228, height = 32}
if !interaction_locked {
if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&controller, .Story) }
if rl.IsKeyPressed(.TWO) { _ = ui.navigate_to_screen(&controller, .Script) }
if rl.IsKeyPressed(.THREE) { _ = ui.navigate_to_screen(&controller, .Characters) }
if rl.IsKeyPressed(.FOUR) { _ = ui.navigate_to_screen(&controller, .Panels) }
if rl.IsKeyPressed(.FIVE) { _ = ui.navigate_to_screen(&controller, .Layout) }
if rl.IsKeyPressed(.SIX) { _ = ui.navigate_to_screen(&controller, .Bubbles) }
if rl.IsKeyPressed(.SEVEN) { _ = ui.navigate_to_screen(&controller, .Export) }
if rl.IsKeyPressed(.EIGHT) { _ = ui.navigate_to_screen(&controller, .Community) }
if button_clicked(nav_story) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Story))
}
if button_clicked(nav_script) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Script))
}
if button_clicked(nav_chars) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Characters))
}
if button_clicked(nav_panels) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Panels))
}
if button_clicked(nav_layout) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Layout))
}
if button_clicked(nav_bubbles) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Bubbles))
}
if button_clicked(nav_export) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Export))
}
if button_clicked(nav_community) {
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Community))
}
if rl.IsKeyPressed(.TAB) { selected_field = (selected_field + 1) % 7 }
if rl.IsKeyPressed(.F1) { selected_field = 0 }
if rl.IsKeyPressed(.F2) { selected_field = 1 }
if rl.IsKeyPressed(.F3) { selected_field = 2 }
if rl.IsKeyPressed(.F4) { selected_field = 3 }
if rl.IsKeyPressed(.F11) { selected_field = 4 }
if rl.IsKeyPressed(.F12) { selected_field = 5 }
if button_clicked(idea_rec) { selected_field = 0 }
if button_clicked(genre_rec) { selected_field = 1 }
if button_clicked(audience_rec) { selected_field = 2 }
if button_clicked(export_rec) { selected_field = 3 }
if button_clicked(pages_rec) { selected_field = 4 }
if button_clicked(project_rec) { selected_field = 5 }
if button_clicked(autosave_rec) { selected_field = 6 }
if button_clicked(autosave_15_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15))
}
if button_clicked(autosave_30_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30))
}
if button_clicked(autosave_60_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60))
}
}
pages_count := parse_pages_or_default(local_script_pages, 2)
autosave_secs := parse_autosave_interval(autosave_interval_text, 20)
autosave_interval_s = f64(autosave_secs)
if selected_field != 6 && len(strings.trim_space(autosave_interval_text)) == 0 {
autosave_interval_text = fmt.aprintf("%d", autosave_secs)
}
shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT)
can_generate_panels := len(controller.state.script.pages) > 0
can_layout := len(controller.state.panel_images) > 0
can_export := len(controller.state.page_layouts) > 0 && len(controller.state.panel_images) > 0
project_path_ok := project_path_is_normalized(project_path)
export_path_ok := export_path_matches_format(export_path, export_format)
if show_confirm_overlay {
confirm_yes := button_clicked(confirm_yes_btn) || rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y)
confirm_no := button_clicked(confirm_no_btn) || rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N)
if confirm_no {
show_confirm_overlay = false
pending_confirm_action = .None
push_status(&status_msg, &action_log, "Cancelled destructive action")
} else if confirm_yes {
action := pending_confirm_action
show_confirm_overlay = false
pending_confirm_action = .None
push_status(&status_msg, &action_log, resolve_confirm_action_with_message(action, &controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
}
}
if !interaction_locked {
if button_clicked(export_copy_btn) {
push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard"))
}
if button_clicked(export_preset_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format))
}
if button_clicked(path_fix_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
}
if button_clicked(project_fix_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
}
if button_clicked(project_from_export_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path))
}
if button_clicked(export_project_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format))
}
if button_clicked(path_fix_project_status_btn) && !project_path_ok {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
}
if button_clicked(path_fix_export_status_btn) && !export_path_ok {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
}
if button_clicked(path_fix_all_status_btn) && (!project_path_ok || !export_path_ok) {
push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format))
}
if button_clicked(fmt_pdf_btn) {
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PDF, &is_dirty))
}
if button_clicked(fmt_png_btn) {
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PNG, &is_dirty))
}
if button_clicked(fmt_cbz_btn) {
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .CBZ, &is_dirty))
}
if button_clicked(script_src_local_btn) {
use_deepseek_script = false
push_status(&status_msg, &action_log, "Script source: Local")
}
if button_clicked(script_src_deepseek_btn) {
if !has_deepseek_key {
push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)")
} else {
use_deepseek_script = true
push_status(&status_msg, &action_log, "Script source: DeepSeek")
}
}
if button_clicked(summary_show_btn) {
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts))
}
if button_clicked(summary_sort_btn) {
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts))
}
if controller.active_screen == .Script && button_clicked(summary_prev_btn) {
page_count := len(controller.state.script.pages)
if page_count > 0 {
summary_opts.script_page_cursor -= 1
if summary_opts.script_page_cursor < 0 {
summary_opts.script_page_cursor = page_count - 1
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
}
}
if controller.active_screen == .Script && button_clicked(summary_next_btn) {
page_count := len(controller.state.script.pages)
if page_count > 0 {
summary_opts.script_page_cursor += 1
if summary_opts.script_page_cursor >= page_count {
summary_opts.script_page_cursor = 0
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
}
}
if controller.active_screen == .Panels && button_clicked(summary_prev_btn) {
panel_count := count_script_panels(controller.state.script)
if panel_count > 0 {
summary_opts.panel_cursor -= 1
if summary_opts.panel_cursor < 0 {
summary_opts.panel_cursor = panel_count - 1
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
}
}
if controller.active_screen == .Panels && button_clicked(summary_next_btn) {
panel_count := count_script_panels(controller.state.script)
if panel_count > 0 {
summary_opts.panel_cursor += 1
if summary_opts.panel_cursor >= panel_count {
summary_opts.panel_cursor = 0
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
}
}
if button_clicked(new_btn) {
if is_dirty && !shift_down {
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?"))
} else {
push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, false))
}
}
if button_clicked(save_btn) {
push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project"))
}
if button_clicked(open_btn) {
if is_dirty && !shift_down {
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?"))
} else {
push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
}
}
if button_clicked(auto_save_btn) {
push_status(&status_msg, &action_log, run_auto_all_save_action(&controller, &project_path, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at, &last_autosave_at, &last_save_at))
}
if button_clicked(autosave_btn) {
push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled))
}
if button_clicked(help_btn) {
toggle_help_overlay(&show_help_overlay)
}
if button_clicked(clear_field_btn) {
push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
}
if button_clicked(reset_helpers_btn) {
push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format))
}
if controller.active_screen != .Script {
if button_clicked(log_reset_btn) {
push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first))
}
if button_clicked(report_file_btn) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx))
}
if button_clicked(log_copy_btn) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx))
}
if button_clicked(diag_file_btn) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx))
}
if button_clicked(log_clear_btn) {
set_status(&status_msg, clear_action_log_with_message(&action_log))
}
if button_clicked(status_copy_btn) {
push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard"))
}
if button_clicked(diag_copy_btn) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx))
}
}
if controller.active_screen == .Script && button_clicked(script_copy_page_btn) {
page_text := build_script_page_detail_text(controller.state, summary_opts.script_page_cursor)
push_status(&status_msg, &action_log, copy_text_with_status(page_text, "Copied script page detail"))
delete(page_text)
}
if controller.active_screen == .Script && button_clicked(script_copy_all_btn) {
full_text := build_full_script_text(controller.state)
push_status(&status_msg, &action_log, copy_text_with_status(full_text, "Copied full script"))
delete(full_text)
}
if button_clicked(script_btn) {
push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty))
}
if button_clicked(panels_btn) {
push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty))
}
if button_clicked(layout_btn) {
push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty))
}
if button_clicked(export_btn) {
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
}
if button_clicked(next_btn) {
push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
}
if button_clicked(auto_btn) {
push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
}
if rl.IsKeyPressed(.F5) {
push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty))
}
if rl.IsKeyPressed(.F6) {
push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty))
}
if rl.IsKeyPressed(.F7) {
push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty))
}
if rl.IsKeyPressed(.F8) {
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
}
if rl.IsKeyPressed(.F9) {
push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
}
if rl.IsKeyPressed(.F10) {
push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
}
ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)
if ctrl_down && rl.IsKeyPressed(.S) {
push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project"))
}
if ctrl_down && rl.IsKeyPressed(.N) {
if is_dirty && !shift_down {
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?"))
} else {
push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, true))
}
}
if ctrl_down && rl.IsKeyPressed(.O) {
if is_dirty && !shift_down {
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?"))
} else {
push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
}
}
if ctrl_down && rl.IsKeyPressed(.E) {
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
}
if ctrl_down && rl.IsKeyPressed(.G) {
if use_deepseek_script {
use_deepseek_script = false
push_status(&status_msg, &action_log, "Script source: Local")
} else {
if !has_deepseek_key {
push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)")
} else {
use_deepseek_script = true
push_status(&status_msg, &action_log, "Script source: DeepSeek")
}
}
}
if ctrl_down && rl.IsKeyPressed(.H) {
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts))
}
if ctrl_down && rl.IsKeyPressed(.J) {
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts))
}
if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
page_count := len(controller.state.script.pages)
if page_count > 0 {
summary_opts.script_page_cursor -= 1
if summary_opts.script_page_cursor < 0 {
summary_opts.script_page_cursor = page_count - 1
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
}
}
if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
page_count := len(controller.state.script.pages)
if page_count > 0 {
summary_opts.script_page_cursor += 1
if summary_opts.script_page_cursor >= page_count {
summary_opts.script_page_cursor = 0
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
}
}
if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
panel_count := count_script_panels(controller.state.script)
if panel_count > 0 {
summary_opts.panel_cursor -= 1
if summary_opts.panel_cursor < 0 {
summary_opts.panel_cursor = panel_count - 1
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
}
}
if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
panel_count := count_script_panels(controller.state.script)
if panel_count > 0 {
summary_opts.panel_cursor += 1
if summary_opts.panel_cursor >= panel_count {
summary_opts.panel_cursor = 0
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
}
}
if ctrl_down && rl.IsKeyPressed(.MINUS) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs-5))
}
if ctrl_down && rl.IsKeyPressed(.EQUAL) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs+5))
}
if ctrl_down && rl.IsKeyPressed(.SEVEN) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15))
}
if ctrl_down && rl.IsKeyPressed(.EIGHT) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30))
}
if ctrl_down && rl.IsKeyPressed(.NINE) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60))
}
if ctrl_down && rl.IsKeyPressed(.ZERO) {
push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format))
}
if ctrl_down && !shift_down && rl.IsKeyPressed(.L) {
set_status(&status_msg, clear_action_log_with_message(&action_log))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.L) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.Z) {
push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.W) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx))
}
if ctrl_down && rl.IsKeyPressed(.V) {
push_status_if_nonempty(&status_msg, &action_log, paste_clipboard_into_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
}
if ctrl_down && rl.IsKeyPressed(.BACKSPACE) {
push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.I) {
field_text := selected_field_value(selected_field, controller.state, export_path, local_script_pages, project_path, autosave_interval_text)
push_status(&status_msg, &action_log, copy_text_with_status(field_text, "Copied selected field to clipboard"))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.C) {
push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard"))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.Y) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.T) {
push_status(&status_msg, &action_log, toggle_log_lines_with_message(&log_show_lines))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.B) {
push_status(&status_msg, &action_log, toggle_log_order_with_message(&log_oldest_first))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.R) {
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.X) {
push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard"))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.P) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.D) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.G) {
push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.J) {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.K) {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.M) {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.U) {
push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.F) {
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
}
if ctrl_down && shift_down && rl.IsKeyPressed(.A) {
push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled))
}
if !interaction_locked {
for {
ch := rl.GetCharPressed()
if ch == 0 { break }
if ch < 32 || ch > 126 { continue }
if selected_field == 6 && (ch < '0' || ch > '9') {
continue
}
switch selected_field {
case 0: append_char(&controller.state.story_idea, ch)
case 1: append_char(&controller.state.story_genre, ch)
case 2: append_char(&controller.state.target_audience, ch)
case 3: append_char(&export_path, ch)
case 4: append_char(&local_script_pages, ch)
case 5: append_char(&project_path, ch)
case 6: append_char(&autosave_interval_text, ch)
}
is_dirty = true
}
if rl.IsKeyPressed(.BACKSPACE) {
switch selected_field {
case 0: pop_char(&controller.state.story_idea)
case 1: pop_char(&controller.state.story_genre)
case 2: pop_char(&controller.state.target_audience)
case 3: pop_char(&export_path)
case 4: pop_char(&local_script_pages)
case 5: pop_char(&project_path)
case 6: pop_char(&autosave_interval_text)
}
is_dirty = true
}
}
}
push_status_if_nonempty(&status_msg, &action_log, autosave_tick_with_message(&project_path, controller.state, autosave_enabled, &is_dirty, &last_autosave_at, &last_save_at, autosave_interval_s))
rl.BeginDrawing()
rl.ClearBackground(BG_BASE)
screen_w := rl.GetScreenWidth()
screen_h := rl.GetScreenHeight()
main_w := screen_w - 282 - 20
if main_w < 960 {
main_w = 960
}
rl.DrawRectangle(0, 0, 260, screen_h, BG_SIDEBAR)
rl.DrawRectangle(260, 0, screen_w-260, 72, BG_TOPBAR)
rl.DrawLine(260, 72, screen_w, 72, BORDER_DIVIDER)
rl.DrawLine(260, 0, 260, screen_h, BORDER_DIVIDER)
draw_card(rl.Rectangle{x = 14, y = 10, width = 236, height = 64})
rl.DrawText("comic-odin", 24, 22, 23, BRAND_TITLE)
rl.DrawText("Native GUI", 24, 46, 14, BRAND_SUBTITLE)
draw_section_title(24, 68, "Screens")
draw_card(rl.Rectangle{x = 14, y = 86, width = 236, height = 296})
draw_nav_item(nav_story, "1 Story", controller.active_screen == .Story)
draw_nav_item(nav_script, "2 Script", controller.active_screen == .Script)
draw_nav_item(nav_chars, "3 Characters", controller.active_screen == .Characters)
draw_nav_item(nav_panels, "4 Panels", controller.active_screen == .Panels)
draw_nav_item(nav_layout, "5 Layout", controller.active_screen == .Layout)
draw_nav_item(nav_bubbles, "6 Bubbles", controller.active_screen == .Bubbles)
draw_nav_item(nav_export, "7 Export", controller.active_screen == .Export)
draw_nav_item(nav_community, "8 Community", controller.active_screen == .Community)
draw_sidebar_shortcuts(screen_h_loop)
// --- Pipeline Stepper in Topbar ---
draw_card(rl.Rectangle{x = 282, y = 12, width = f32(main_w), height = 48})
rl.DrawText(fmt.ctprintf("%s", ui.screen_name(controller.active_screen)), 300, 26, 20, BRAND_TITLE)
script_ok := len(controller.state.script.pages) > 0
panels_ok := len(controller.state.panel_images) > 0
layout_ok := len(controller.state.page_layouts) > 0
export_ok := panels_ok && layout_ok
step_x := i32(460)
step_y := i32(36)
step_spacing := i32(140)
// Draw connecting lines first
for i in 0..<3 {
x1 := step_x + i32(i)*step_spacing + 24
x2 := step_x + i32(i+1)*step_spacing - 24
c := STEP_LINE_TODO
if (i == 0 && panels_ok) || (i == 1 && layout_ok) || (i == 2 && export_ok) {
c = STEP_LINE_DONE
}
rl.DrawLineEx(rl.Vector2{f32(x1), f32(step_y)}, rl.Vector2{f32(x2), f32(step_y)}, 2.0, c)
}
// Helper to draw a single pipeline step
draw_step := proc(x, y: i32, label: string, done: bool) {
fill := STEP_TODO_FILL
border := STEP_TODO_BORDER
text_col := STEP_LABEL_TODO
if done {
fill = STEP_DONE_FILL
border = STEP_DONE_BORDER
text_col = STEP_LABEL_DONE
}
rl.DrawCircle(x, y, 10, fill)
rl.DrawCircleLines(x, y, 10, border)
// Draw checkmark if done
if done {
rl.DrawLineEx(rl.Vector2{f32(x-3), f32(y+1)}, rl.Vector2{f32(x-1), f32(y+4)}, 2.0, BG_BASE)
rl.DrawLineEx(rl.Vector2{f32(x-1), f32(y+4)}, rl.Vector2{f32(x+4), f32(y-3)}, 2.0, BG_BASE)
}
draw_text_fitted(label, x - 24, y + 16, 14, 48, 7, text_col)
}
draw_step(step_x, step_y, "Script", script_ok)
draw_step(step_x + step_spacing, step_y, "Panels", panels_ok)
draw_step(step_x + step_spacing*2, step_y, "Layout", layout_ok)
draw_step(step_x + step_spacing*3, step_y, "Export", export_ok)
// Pages input on far right of topbar
rl.DrawText("Local script pages", screen_w_loop - 250, 26, 16, TEXT_SECONDARY)
draw_input_field(pages_rec, local_script_pages, selected_field == 4)
draw_card(rl.Rectangle{x = 282, y = 82, width = f32(main_w), height = 356})
draw_card(rl.Rectangle{x = 282, y = 460, width = f32(main_w), height = 110})
draw_section_title(300, 92, "Project Setup")
draw_section_title(300, 470, "Actions")
rl.DrawText("Story Idea", 290, 96, 16, TEXT_SECONDARY)
draw_input_field(idea_rec, controller.state.story_idea, selected_field == 0)
rl.DrawText("Genre", 290, 152, 16, TEXT_SECONDARY)
draw_input_field(genre_rec, controller.state.story_genre, selected_field == 1)
rl.DrawText("Audience", 290, 208, 16, TEXT_SECONDARY)
draw_input_field(audience_rec, controller.state.target_audience, selected_field == 2)
rl.DrawText("Export Path", 290, 264, 16, TEXT_SECONDARY)
draw_input_field(export_rec, export_path, selected_field == 3)
rl.DrawText("Project Path", 290, 320, 16, TEXT_SECONDARY)
draw_input_field(project_rec, project_path, selected_field == 5)
// Compact utility row below inputs
draw_small_button(export_copy_btn, "Copy Export")
draw_small_button(export_preset_btn, "Preset Ext")
draw_small_button(path_fix_btn, "Fix Exp Ext")
draw_small_button(project_fix_btn, "Fix Proj Ext")
draw_small_button(project_from_export_btn, "Proj From Exp")
draw_small_button(export_project_btn, "Exp From Proj")
rl.DrawText("Format", 290, 404, 16, TEXT_SECONDARY)
draw_nav_item(fmt_pdf_btn, "PDF", export_format == .PDF)
draw_nav_item(fmt_png_btn, "PNG", export_format == .PNG)
draw_nav_item(fmt_cbz_btn, "CBZ", export_format == .CBZ)
rl.DrawText("Script Source", 700, 404, 16, TEXT_SECONDARY)
draw_nav_item(script_src_local_btn, "Local", !use_deepseek_script)
draw_nav_item(script_src_deepseek_btn, "DeepSeek", use_deepseek_script)
if !has_deepseek_key {
draw_summary_subline(1024, 408, "set DEEPSEEK_API_KEY", KEY_MISSING_COLOR)
}
next_hint := gui_next_hint_with_source(controller, use_deepseek_script)
recommended_label := recommended_label_from_hint(next_hint)
script_btn_label := "Generate Script Local"
if use_deepseek_script {
script_btn_label = "Generate Script"
}
draw_button_warning(new_btn, "New Project")
if recommended_label == "Generate Script" || recommended_label == "Generate Script Local" {
draw_button_recommended(script_btn, script_btn_label)
} else {
draw_button(script_btn, script_btn_label)
}
if recommended_label == "Generate Panels Local" {
draw_button_recommended(panels_btn, "Generate Panels Local")
} else {
draw_button_state(panels_btn, "Generate Panels Local", can_generate_panels)
}
if recommended_label == "Layout" {
draw_button_recommended(layout_btn, "Layout Pages")
} else {
draw_button_state(layout_btn, "Layout Pages", can_layout)
}
if recommended_label == "Export" {
draw_button_recommended(export_btn, "Export")
} else {
draw_button_state(export_btn, "Export", can_export)
}
draw_button_soft_accent(save_btn, "Save")
draw_button_soft_accent(open_btn, "Open")
draw_button_primary(next_btn, "Next Step")
draw_button_primary(auto_btn, "Auto-All")
draw_button_primary(auto_save_btn, "Auto-All + Save")
draw_button_soft_accent(autosave_btn, autosave_enabled ? "Autosave: yes" : "Autosave: no")
rl.DrawText("Interval(s)", 500, 574, 16, TEXT_SECONDARY)
draw_input_field(autosave_rec, autosave_interval_text, selected_field == 6)
draw_small_button(autosave_15_btn, "15")
draw_small_button(autosave_30_btn, "30")
draw_small_button(autosave_60_btn, "60")
draw_button_soft_accent(help_btn, "Help (/)")
draw_button(clear_field_btn, "Clear Field")
draw_button_soft_accent(reset_helpers_btn, "Reset Helpers")
label := "idea"
if selected_field == 1 { label = "genre" }
if selected_field == 2 { label = "audience" }
if selected_field == 3 { label = "export path" }
if selected_field == 4 { label = "local pages" }
if selected_field == 5 { label = "project path" }
if selected_field == 6 { label = "autosave interval" }
if !compact_mode {
hint_msg := button_readiness_hint(rl.GetMousePosition(), panels_btn, layout_btn, export_btn, can_generate_panels, can_layout, can_export)
if len(hint_msg) > 0 {
draw_hint_pill(rl.Rectangle{x = 1000, y = 580, width = 210, height = 22}, fmt.tprintf("%s", hint_msg), true)
}
}
draw_card(rl.Rectangle{x = 282, y = f32(lower_y_loop-140), width = f32(status_w_loop), height = 160})
now_draw := rl.GetTime()
status_y := lower_y_loop - 126
rl.DrawText("Status", 300, status_y, 19, BRAND_TITLE)
rl.DrawLine(370, status_y+10, i32(282+status_w_loop)-14, status_y+10, BORDER_DIVIDER)
draw_text_fitted(status_msg, 300, status_y+26, 18, int(status_w_loop-36), 8, status_text_color(status_msg))
draw_readiness_row(controller, 300, status_y+50)
ready_count, total_count := ready_stage_count(controller)
progress := f32(0)
if total_count > 0 {
progress = f32(ready_count) / f32(total_count)
}
draw_progress_bar(300, status_y+84, status_w_loop-26, progress)
draw_text_fitted(fmt.tprintf("Pipeline: %d/%d", ready_count, total_count), 300, status_y+102, 14, 122, 7, TEXT_TERTIARY)
draw_text_fitted(fmt.tprintf("Next: %s", gui_next_hint_with_source(controller, use_deepseek_script)), 430, status_y+102, 14, int(status_w_loop-166), 7, TEXT_SECONDARY)
draw_status_badge(rl.Rectangle{x = 300, y = f32(status_y+120), width = 118, height = 22}, fmt.tprintf("Dirty: %s", yn(is_dirty)), !is_dirty)
draw_status_badge(rl.Rectangle{x = 430, y = f32(status_y+120), width = 188, height = 22}, fmt.tprintf("Autosave: %s (%ds)", yn(autosave_enabled), autosave_secs), autosave_enabled)
save_meta := "save: never"
if last_save_at >= 0 {
save_meta = fmt.tprintf("save: %.0fs", now_draw-last_save_at)
}
export_meta := "export: never"
if last_export_at >= 0 {
export_meta = fmt.tprintf("export: %.0fs", now_draw-last_export_at)
}
draw_text_fitted(fmt.tprintf("%s | %s", save_meta, export_meta), 630, status_y+124, 13, int(status_w_loop-26), 7, TEXT_TERTIARY)
path_fix_project_status_btn.y = f32(status_y + 114)
path_fix_export_status_btn.y = f32(status_y + 114)
path_fix_all_status_btn.y = f32(status_y + 114)
draw_small_button_state(path_fix_project_status_btn, "P", !project_path_ok)
draw_small_button_state(path_fix_export_status_btn, "E", !export_path_ok)
draw_small_button_state(path_fix_all_status_btn, "PE", !project_path_ok || !export_path_ok)
draw_screen_summary(controller, export_path, 300, lower_y_loop+12, status_w_loop-8, summary_opts)
if controller.active_screen == .Script || controller.active_screen == .Layout || controller.active_screen == .Panels {
if controller.active_screen == .Script || controller.active_screen == .Layout {
show_txt := "Top"
sort_txt := "Asc"
if controller.active_screen == .Script {
if summary_opts.script_show_all { show_txt = "All" }
if summary_opts.script_desc { sort_txt = "Desc" }
} else {
if summary_opts.layout_show_all { show_txt = "All" }
if summary_opts.layout_desc { sort_txt = "Desc" }
}
show_btn_label := "Show:Top"
if show_txt == "All" {
show_btn_label = "Show:All"
}
sort_btn_label := "Sort:Asc"
if sort_txt == "Desc" {
sort_btn_label = "Sort:Desc"
}
draw_small_button(summary_show_btn, show_btn_label)
draw_small_button(summary_sort_btn, sort_btn_label)
}
if controller.active_screen == .Script {
draw_small_button(summary_prev_btn, "< Pg")
draw_small_button(summary_next_btn, "Pg >")
} else if controller.active_screen == .Panels {
draw_small_button(summary_prev_btn, "< Pn")
draw_small_button(summary_next_btn, "Pn >")
}
if !compact_mode {
hint_label := "Ctrl+[ / Ctrl+]"
if controller.active_screen == .Script || controller.active_screen == .Layout {
hint_label = "Ctrl+H / Ctrl+J"
}
draw_hint_pill(rl.Rectangle{x = f32(282 + status_w_loop - 186), y = f32(lower_y_loop + 46), width = 172, height = 20}, hint_label, false)
}
}
if !compact_mode {
draw_hint_pill(rl.Rectangle{x = 300, y = f32(lower_y_loop + 206), width = f32(status_w_loop-18), height = 24}, "Tip: New/Open show confirm modal when dirty (Shift still quick-confirms)", true)
}
if controller.active_screen == .Script {
draw_script_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.script_page_cursor)
draw_small_button(script_copy_page_btn, "Copy Page")
draw_small_button(script_copy_all_btn, "Copy All")
draw_text_fitted("Ctrl+[ / Ctrl+] page nav", log_x_loop+216, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-228), 7, TEXT_TERTIARY)
} else if controller.active_screen == .Panels {
retry, new_cursor := draw_panels_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.panel_cursor)
panel_count := count_script_panels(controller.state.script)
if summary_opts.panel_cursor != new_cursor {
summary_opts.panel_cursor = new_cursor
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
}
if retry {
panel, _, ok := panel_by_flat_index(controller.state.script, summary_opts.panel_cursor)
if ok {
msg := action_regenerate_panel(&controller, panel.panel_id)
push_status(&status_msg, &action_log, msg)
}
}
wheel := rl.GetMouseWheelMove()
if wheel != 0 && panel_count > 0 {
summary_opts.panel_cursor -= int(wheel)
if summary_opts.panel_cursor < 0 {
summary_opts.panel_cursor = 0
}
if summary_opts.panel_cursor >= panel_count {
summary_opts.panel_cursor = panel_count - 1
}
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
}
draw_text_fitted("Ctrl+[ / Ctrl+] panel nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY)
} else {
draw_card(rl.Rectangle{x = f32(log_x_loop), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-2), height = 200})
draw_section_title(log_x_loop+18, lower_y_loop+6, "Action Log")
draw_subtle_strip(rl.Rectangle{x = f32(log_x_loop+12), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-26), height = 40})
draw_small_button(log_reset_btn, "Default")
draw_small_button(report_file_btn, "Report")
draw_small_button(log_copy_btn, "LogCopy")
draw_small_button(diag_file_btn, "DiagFile")
draw_small_button(status_copy_btn, "Copy")
draw_small_button(log_clear_btn, "Clear")
draw_small_button(diag_copy_btn, "Diag")
order_label := "newest"
if log_oldest_first {
order_label = "oldest"
}
draw_text_fitted(fmt.tprintf("View: %d lines, %s first", log_show_lines, order_label), log_x_loop+18, lower_y_loop+36, 13, 216, 7, TEXT_TERTIARY)
draw_action_log(action_log, log_x_loop+18, lower_y_loop+52, log_show_lines, log_oldest_first)
}
draw_toast(action_log, log_x_loop+8, 70, main_w_loop-status_w_loop-18)
if show_help_overlay {
draw_help_overlay()
}
if show_confirm_overlay {
draw_confirm_overlay(pending_confirm_action)
draw_button_danger(confirm_yes_btn, "Confirm")
draw_button_soft_accent(confirm_no_btn, "Cancel")
}
rl.EndDrawing()
}
core.dispose_state(state)
state^ = controller.state
controller.state = core.Comic_State{}
return shared.ok()
}

View File

@ -0,0 +1,212 @@
package gui
import "core:fmt"
import rl "vendor:raylib"
import "../core"
import "../shared"
import "../ui"
set_status :: proc(status: ^string, msg: string) {
delete(status^)
status^ = fmt.aprintf("%s", msg)
}
push_status :: proc(status: ^string, log: ^Action_Log, msg: string) {
set_status(status, msg)
action_log_push(log, status^)
}
push_status_if_nonempty :: proc(status: ^string, log: ^Action_Log, msg: string) {
if len(msg) > 0 {
push_status(status, log, msg)
}
}
push_dirty_status :: proc(is_dirty: ^bool, status: ^string, log: ^Action_Log, msg: string) {
is_dirty^ = true
push_status(status, log, msg)
}
screen_status_label :: proc(screen: ui.App_Screen) -> string {
switch screen {
case .Story: return "Story"
case .Script: return "Script"
case .Characters: return "Characters"
case .Panels: return "Panels"
case .Layout: return "Layout"
case .Bubbles: return "Bubbles"
case .Export: return "Export"
case .Community: return "Community"
}
return "Unknown"
}
navigate_screen_with_status :: proc(controller: ^ui.App_Controller, screen: ui.App_Screen) -> string {
err := ui.navigate_to_screen(controller, screen)
if !shared.is_ok(err) {
return err.message
}
return fmt.aprintf("Screen: %s", screen_status_label(screen))
}
request_confirmation :: proc(show_confirm_overlay, show_help_overlay: ^bool, pending_confirm_action: ^Pending_Confirm_Action, action: Pending_Confirm_Action, prompt: string) -> string {
show_confirm_overlay^ = true
show_help_overlay^ = false
pending_confirm_action^ = action
return prompt
}
toggle_autosave_with_message :: proc(autosave_enabled: ^bool) -> string {
autosave_enabled^ = !autosave_enabled^
return fmt.aprintf("Autosave: %s", yn(autosave_enabled^))
}
reset_helper_fields_with_message :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) -> string {
reset_helper_fields(export_path, local_script_pages, autosave_interval_text, f)
return "Reset helper fields to defaults"
}
toggle_help_overlay :: proc(show_help_overlay: ^bool) {
show_help_overlay^ = !show_help_overlay^
}
close_help_overlay_if_open :: proc(show_help_overlay: ^bool) {
if show_help_overlay^ {
show_help_overlay^ = false
}
}
clear_action_log_with_message :: proc(log: ^Action_Log) -> string {
action_log_dispose(log)
return "Action log cleared"
}
reset_log_view :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) {
log_show_lines^ = 6
log_oldest_first^ = false
}
reset_log_view_with_message :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) -> string {
reset_log_view(log_show_lines, log_oldest_first)
return "Reset log view"
}
toggle_log_lines_with_message :: proc(log_show_lines: ^i32) -> string {
if log_show_lines^ == 6 {
log_show_lines^ = 4
} else {
log_show_lines^ = 6
}
return fmt.aprintf("Log lines: %d", log_show_lines^)
}
toggle_log_order_with_message :: proc(log_oldest_first: ^bool) -> string {
log_oldest_first^ = !log_oldest_first^
order := "newest"
if log_oldest_first^ {
order = "oldest"
}
return fmt.aprintf("Log order: %s first", order)
}
selected_field_value :: proc(selected_field: int, state: core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: string) -> string {
switch selected_field {
case 0: return state.story_idea
case 1: return state.story_genre
case 2: return state.target_audience
case 3: return export_path
case 4: return local_script_pages
case 5: return project_path
case 6: return autosave_interval_text
}
return ""
}
clear_selected_field :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string) -> bool {
switch selected_field {
case 0:
if len(state.story_idea) == 0 { return false }
state.story_idea = ""
case 1:
if len(state.story_genre) == 0 { return false }
state.story_genre = ""
case 2:
if len(state.target_audience) == 0 { return false }
state.target_audience = ""
case 3:
if len(export_path^) == 0 { return false }
export_path^ = ""
case 4:
if len(local_script_pages^) == 0 { return false }
local_script_pages^ = ""
case 5:
if len(project_path^) == 0 { return false }
project_path^ = ""
case 6:
if len(autosave_interval_text^) == 0 { return false }
autosave_interval_text^ = ""
case:
return false
}
return true
}
clear_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string {
if clear_selected_field(selected_field, state, export_path, local_script_pages, project_path, autosave_interval_text) {
is_dirty^ = true
return "Cleared selected field"
}
return "Selected field already empty"
}
paste_clipboard_into_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string {
clip_raw := rl.GetClipboardText()
if clip_raw == nil {
return ""
}
clip_text := fmt.aprintf("%s", clip_raw)
switch selected_field {
case 0: state.story_idea = clip_text
case 1: state.story_genre = clip_text
case 2: state.target_audience = clip_text
case 3: export_path^ = clip_text
case 4: local_script_pages^ = clip_text
case 5: project_path^ = clip_text
case 6: autosave_interval_text^ = clip_text
}
is_dirty^ = true
return "Pasted clipboard into selected field"
}
Action_Log :: struct {
entries: [8]string,
owned: [8]bool,
entry_times: [8]f64,
count: int,
last_push_at: f64,
}
action_log_push :: proc(log: ^Action_Log, msg: string) {
now := rl.GetTime()
idx := log.count % len(log.entries)
if log.owned[idx] {
delete(log.entries[idx])
}
log.entries[idx] = fmt.aprintf("%s", msg)
log.owned[idx] = true
log.entry_times[idx] = now
log.count += 1
log.last_push_at = now
}
action_log_dispose :: proc(log: ^Action_Log) {
for i in 0..<len(log.entries) {
if log.owned[i] {
delete(log.entries[i])
log.owned[i] = false
}
log.entry_times[i] = 0
}
log.count = 0
log.last_push_at = 0
}

View File

@ -0,0 +1,443 @@
package gui
import "core:fmt"
import rl "vendor:raylib"
import "../core"
import "../ui"
draw_stat_chip :: proc(x, y: i32, label: string, value: int) {
rec := rl.Rectangle{x = f32(x), y = f32(y), width = 80, height = 28}
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, CHIP_BG)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, CHIP_BORDER)
draw_text_fitted(label, x+10, y+8, 12, 40, 6, CHIP_TEXT)
draw_text_fitted(fmt.tprintf("%d", value), x+56, y+6, 14, 20, 7, TEXT_PRIMARY)
}
draw_readiness_chip :: proc(x, y, w: i32, label: string, ok: bool) {
bg := UNREADY_BG
border := UNREADY_BORDER
fg := UNREADY_TEXT
if ok {
bg = READY_BG
border = READY_BORDER
fg = READY_TEXT
}
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 26}
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, border)
prefix := "○"
if ok {
prefix = "●"
}
draw_text_fitted(fmt.tprintf("%s %s", prefix, label), x+10, y+7, 12, int(w-20), 6, fg)
}
ready_stage_count :: proc(controller: ui.App_Controller) -> (ready: int, total: int) {
script_ok := len(controller.state.script.pages) > 0
panels_ok := len(controller.state.panel_images) > 0
layout_ok := len(controller.state.page_layouts) > 0
export_ok := panels_ok && layout_ok
ready = 0
if script_ok { ready += 1 }
if panels_ok { ready += 1 }
if layout_ok { ready += 1 }
if export_ok { ready += 1 }
return ready, 4
}
draw_progress_bar :: proc(x, y, w: i32, progress: f32) {
track := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 12}
rl.DrawRectangleRounded(track, RADIUS_BAR, 8, PROGRESS_TRACK)
fill_w := i32(f32(w) * progress)
if fill_w > 0 {
fill := rl.Rectangle{x = f32(x), y = f32(y), width = f32(fill_w), height = 12}
rl.DrawRectangleRounded(fill, RADIUS_BAR, 8, PROGRESS_FILL)
}
}
draw_readiness_row :: proc(controller: ui.App_Controller, x, y: i32) {
script_ok := len(controller.state.script.pages) > 0
panels_ok := len(controller.state.panel_images) > 0
layout_ok := len(controller.state.page_layouts) > 0
export_ok := panels_ok && layout_ok
draw_readiness_chip(x, y, 120, "Script", script_ok)
draw_readiness_chip(x+126, y, 120, "Panels", panels_ok)
draw_readiness_chip(x+252, y, 120, "Layout", layout_ok)
draw_readiness_chip(x+378, y, 120, "Export", export_ok)
}
export_block_reason :: proc(state: core.Comic_State) -> string {
if len(state.panel_images) == 0 && len(state.page_layouts) == 0 {
return "need panels + layout"
}
if len(state.panel_images) == 0 {
return "need panels"
}
if len(state.page_layouts) == 0 {
return "need layout"
}
return ""
}
draw_screen_summary :: proc(controller: ui.App_Controller, export_path: string, x, y, w: i32, opts: Summary_View_Options) {
draw_card(rl.Rectangle{x = f32(x-18), y = f32(y-12), width = f32(w), height = 200})
rl.DrawText("Screen Summary", x, y, 22, SUMMARY_TITLE)
chip_base := x + w - 258
if chip_base < x+210 {
chip_base = x + 210
}
draw_stat_chip(chip_base, y-4, "Pages", len(controller.state.script.pages))
draw_stat_chip(chip_base+86, y-4, "Panels", len(controller.state.panel_images))
draw_stat_chip(chip_base+172, y-4, "Layout", len(controller.state.page_layouts))
switch controller.active_screen {
case .Story:
draw_summary_line(x, y+30, fmt.tprintf("Idea length: %d chars", len(controller.state.story_idea)), rl.DARKGRAY)
draw_summary_line(x, y+54, fmt.tprintf("Genre: %s", controller.state.story_genre), rl.DARKGRAY)
draw_summary_line(x, y+78, fmt.tprintf("Audience: %s", controller.state.target_audience), rl.DARKGRAY)
rl.DrawText("Use Generate Script Local to begin", x, y+112, 18, SUMMARY_HINT)
case .Script:
draw_summary_line(x, y+30, fmt.tprintf("Title: %s", controller.state.script.title), rl.DARKGRAY)
page_count := len(controller.state.script.pages)
draw_summary_line(x, y+54, fmt.tprintf("Pages: %d | Characters: %d", page_count, len(controller.state.script.characters)), rl.DARKGRAY)
if page_count == 0 {
rl.DrawText("No script pages yet. Generate Script Local to continue.", x, y+86, 18, SUMMARY_HINT)
} else {
cursor := opts.script_page_cursor
if cursor < 0 {
cursor = 0
}
if cursor >= page_count {
cursor = page_count - 1
}
page := controller.state.script.pages[cursor]
draw_summary_line(x, y+78, fmt.tprintf("Viewing page %d/%d (script page #%d)", cursor+1, page_count, page.page_number), SUMMARY_ACCENT)
draw_summary_line(x, y+100, fmt.tprintf("Panels on page: %d", len(page.panels)), rl.DARKGRAY)
line_y := y + 124
show_panels := len(page.panels)
if show_panels > 2 {
show_panels = 2
}
for i in 0..<show_panels {
pn := page.panels[i]
desc := pn.description
if len(desc) == 0 {
desc = "(no description)"
}
draw_summary_subline(x, line_y+i32(i*28), fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-36), 7), SUMMARY_SUBLINE)
if len(pn.dialogue) > 0 {
draw_summary_subline(x+12, line_y+i32(i*28)+14, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-54), 7), SUMMARY_DIM)
}
}
if len(page.panels) > show_panels {
draw_summary_subline(x+w-158, y+166, fmt.tprintf("+%d more panels", len(page.panels)-show_panels), SUMMARY_ACCENT)
}
}
case .Characters:
draw_summary_line(x, y+30, fmt.tprintf("Character count: %d", len(controller.state.characters)), rl.DARKGRAY)
rl.DrawText("Character editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
rl.DrawText("Use script generation to populate", x, y+78, 18, rl.DARKGRAY)
case .Panels:
script_panel_count := count_script_panels(controller.state.script)
draw_summary_line(x, y+30, fmt.tprintf("Panel images: %d", len(controller.state.panel_images)), rl.DARKGRAY)
draw_summary_line(x, y+54, fmt.tprintf("Script panels: %d", script_panel_count), rl.DARKGRAY)
draw_summary_line(x, y+78, fmt.tprintf("Script pages: %d", len(controller.state.script.pages)), rl.DARKGRAY)
if script_panel_count == 0 {
rl.DrawText("No script panels yet. Generate Script first.", x, y+112, 18, SUMMARY_HINT)
} else {
pidx := clamp_panel_cursor(script_panel_count, opts.panel_cursor)
panel, page_num, _ := panel_by_flat_index(controller.state.script, pidx)
status := "missing"
if _, has_img := controller.state.panel_images[panel.panel_id]; has_img {
status = "ready"
}
draw_summary_line(x, y+102, fmt.tprintf("Viewing panel %d/%d • page %d # %d", pidx+1, script_panel_count, page_num, panel.panel_number), SUMMARY_ACCENT)
draw_summary_subline(x, y+124, fmt.tprintf("%s • %s", panel.panel_id, status), SUMMARY_SUBLINE)
}
case .Layout:
draw_summary_line(x, y+30, fmt.tprintf("Layout pages: %d", len(controller.state.page_layouts)), rl.DARKGRAY)
draw_summary_line(x, y+54, fmt.tprintf("Page size: %v", controller.state.page_size), rl.DARKGRAY)
layout_show := len(controller.state.page_layouts)
if !opts.layout_show_all && layout_show > 3 { layout_show = 3 }
if layout_show == 0 {
rl.DrawText("No layouts yet. Use Layout Auto after panels are ready.", x, y+86, 18, SUMMARY_HINT)
} else {
if opts.layout_desc {
for i in 0..<layout_show {
idx := len(controller.state.page_layouts)-1-i
l := controller.state.page_layouts[idx]
draw_summary_line(x, y+78+i32(i*22), fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels)), rl.DARKGRAY)
}
} else {
for i in 0..<layout_show {
l := controller.state.page_layouts[i]
draw_summary_line(x, y+78+i32(i*22), fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels)), rl.DARKGRAY)
}
}
}
case .Bubbles:
draw_summary_line(x, y+30, fmt.tprintf("Bubble maps: %d", len(controller.state.speech_bubbles)), rl.DARKGRAY)
rl.DrawText("Bubble editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
case .Export:
draw_summary_line(x, y+30, fmt.tprintf("Format: %v", controller.state.export_format), rl.DARKGRAY)
draw_summary_line(x, y+54, fmt.tprintf("Layouts: %d | Panels: %d", len(controller.state.page_layouts), len(controller.state.panel_images)), rl.DARKGRAY)
draw_summary_line(x, y+78, fmt.tprintf("Target: %s", export_path), rl.DARKGRAY)
if len(controller.state.page_layouts) > 0 {
last := controller.state.page_layouts[len(controller.state.page_layouts)-1]
draw_summary_subline(x, y+102, fmt.tprintf("Last layout pattern: %s", last.pattern_id), SUMMARY_DIM)
}
reason := export_block_reason(controller.state)
if len(reason) > 0 {
draw_summary_line(x, y+124, fmt.tprintf("Export blocked: %s", reason), ERROR)
} else {
rl.DrawText("Use Export button or Ctrl+E", x, y+124, 18, SUMMARY_HINT)
}
case .Community:
rl.DrawText("Community features coming soon", x, y+30, 18, rl.DARKGRAY)
rl.DrawText("Current focus: local GUI workflows", x, y+54, 18, rl.DARKGRAY)
}
}
clamp_script_cursor :: proc(page_count, cursor: int) -> int {
if page_count <= 0 {
return 0
}
if cursor < 0 {
return 0
}
if cursor >= page_count {
return page_count - 1
}
return cursor
}
clamp_panel_cursor :: proc(panel_count, cursor: int) -> int {
if panel_count <= 0 {
return 0
}
if cursor < 0 {
return 0
}
if cursor >= panel_count {
return panel_count - 1
}
return cursor
}
panel_by_flat_index :: proc(script: core.Comic_Script, panel_idx: int) -> (core.Panel, int, bool) {
if panel_idx < 0 {
return core.Panel{}, 0, false
}
flat := 0
for page in script.pages {
for panel in page.panels {
if flat == panel_idx {
return panel, page.page_number, true
}
flat += 1
}
}
return core.Panel{}, 0, false
}
build_script_page_detail_text :: proc(state: core.Comic_State, cursor: int) -> string {
page_count := len(state.script.pages)
if page_count == 0 {
return fmt.aprintf("No script pages available.")
}
idx := clamp_script_cursor(page_count, cursor)
page := state.script.pages[idx]
out := fmt.aprintf("Title: %s\nPage %d/%d (script page #%d)\nPanels: %d", state.script.title, idx+1, page_count, page.page_number, len(page.panels))
for pn in page.panels {
desc := pn.description
if len(desc) == 0 {
desc = "(no description)"
}
next := fmt.aprintf("%s\n\nPanel %d [%v]\n%s", out, pn.panel_number, pn.shot_type, desc)
delete(out)
out = next
for d in pn.dialogue {
line := fmt.aprintf("%s\n- %s: %s", out, d.speaker_id, d.text)
delete(out)
out = line
}
if len(pn.caption) > 0 {
line := fmt.aprintf("%s\n caption: %s", out, pn.caption)
delete(out)
out = line
}
}
return out
}
build_full_script_text :: proc(state: core.Comic_State) -> string {
page_count := len(state.script.pages)
if page_count == 0 {
return fmt.aprintf("No script pages available.")
}
out := fmt.aprintf("Title: %s\nSynopsis: %s\nCharacters: %d\nPages: %d", state.script.title, state.script.synopsis, len(state.script.characters), page_count)
for page in state.script.pages {
head := fmt.aprintf("%s\n\n=== Page %d (%d panels) ===", out, page.page_number, len(page.panels))
delete(out)
out = head
for pn in page.panels {
desc := pn.description
if len(desc) == 0 {
desc = "(no description)"
}
row := fmt.aprintf("%s\nPanel %d: %s", out, pn.panel_number, desc)
delete(out)
out = row
}
}
return out
}
draw_script_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) {
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
draw_section_title(x+18, y+6, "Script Detail")
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
page_count := len(controller.state.script.pages)
if page_count == 0 {
draw_summary_line(x+18, y+46, "No script pages yet. Run Generate Script Local.", SUMMARY_HINT)
return
}
idx := clamp_script_cursor(page_count, cursor)
page := controller.state.script.pages[idx]
draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d (#%d) • panels %d", idx+1, page_count, page.page_number, len(page.panels)), SUMMARY_ACCENT)
line_y := y + 70
line_step: i32 = 20
line_max: i32 = (h - 84) / line_step
lines_used: i32 = 0
for pn in page.panels {
if lines_used >= line_max {
break
}
desc := pn.description
if len(desc) == 0 {
desc = "(no description)"
}
draw_summary_subline(x+18, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-40), 7), SUMMARY_SUBLINE)
lines_used += 1
if len(pn.dialogue) > 0 && lines_used < line_max {
draw_summary_subline(x+30, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-52), 7), SUMMARY_DIM)
lines_used += 1
}
}
if len(page.panels) > 0 && lines_used >= line_max {
draw_summary_subline(x+w-140, y+h-18, "…more", SUMMARY_ACCENT)
}
}
draw_panels_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) -> (retry_clicked: bool, new_cursor: int) {
new_cursor = cursor
retry_clicked = false
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
draw_section_title(x+18, y+6, "Panel Results")
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
panel_count := count_script_panels(controller.state.script)
if panel_count == 0 {
draw_summary_line(x+18, y+46, "No script panels yet. Generate Script first.", SUMMARY_HINT)
return
}
idx := clamp_panel_cursor(panel_count, cursor)
panel, page_num, ok := panel_by_flat_index(controller.state.script, idx)
if !ok {
draw_summary_line(x+18, y+46, "Panel index out of range.", ERROR)
return
}
img, has_img := controller.state.panel_images[panel.panel_id]
err_msg, has_err := controller.state.panel_errors[panel.panel_id]
status := "missing"
status_color := WARNING
if has_err {
status = "error"
status_color = ERROR
}
if has_img {
status = "ready"
status_color = SUCCESS
}
draw_summary_line(x+18, y+46, fmt.tprintf("Panel %d/%d • page %d # %d • %s", idx+1, panel_count, page_num, panel.panel_number, status), status_color)
btn_label := "Regenerate"
if status != "ready" {
btn_label = "Generate"
}
btn_rec := rl.Rectangle{x = f32(x + w - 100), y = f32(y+42), width = 80, height = 24}
draw_small_button_state(btn_rec, btn_label, true)
if button_clicked(btn_rec) {
retry_clicked = true
}
draw_summary_subline(x+18, y+66, fit_text_for_width(fmt.tprintf("id: %s", panel.panel_id), int(w-120), 7), SUMMARY_SUBLINE)
if has_err {
draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("err: %s", err_msg), int(w-36), 7), ERROR)
} else if has_img {
draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("img: %dx%d seed:%d", img.width, img.height, img.seed), int(w-36), 7), SUMMARY_DIM)
} else {
draw_summary_subline(x+18, y+84, "img: not generated", SUMMARY_DIM)
}
desc := panel.description
if len(desc) == 0 {
desc = "(no description)"
}
draw_summary_subline(x+18, y+104, fit_text_for_width(fmt.tprintf("desc: %s", desc), int(w-36), 7), SUMMARY_SUBLINE)
if has_img {
draw_summary_subline(x+18, y+124, fit_text_for_width(fmt.tprintf("src: %s", img.url), int(w-36), 7), SUMMARY_DIM)
}
list_y := y + 146
row_h: i32 = 18
rows: i32 = (h - 154) / row_h
if rows < 1 {
rows = 1
}
start := idx - int(rows/2)
if start < 0 {
start = 0
}
end := start + int(rows)
if end > panel_count {
end = panel_count
start = end - int(rows)
if start < 0 {
start = 0
}
}
line: i32 = 0
for i in start..<end {
row_panel, row_page, row_ok := panel_by_flat_index(controller.state.script, i)
if !row_ok {
continue
}
mark := " "
row_color := TEXT_TERTIARY
if i == idx {
mark = ">"
row_color = TEXT_PRIMARY
}
ready := "missing"
if _, err_exists := controller.state.panel_errors[row_panel.panel_id]; err_exists {
ready = "error"
}
if _, exists := controller.state.panel_images[row_panel.panel_id]; exists {
ready = "ready"
}
row_rec := rl.Rectangle{x = f32(x+18), y = f32(list_y+line*row_h), width = f32(w-36), height = f32(row_h)}
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && rl.IsMouseButtonPressed(.LEFT) {
new_cursor = i
}
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && row_color == TEXT_TERTIARY {
row_color = TEXT_SECONDARY
}
draw_summary_subline(x+18, list_y+line*row_h+2, fit_text_for_width(fmt.tprintf("%s %02d p%d#%d %s", mark, i+1, row_page, row_panel.panel_number, ready), int(w-36), 7), row_color)
line += 1
}
return
}

View File

@ -0,0 +1,23 @@
package gui
import "core:fmt"
import rl "vendor:raylib"
fit_text_for_width :: proc(text: string, width_px, px_per_char: int) -> string {
if px_per_char <= 0 {
return text
}
max_chars := width_px / px_per_char
if max_chars < 4 {
max_chars = 4
}
if len(text) <= max_chars {
return text
}
return fmt.tprintf("%s…", text[:max_chars-1])
}
draw_text_fitted :: proc(text: string, x, y, font_size: i32, width_px, px_per_char: int, color: rl.Color) {
display := fit_text_for_width(text, width_px, px_per_char)
rl.DrawText(fmt.ctprintf("%s", display), x, y, font_size, color)
}

210
odin/src/gui/theme.odin Normal file
View File

@ -0,0 +1,210 @@
package gui
import rl "vendor:raylib"
// Backgrounds
BG_BASE :: rl.Color{13, 13, 18, 255}
BG_SIDEBAR :: rl.Color{18, 18, 24, 255}
BG_TOPBAR :: rl.Color{18, 18, 24, 255}
BG_CARD :: rl.Color{24, 24, 32, 255}
BG_CARD_ALT :: rl.Color{28, 28, 38, 255} // slightly elevated card
BG_STRIP :: rl.Color{22, 22, 30, 255} // subtle strip background
BG_OVERLAY :: rl.Color{8, 8, 12, 180} // modal backdrop
// Borders
BORDER_CARD :: rl.Color{40, 40, 52, 255}
BORDER_SUBTLE :: rl.Color{36, 36, 48, 255}
BORDER_DIVIDER :: rl.Color{36, 36, 48, 255}
// Accent (Indigo-Violet)
ACCENT :: rl.Color{99, 102, 241, 255}
ACCENT_HOVER :: rl.Color{120, 122, 248, 255}
ACCENT_MUTED :: rl.Color{68, 70, 180, 255}
ACCENT_SURFACE :: rl.Color{30, 30, 56, 255}
ACCENT_GLOW :: rl.Color{99, 102, 241, 80}
// Text
TEXT_PRIMARY :: rl.Color{228, 228, 240, 255}
TEXT_SECONDARY :: rl.Color{148, 148, 168, 255}
TEXT_TERTIARY :: rl.Color{98, 98, 118, 255}
TEXT_DISABLED :: rl.Color{68, 68, 88, 255}
TEXT_BRIGHT :: rl.Color{245, 245, 255, 255}
// Semantic: Success
SUCCESS :: rl.Color{52, 211, 153, 255}
SUCCESS_BG :: rl.Color{16, 42, 32, 255}
SUCCESS_BORDER :: rl.Color{40, 100, 74, 255}
SUCCESS_TEXT :: rl.Color{110, 231, 183, 255}
// Semantic: Warning
WARNING :: rl.Color{251, 191, 36, 255}
WARNING_BG :: rl.Color{50, 38, 14, 255}
WARNING_BORDER :: rl.Color{120, 90, 30, 255}
WARNING_TEXT :: rl.Color{253, 224, 120, 255}
// Semantic: Error
ERROR :: rl.Color{248, 113, 113, 255}
ERROR_BG :: rl.Color{50, 18, 18, 255}
ERROR_BORDER :: rl.Color{120, 50, 50, 255}
ERROR_TEXT :: rl.Color{254, 178, 178, 255}
// Semantic: Danger (destructive buttons)
DANGER_BG :: rl.Color{153, 50, 58, 255}
DANGER_BG_HOVER :: rl.Color{172, 60, 68, 255}
DANGER_BORDER :: rl.Color{130, 42, 48, 255}
// Semantic: Warning-style buttons
WARN_BTN_BG :: rl.Color{80, 64, 30, 255}
WARN_BTN_BG_HOVER :: rl.Color{95, 76, 36, 255}
WARN_BTN_BORDER :: rl.Color{110, 88, 40, 255}
WARN_BTN_TEXT :: rl.Color{253, 224, 120, 255}
// Buttons
BTN_BG :: rl.Color{32, 32, 44, 255}
BTN_BG_HOVER :: rl.Color{40, 40, 54, 255}
BTN_BORDER :: rl.Color{52, 52, 68, 255}
BTN_BORDER_HOVER :: rl.Color{80, 82, 140, 255}
BTN_TEXT :: TEXT_PRIMARY
BTN_SOFT_BG :: rl.Color{28, 30, 48, 255}
BTN_SOFT_BG_HOVER :: rl.Color{36, 38, 58, 255}
BTN_SOFT_BORDER :: rl.Color{60, 62, 100, 255}
BTN_SOFT_TEXT :: rl.Color{160, 162, 220, 255}
BTN_DISABLED_BG :: rl.Color{24, 24, 32, 255}
BTN_DISABLED_BORDER :: rl.Color{36, 36, 48, 255}
BTN_DISABLED_TEXT :: rl.Color{68, 68, 88, 255}
// Small Buttons
SBTN_BG :: rl.Color{30, 30, 42, 255}
SBTN_BG_HOVER :: rl.Color{40, 40, 54, 255}
SBTN_BORDER :: rl.Color{50, 50, 66, 255}
SBTN_BORDER_HOVER :: rl.Color{80, 82, 140, 255}
SBTN_TEXT :: rl.Color{188, 188, 210, 255}
// Navigation
NAV_BG :: rl.Color{24, 24, 32, 255}
NAV_BG_HOVER :: rl.Color{32, 32, 44, 255}
NAV_BORDER :: rl.Color{40, 40, 52, 255}
NAV_BORDER_HOVER :: rl.Color{70, 72, 120, 255}
NAV_TEXT :: rl.Color{168, 168, 188, 255}
NAV_ACTIVE_BG :: ACCENT
NAV_ACTIVE_TEXT :: TEXT_BRIGHT
NAV_ACTIVE_BAR :: rl.Color{180, 182, 255, 255}
// Input Fields
INPUT_BG :: rl.Color{16, 16, 24, 255}
INPUT_BORDER :: rl.Color{40, 40, 54, 255}
INPUT_FOCUS_BG :: rl.Color{20, 20, 30, 255}
INPUT_FOCUS_BORDER :: ACCENT
INPUT_FOCUS_RING :: ACCENT_GLOW
INPUT_TEXT :: TEXT_PRIMARY
INPUT_TEXT_FOCUS :: TEXT_BRIGHT
// Chips & Pills
PILL_BG :: rl.Color{28, 28, 38, 255}
PILL_BORDER :: rl.Color{44, 44, 58, 255}
PILL_TEXT :: TEXT_SECONDARY
PILL_ACCENT_BG :: ACCENT_SURFACE
PILL_ACCENT_BORDER :: ACCENT_MUTED
PILL_ACCENT_TEXT :: rl.Color{180, 182, 255, 255}
CHIP_BG :: rl.Color{28, 28, 38, 255}
CHIP_BORDER :: rl.Color{44, 44, 58, 255}
CHIP_TEXT :: TEXT_SECONDARY
CHIP_ACCENT_BG :: ACCENT_SURFACE
CHIP_ACCENT_BORDER :: ACCENT_MUTED
CHIP_ACCENT_TEXT :: PILL_ACCENT_TEXT
// Status Badges
BADGE_OK_BG :: SUCCESS_BG
BADGE_OK_BORDER :: SUCCESS_BORDER
BADGE_OK_TEXT :: SUCCESS_TEXT
BADGE_BAD_BG :: ERROR_BG
BADGE_BAD_BORDER :: ERROR_BORDER
BADGE_BAD_TEXT :: ERROR_TEXT
// Readiness Chips
READY_BG :: SUCCESS_BG
READY_BORDER :: SUCCESS_BORDER
READY_TEXT :: SUCCESS_TEXT
UNREADY_BG :: rl.Color{28, 28, 38, 255}
UNREADY_BORDER :: rl.Color{44, 44, 58, 255}
UNREADY_TEXT :: TEXT_TERTIARY
// Progress Bar
PROGRESS_TRACK :: rl.Color{28, 28, 40, 255}
PROGRESS_FILL :: ACCENT
// Toast
TOAST_SUCCESS :: rl.Color{28, 120, 80, 235}
TOAST_WARNING :: rl.Color{140, 100, 30, 235}
TOAST_ERROR :: rl.Color{150, 50, 50, 235}
TOAST_BORDER :: rl.Color{255, 255, 255, 40}
TOAST_SHADOW :: rl.Color{0, 0, 0, 60}
// Action Log
LOG_ROW_ALT :: rl.Color{22, 22, 30, 255}
LOG_TEXT :: rl.Color{158, 158, 178, 255}
// Section Titles
SECTION_TITLE_COLOR :: rl.Color{148, 150, 210, 255}
SECTION_UNDERLINE :: rl.Color{44, 44, 60, 255}
// Screen Summary
SUMMARY_TITLE :: rl.Color{170, 172, 230, 255}
SUMMARY_ACCENT :: rl.Color{99, 140, 220, 255}
SUMMARY_HINT :: rl.Color{120, 90, 200, 255}
SUMMARY_SUBLINE :: rl.Color{128, 128, 148, 255}
SUMMARY_DIM :: rl.Color{98, 98, 118, 255}
// Pipeline Stepper
STEP_DONE_FILL :: SUCCESS
STEP_DONE_BORDER :: SUCCESS_BORDER
STEP_TODO_FILL :: rl.Color{36, 36, 48, 255}
STEP_TODO_BORDER :: rl.Color{52, 52, 68, 255}
STEP_LINE_DONE :: rl.Color{40, 100, 74, 255}
STEP_LINE_TODO :: rl.Color{40, 40, 52, 255}
STEP_LABEL_DONE :: SUCCESS_TEXT
STEP_LABEL_TODO :: TEXT_TERTIARY
// Help Overlay
HELP_TITLE :: TEXT_BRIGHT
HELP_SECTION :: rl.Color{130, 132, 210, 255}
HELP_LINE :: TEXT_SECONDARY
HELP_CLOSE :: rl.Color{170, 148, 240, 255}
// Confirm Overlay
CONFIRM_ACCENT :: ACCENT
CONFIRM_TITLE :: TEXT_BRIGHT
CONFIRM_BODY :: TEXT_SECONDARY
CONFIRM_HINT :: rl.Color{170, 148, 240, 255}
// Sidebar Shortcuts
SIDEBAR_TITLE :: rl.Color{130, 132, 200, 255}
SIDEBAR_TEXT :: TEXT_TERTIARY
SIDEBAR_FOOTER :: rl.Color{80, 80, 100, 255}
// Brand
BRAND_TITLE :: TEXT_BRIGHT
BRAND_SUBTITLE :: TEXT_TERTIARY
// Roundness Constants
RADIUS_CARD :: f32(0.14)
RADIUS_BUTTON :: f32(0.32)
RADIUS_PILL :: f32(0.50)
RADIUS_INPUT :: f32(0.24)
RADIUS_NAV :: f32(0.28)
RADIUS_CHIP :: f32(0.42)
RADIUS_BADGE :: f32(0.42)
RADIUS_TOAST :: f32(0.40)
RADIUS_BAR :: f32(0.60)
// Recommended Halo
RECOMMEND_HALO_FILL :: rl.Color{50, 50, 100, 255}
RECOMMEND_HALO_BORDER :: rl.Color{120, 122, 248, 255}
// DeepSeek key missing
KEY_MISSING_COLOR :: ERROR

17
odin/src/gui/types.odin Normal file
View File

@ -0,0 +1,17 @@
package gui
Summary_View_Options :: struct {
script_show_all: bool,
script_desc: bool,
script_page_cursor: int,
panel_cursor: int,
layout_show_all: bool,
layout_desc: bool,
layout_page_cursor: int,
}
Pending_Confirm_Action :: enum {
None,
Reset_Project,
Open_Project,
}

76
odin/src/gui/widgets.odin Normal file
View File

@ -0,0 +1,76 @@
package gui
import rl "vendor:raylib"
draw_card :: proc(rec: rl.Rectangle) {
rl.DrawRectangleRounded(rec, RADIUS_CARD, 8, BG_CARD)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CARD, 8, 1.0, BORDER_CARD)
}
draw_subtle_strip :: proc(rec: rl.Rectangle) {
rl.DrawRectangleRounded(rec, 0.20, 8, BG_STRIP)
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 8, 1.0, BORDER_SUBTLE)
}
draw_hint_pill :: proc(rec: rl.Rectangle, label: string, accent: bool) {
bg := PILL_BG
border := PILL_BORDER
fg := PILL_TEXT
if accent {
bg = PILL_ACCENT_BG
border = PILL_ACCENT_BORDER
fg = PILL_ACCENT_TEXT
}
rl.DrawRectangleRounded(rec, RADIUS_PILL, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_PILL, 8, 1.0, border)
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 13, int(rec.width)-16, 7, fg)
}
draw_topbar_chip :: proc(rec: rl.Rectangle, label: string, accent: bool) {
bg := CHIP_BG
border := CHIP_BORDER
fg := CHIP_TEXT
if accent {
bg = CHIP_ACCENT_BG
border = CHIP_ACCENT_BORDER
fg = CHIP_ACCENT_TEXT
}
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, border)
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-16, 8, fg)
}
draw_status_badge :: proc(rec: rl.Rectangle, label: string, ok: bool) {
bg := BADGE_BAD_BG
border := BADGE_BAD_BORDER
fg := BADGE_BAD_TEXT
if ok {
bg = BADGE_OK_BG
border = BADGE_OK_BORDER
fg = BADGE_OK_TEXT
}
rl.DrawRectangleRounded(rec, RADIUS_BADGE, 8, bg)
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BADGE, 8, 1.0, border)
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 13, int(rec.width)-16, 7, fg)
}
draw_section_title :: proc(x, y: i32, label: string) {
draw_text_fitted(label, x, y, 17, 180, 8, SECTION_TITLE_COLOR)
rl.DrawLine(x, y+20, x+180, y+20, SECTION_UNDERLINE)
}
draw_summary_line :: proc(x, y: i32, text: string, c: rl.Color) {
fg := c
if int(c.r)+int(c.g)+int(c.b) < 260 {
fg = TEXT_PRIMARY
}
draw_text_fitted(text, x, y, 18, 438, 8, fg)
}
draw_summary_subline :: proc(x, y: i32, text: string, c: rl.Color) {
fg := c
if int(c.r)+int(c.g)+int(c.b) < 260 {
fg = TEXT_SECONDARY
}
draw_text_fitted(text, x, y, 16, 438, 7, fg)
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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()
}

19
odin/src/ui/dispose.odin Normal file
View File

@ -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)
}

105
odin/src/ui/jobs.odin Normal file
View File

@ -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
}

View File

@ -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
}

60
odin/src/ui/runtime.odin Normal file
View File

@ -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()
}

48
odin/src/ui/screens.odin Normal file
View File

@ -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"
}

91
odin/src/ui/views.odin Normal file
View File

@ -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
}

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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-")
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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")
}

61
odin/tests/ui_phase4.odin Normal file
View File

@ -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")
}

View File

@ -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")
}

22
package-lock.json generated
View File

@ -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",