check point
This commit is contained in:
parent
ae1cae967b
commit
1e85df5193
44
.github/workflows/odin-ci.yml
vendored
Normal file
44
.github/workflows/odin-ci.yml
vendored
Normal 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
2
odin/.env
Normal 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
5
odin/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
bin/
|
||||
*.ll
|
||||
*.o
|
||||
*.obj
|
||||
*.pdb
|
||||
33
odin/README.md
Normal file
33
odin/README.md
Normal 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
5
odin/build.sh
Executable 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
BIN
odin/comic-odin
Executable file
Binary file not shown.
27
odin/comic.pdf
Normal file
27
odin/comic.pdf
Normal 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
1027
odin/docs/PORT_BACKLOG.md
Normal file
File diff suppressed because it is too large
Load Diff
27
odin/generated/demo_comic.pdf
Normal file
27
odin/generated/demo_comic.pdf
Normal 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
|
||||
296
odin/generated/demo_project.comic.json
Normal file
296
odin/generated/demo_project.comic.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
27
odin/generated/nested/demo.pdf
Normal file
27
odin/generated/nested/demo.pdf
Normal 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
|
||||
206
odin/generated/nested/demo_project.comic.json
Normal file
206
odin/generated/nested/demo_project.comic.json
Normal 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
27
odin/gui_export.pdf
Normal 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
387
odin/gui_project.comic.json
Normal 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
BIN
odin/gui_session_report.txt
Normal file
Binary file not shown.
BIN
odin/local.cbz
Normal file
BIN
odin/local.cbz
Normal file
Binary file not shown.
27
odin/local.pdf
Normal file
27
odin/local.pdf
Normal 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
|
||||
51
odin/missing-dir-for-autosave/project.comic.json
Normal file
51
odin/missing-dir-for-autosave/project.comic.json
Normal 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
BIN
odin/quick_3.cbz
Normal file
Binary file not shown.
27
odin/quick_local.pdf
Normal file
27
odin/quick_local.pdf
Normal 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
|
||||
29
odin/schemas/comic-project.schema.json
Normal file
29
odin/schemas/comic-project.schema.json
Normal 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
11
odin/scratch.odin
Normal 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
BIN
odin/screenshot000.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
odin/screenshot001.png
Normal file
BIN
odin/screenshot001.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
32
odin/scripts/package.sh
Executable file
32
odin/scripts/package.sh
Executable 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"
|
||||
789
odin/src/adapters/deepseek.odin
Normal file
789
odin/src/adapters/deepseek.odin
Normal 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)
|
||||
}
|
||||
251
odin/src/adapters/export.odin
Normal file
251
odin/src/adapters/export.odin
Normal 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
299
odin/src/adapters/fal.odin
Normal 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)
|
||||
}
|
||||
133
odin/src/adapters/storage.odin
Normal file
133
odin/src/adapters/storage.odin
Normal 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
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
25
odin/src/app/main.odin
Normal 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
163
odin/src/core/bubble.odin
Normal 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[:]
|
||||
}
|
||||
108
odin/src/core/character_prompt.odin
Normal file
108
odin/src/core/character_prompt.odin
Normal 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
140
odin/src/core/dispose.odin
Normal 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
264
odin/src/core/layout.odin
Normal 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
56
odin/src/core/script.odin
Normal 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
51
odin/src/core/state.odin
Normal 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
251
odin/src/core/types.odin
Normal 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,
|
||||
}
|
||||
24
odin/src/core/workflow.odin
Normal file
24
odin/src/core/workflow.odin
Normal 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
429
odin/src/gui/actions.odin
Normal 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
177
odin/src/gui/controls.odin
Normal file
@ -0,0 +1,177 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
|
||||
button_clicked :: proc(rec: rl.Rectangle) -> bool {
|
||||
if !rl.IsMouseButtonPressed(.LEFT) {
|
||||
return false
|
||||
}
|
||||
return rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
}
|
||||
|
||||
draw_button :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := BTN_BG
|
||||
border := BTN_BORDER
|
||||
text := BTN_TEXT
|
||||
if hover {
|
||||
bg = BTN_BG_HOVER
|
||||
border = BTN_BORDER_HOVER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
|
||||
}
|
||||
|
||||
draw_button_primary :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := ACCENT
|
||||
border := ACCENT_MUTED
|
||||
if hover {
|
||||
bg = ACCENT_HOVER
|
||||
border = ACCENT
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT)
|
||||
}
|
||||
|
||||
draw_button_danger :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := DANGER_BG
|
||||
border := DANGER_BORDER
|
||||
if hover {
|
||||
bg = DANGER_BG_HOVER
|
||||
border = DANGER_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, TEXT_BRIGHT)
|
||||
}
|
||||
|
||||
draw_button_warning :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := WARN_BTN_BG
|
||||
border := WARN_BTN_BORDER
|
||||
text := WARN_BTN_TEXT
|
||||
if hover {
|
||||
bg = WARN_BTN_BG_HOVER
|
||||
border = WARN_BTN_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
|
||||
}
|
||||
|
||||
draw_button_soft_accent :: proc(rec: rl.Rectangle, label: string) {
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := BTN_SOFT_BG
|
||||
border := BTN_SOFT_BORDER
|
||||
text := BTN_SOFT_TEXT
|
||||
if hover {
|
||||
bg = BTN_SOFT_BG_HOVER
|
||||
border = BTN_SOFT_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, border)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, text)
|
||||
}
|
||||
|
||||
draw_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) {
|
||||
if !enabled {
|
||||
rl.DrawRectangleRounded(rec, RADIUS_BUTTON, 8, BTN_DISABLED_BG)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_BUTTON, 8, 1.2, BTN_DISABLED_BORDER)
|
||||
draw_text_fitted(label, i32(rec.x)+10, i32(rec.y)+8, 18, int(rec.width)-20, 8, BTN_DISABLED_TEXT)
|
||||
return
|
||||
}
|
||||
draw_button(rec, label)
|
||||
}
|
||||
|
||||
draw_small_button_state :: proc(rec: rl.Rectangle, label: string, enabled: bool) {
|
||||
if !enabled {
|
||||
rl.DrawRectangleRounded(rec, 0.20, 6, BTN_DISABLED_BG)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, BTN_DISABLED_BORDER)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, BTN_DISABLED_TEXT)
|
||||
return
|
||||
}
|
||||
hover := rl.CheckCollisionPointRec(rl.GetMousePosition(), rec)
|
||||
bg := SBTN_BG
|
||||
border := SBTN_BORDER
|
||||
if hover {
|
||||
bg = SBTN_BG_HOVER
|
||||
border = SBTN_BORDER_HOVER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, 0.20, 6, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, 0.20, 6, 1.0, border)
|
||||
draw_text_fitted(label, i32(rec.x)+8, i32(rec.y)+4, 14, int(rec.width)-12, 7, SBTN_TEXT)
|
||||
}
|
||||
|
||||
draw_small_button :: proc(rec: rl.Rectangle, label: string) {
|
||||
draw_small_button_state(rec, label, true)
|
||||
}
|
||||
|
||||
button_readiness_hint :: proc(mouse: rl.Vector2, panels_btn, layout_btn, export_btn: rl.Rectangle, can_generate_panels, can_layout, can_export: bool) -> string {
|
||||
if rl.CheckCollisionPointRec(mouse, panels_btn) && !can_generate_panels {
|
||||
return "Panels requires a generated script"
|
||||
}
|
||||
if rl.CheckCollisionPointRec(mouse, layout_btn) && !can_layout {
|
||||
return "Layout requires generated panels"
|
||||
}
|
||||
if rl.CheckCollisionPointRec(mouse, export_btn) && !can_export {
|
||||
return "Export requires panels + layout"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
draw_button_recommended :: proc(rec: rl.Rectangle, label: string) {
|
||||
halo := rl.Rectangle{x = rec.x-2, y = rec.y-2, width = rec.width+4, height = rec.height+4}
|
||||
rl.DrawRectangleRounded(halo, RADIUS_BUTTON, 8, RECOMMEND_HALO_FILL)
|
||||
rl.DrawRectangleRoundedLinesEx(halo, RADIUS_BUTTON, 8, 1.4, RECOMMEND_HALO_BORDER)
|
||||
draw_button(rec, label)
|
||||
}
|
||||
|
||||
draw_nav_item :: proc(rec: rl.Rectangle, label: string, active: bool) {
|
||||
bg := NAV_BG
|
||||
border := NAV_BORDER
|
||||
text := NAV_TEXT
|
||||
if active {
|
||||
bg = NAV_ACTIVE_BG
|
||||
border = NAV_ACTIVE_BG
|
||||
text = NAV_ACTIVE_TEXT
|
||||
} else if rl.CheckCollisionPointRec(rl.GetMousePosition(), rec) {
|
||||
bg = NAV_BG_HOVER
|
||||
border = NAV_BORDER_HOVER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_NAV, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_NAV, 8, 1.0, border)
|
||||
if active {
|
||||
rl.DrawRectangleRounded(rl.Rectangle{x = rec.x+2, y = rec.y+4, width = 4, height = rec.height-8}, 0.5, 8, NAV_ACTIVE_BAR)
|
||||
}
|
||||
label_x := i32(rec.x) + 8
|
||||
label_w := int(rec.width) - 16
|
||||
if active {
|
||||
label_x = i32(rec.x) + 14
|
||||
label_w = int(rec.width) - 22
|
||||
}
|
||||
draw_text_fitted(label, label_x, i32(rec.y)+6, 18, label_w, 8, text)
|
||||
}
|
||||
|
||||
draw_input_field :: proc(rec: rl.Rectangle, value: string, selected: bool) {
|
||||
bg := INPUT_BG
|
||||
border := INPUT_BORDER
|
||||
if selected {
|
||||
halo := rl.Rectangle{x = rec.x - 2, y = rec.y - 2, width = rec.width + 4, height = rec.height + 4}
|
||||
rl.DrawRectangleRounded(halo, RADIUS_INPUT, 8, INPUT_FOCUS_RING)
|
||||
rl.DrawRectangleRoundedLinesEx(halo, RADIUS_INPUT, 8, 1.0, INPUT_FOCUS_BORDER)
|
||||
bg = INPUT_FOCUS_BG
|
||||
border = INPUT_FOCUS_BORDER
|
||||
}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_INPUT, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_INPUT, 8, 1.2, border)
|
||||
if !selected {
|
||||
draw_text_fitted(value, i32(rec.x)+8, i32(rec.y)+6, 18, int(rec.width)-16, 8, INPUT_TEXT)
|
||||
return
|
||||
}
|
||||
rl.DrawText(fmt.ctprintf("%s", value), i32(rec.x)+8, i32(rec.y)+6, 18, INPUT_TEXT_FOCUS)
|
||||
}
|
||||
159
odin/src/gui/diagnostics.odin
Normal file
159
odin/src/gui/diagnostics.odin
Normal file
@ -0,0 +1,159 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:path/filepath"
|
||||
import rl "vendor:raylib"
|
||||
import "../ui"
|
||||
|
||||
build_diagnostics_snapshot :: proc(controller: ui.App_Controller, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string {
|
||||
log_order := "newest"
|
||||
if log_oldest_first {
|
||||
log_order = "oldest"
|
||||
}
|
||||
return fmt.aprintf("screen=%s workflow=%v next=%s dirty=%s autosave=%s(%ds) content=pages:%d,panels:%d,layouts:%d,chars:%d paths=P:%s,E:%s project=%s export=%s log=%d,%s uptime=%.1fs", ui.screen_name(controller.active_screen), controller.state.workflow.current_step, gui_next_hint(controller), yn(is_dirty), yn(autosave_enabled), autosave_secs, len(controller.state.script.pages), len(controller.state.panel_images), len(controller.state.page_layouts), len(controller.state.characters), yn(project_ok), yn(export_ok), project_path, export_path, log_show_lines, log_order, rl.GetTime())
|
||||
}
|
||||
|
||||
build_action_log_snapshot :: proc(log: Action_Log) -> string {
|
||||
if log.count == 0 {
|
||||
return fmt.aprintf("(action log empty)")
|
||||
}
|
||||
now := rl.GetTime()
|
||||
max_lines := len(log.entries)
|
||||
if log.count < max_lines {
|
||||
max_lines = log.count
|
||||
}
|
||||
out := ""
|
||||
for line in 0..<max_lines {
|
||||
idx := (log.count - 1 - line) % len(log.entries)
|
||||
if idx < 0 {
|
||||
idx += len(log.entries)
|
||||
}
|
||||
entry := fmt.aprintf("[%2.0fs] %s", now-log.entry_times[idx], log.entries[idx])
|
||||
if line == 0 {
|
||||
out = entry
|
||||
} else {
|
||||
prev := out
|
||||
out = fmt.aprintf("%s\n%s", prev, entry)
|
||||
delete(prev)
|
||||
delete(entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
build_session_report :: proc(controller: ui.App_Controller, log: Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> string {
|
||||
diag := build_diagnostics_snapshot(controller, is_dirty, autosave_enabled, project_ok, export_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
log_text := build_action_log_snapshot(log)
|
||||
report := fmt.aprintf("# comic-odin gui session report\n\n[meta]\ngenerated_uptime=%.1fs\n\n[diagnostics]\n%s\n\n[action_log]\n%s\n", rl.GetTime(), diag, log_text)
|
||||
delete(diag)
|
||||
delete(log_text)
|
||||
return report
|
||||
}
|
||||
|
||||
Diagnostics_Action_Context :: struct {
|
||||
controller: ^ui.App_Controller,
|
||||
action_log: ^Action_Log,
|
||||
is_dirty: bool,
|
||||
autosave_enabled: bool,
|
||||
project_ok: bool,
|
||||
export_ok: bool,
|
||||
autosave_secs: int,
|
||||
project_path: string,
|
||||
export_path: string,
|
||||
log_show_lines: i32,
|
||||
log_oldest_first: bool,
|
||||
}
|
||||
|
||||
make_diagnostics_action_context :: proc(controller: ^ui.App_Controller, action_log: ^Action_Log, is_dirty, autosave_enabled, project_ok, export_ok: bool, autosave_secs: int, project_path, export_path: string, log_show_lines: i32, log_oldest_first: bool) -> Diagnostics_Action_Context {
|
||||
return Diagnostics_Action_Context{
|
||||
controller = controller,
|
||||
action_log = action_log,
|
||||
is_dirty = is_dirty,
|
||||
autosave_enabled = autosave_enabled,
|
||||
project_ok = project_ok,
|
||||
export_ok = export_ok,
|
||||
autosave_secs = autosave_secs,
|
||||
project_path = project_path,
|
||||
export_path = export_path,
|
||||
log_show_lines = log_show_lines,
|
||||
log_oldest_first = log_oldest_first,
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics_path_for_project :: proc(project_path: string) -> string {
|
||||
dir, _ := filepath.split(project_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "./"
|
||||
}
|
||||
parts := []string{dir, "gui_diagnostics.txt"}
|
||||
joined, err := filepath.join(parts)
|
||||
if err != nil {
|
||||
return "./gui_diagnostics.txt"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
session_report_path_for_project :: proc(project_path: string) -> string {
|
||||
dir, _ := filepath.split(project_path)
|
||||
if len(dir) == 0 {
|
||||
dir = "./"
|
||||
}
|
||||
parts := []string{dir, "gui_session_report.txt"}
|
||||
joined, err := filepath.join(parts)
|
||||
if err != nil {
|
||||
return "./gui_session_report.txt"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
write_diagnostics_file :: proc(project_path, diag: string) -> string {
|
||||
diag_path := diagnostics_path_for_project(project_path)
|
||||
defer delete(diag_path)
|
||||
if err := os.write_entire_file(diag_path, diag); err == nil {
|
||||
return fmt.aprintf("Wrote diagnostics file: %s", diag_path)
|
||||
}
|
||||
return fmt.aprintf("Failed writing diagnostics file")
|
||||
}
|
||||
|
||||
write_session_report_file :: proc(project_path, report: string) -> string {
|
||||
report_path := session_report_path_for_project(project_path)
|
||||
defer delete(report_path)
|
||||
if err := os.write_entire_file(report_path, report); err == nil {
|
||||
return fmt.aprintf("Wrote session report: %s", report_path)
|
||||
}
|
||||
return fmt.aprintf("Failed writing session report")
|
||||
}
|
||||
|
||||
write_session_report_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
report := build_session_report(ctx.controller^, ctx.action_log^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
|
||||
msg := write_session_report_file(ctx.project_path, report)
|
||||
delete(report)
|
||||
return msg
|
||||
}
|
||||
|
||||
write_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
|
||||
msg := write_diagnostics_file(ctx.project_path, diag)
|
||||
delete(diag)
|
||||
return msg
|
||||
}
|
||||
|
||||
copy_diagnostics_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
diag := build_diagnostics_snapshot(ctx.controller^, ctx.is_dirty, ctx.autosave_enabled, ctx.project_ok, ctx.export_ok, ctx.autosave_secs, ctx.project_path, ctx.export_path, ctx.log_show_lines, ctx.log_oldest_first)
|
||||
msg := copy_text_with_status(diag, "Copied diagnostics snapshot")
|
||||
delete(diag)
|
||||
return msg
|
||||
}
|
||||
|
||||
copy_action_log_snapshot_with_message :: proc(ctx: Diagnostics_Action_Context) -> string {
|
||||
log_text := build_action_log_snapshot(ctx.action_log^)
|
||||
msg := copy_text_with_status(log_text, "Copied action log snapshot")
|
||||
delete(log_text)
|
||||
return msg
|
||||
}
|
||||
|
||||
copy_text_with_status :: proc(text, status: string) -> string {
|
||||
rl.SetClipboardText(fmt.ctprintf("%s", text))
|
||||
return status
|
||||
}
|
||||
124
odin/src/gui/local_helpers.odin
Normal file
124
odin/src/gui/local_helpers.odin
Normal 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
151
odin/src/gui/overlays.odin
Normal file
@ -0,0 +1,151 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import rl "vendor:raylib"
|
||||
|
||||
draw_action_log :: proc(log: Action_Log, x, y, max_visible: i32, oldest_first: bool) {
|
||||
now := rl.GetTime()
|
||||
max_lines := len(log.entries)
|
||||
if log.count < max_lines {
|
||||
max_lines = log.count
|
||||
}
|
||||
if max_visible > 0 && int(max_visible) < max_lines {
|
||||
max_lines = int(max_visible)
|
||||
}
|
||||
for line in 0..<max_lines {
|
||||
idx := 0
|
||||
if oldest_first {
|
||||
start := log.count - max_lines
|
||||
idx = (start + line) % len(log.entries)
|
||||
} else {
|
||||
idx = (log.count - 1 - line) % len(log.entries)
|
||||
}
|
||||
if idx < 0 {
|
||||
idx += len(log.entries)
|
||||
}
|
||||
row_y := y + i32(line*22)
|
||||
if line % 2 == 0 {
|
||||
rl.DrawRectangleRounded(rl.Rectangle{x = f32(x-4), y = f32(row_y-2), width = 442, height = 20}, 0.2, 6, LOG_ROW_ALT)
|
||||
}
|
||||
age := now - log.entry_times[idx]
|
||||
line_text := fmt.tprintf("[%2.0fs] %s", age, log.entries[idx])
|
||||
draw_text_fitted(line_text, x, row_y, 15, 430, 7, LOG_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
is_error_message :: proc(msg: string) -> bool {
|
||||
return strings.contains(msg, "failed") || strings.contains(msg, "blocked") || strings.contains(msg, "No script")
|
||||
}
|
||||
|
||||
is_warning_message :: proc(msg: string) -> bool {
|
||||
return strings.contains(msg, "Unsaved") || strings.contains(msg, "Confirm") || strings.contains(msg, "requires") || strings.contains(msg, "before") || strings.contains(msg, "Cancelled")
|
||||
}
|
||||
|
||||
status_text_color :: proc(msg: string) -> rl.Color {
|
||||
if is_error_message(msg) {
|
||||
return ERROR
|
||||
}
|
||||
if is_warning_message(msg) {
|
||||
return WARNING
|
||||
}
|
||||
return SUCCESS
|
||||
}
|
||||
|
||||
toast_bg_color :: proc(msg: string) -> rl.Color {
|
||||
if is_error_message(msg) {
|
||||
return TOAST_ERROR
|
||||
}
|
||||
if is_warning_message(msg) {
|
||||
return TOAST_WARNING
|
||||
}
|
||||
return TOAST_SUCCESS
|
||||
}
|
||||
|
||||
draw_toast :: proc(log: Action_Log, x, y, w: i32) {
|
||||
if log.count == 0 {
|
||||
return
|
||||
}
|
||||
age := rl.GetTime() - log.last_push_at
|
||||
if age > 2.8 {
|
||||
return
|
||||
}
|
||||
idx := (log.count - 1) % len(log.entries)
|
||||
if idx < 0 {
|
||||
idx += len(log.entries)
|
||||
}
|
||||
msg := log.entries[idx]
|
||||
bg := toast_bg_color(msg)
|
||||
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 34}
|
||||
shadow := rl.Rectangle{x = f32(x), y = f32(y + 2), width = f32(w), height = 34}
|
||||
rl.DrawRectangleRounded(shadow, RADIUS_TOAST, 8, TOAST_SHADOW)
|
||||
rl.DrawRectangleRounded(rec, RADIUS_TOAST, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_TOAST, 8, 1.0, TOAST_BORDER)
|
||||
draw_text_fitted(msg, x+10, y+8, 16, int(w-20), 8, TEXT_BRIGHT)
|
||||
}
|
||||
|
||||
draw_help_line :: proc(x, y: i32, text: string) {
|
||||
draw_text_fitted(text, x, y, 16, 820, 8, HELP_LINE)
|
||||
}
|
||||
|
||||
draw_help_overlay :: proc() {
|
||||
sw := rl.GetScreenWidth()
|
||||
sh := rl.GetScreenHeight()
|
||||
rec := rl.Rectangle{x = f32((sw-860)/2), y = f32((sh-642)/2), width = 860, height = 642}
|
||||
rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY)
|
||||
draw_card(rec)
|
||||
x := i32(rec.x) + 30
|
||||
y := i32(rec.y) + 28
|
||||
rl.DrawText("Keyboard Shortcuts", x, y, 28, HELP_TITLE)
|
||||
rl.DrawText("Navigation", x, y+44, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+72, "1..8 screens | TAB fields | click to focus | F11 pages | F12 project")
|
||||
rl.DrawText("Core Actions", x, y+106, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+134, "F5 script F6 panels F7 layout F8 export F9 next F10 auto-all")
|
||||
draw_help_line(x, y+160, "Ctrl+S save | Ctrl+O open | Ctrl+N new | Ctrl+E export | Ctrl+H/J summary")
|
||||
rl.DrawText("Clipboard + Logs", x, y+196, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+224, "Ctrl+L clear log | Ctrl+Shift+L copy log | Ctrl+Shift+T/B log view | Ctrl+Shift+Z reset")
|
||||
draw_help_line(x, y+248, "Ctrl+Shift+C status")
|
||||
draw_help_line(x, y+272, "Ctrl+Shift+Y diag copy | Ctrl+Shift+R diag file | Ctrl+Shift+W report")
|
||||
draw_help_line(x, y+296, "Ctrl+0 reset helpers | Ctrl+V paste | Ctrl+Shift+I copy | Ctrl+Backspace clear")
|
||||
rl.DrawText("Paths", x, y+332, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+360, "Ctrl+Shift+X copy export | P preset | D exp-from-project | G proj-from-export")
|
||||
draw_help_line(x, y+386, "Ctrl+Shift+J fix project | F fix export | K/M quick-fix P/E | U fix all")
|
||||
rl.DrawText("Autosave", x, y+422, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+450, "Ctrl+Shift+A toggle | Ctrl+-/= adjust | Ctrl+7/8/9 set 15/30/60")
|
||||
rl.DrawText("Safety", x, y+486, 20, HELP_SECTION)
|
||||
draw_help_line(x, y+514, "Dirty guard: Shift-click New/Open | Keyboard confirm: Ctrl+Shift+N / Ctrl+Shift+O")
|
||||
rl.DrawText("Close help: Esc or /", x, y+542, 18, HELP_CLOSE)
|
||||
}
|
||||
|
||||
draw_sidebar_shortcut_line :: proc(x, y: i32, text: string, c: rl.Color) {
|
||||
draw_text_fitted(text, x, y, 14, 220, 7, c)
|
||||
}
|
||||
|
||||
draw_sidebar_shortcuts :: proc(screen_h: i32) {
|
||||
base_y := screen_h - 280
|
||||
if base_y < 120 {
|
||||
base_y = 120
|
||||
}
|
||||
draw_card(rl.Rectangle{x = 14, y = f32(base_y), width = 236, height = 210})
|
||||
rl.DrawText("Quick Keys", 26, base_y+12, 18, SIDEBAR_TITLE)
|
||||
draw_sidebar_shortcut_line(26, base_y+36, "F5/F6/F7/F8 generate/layout/export", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+54, "Ctrl+S save Ctrl+O open", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+72, "Ctrl+N new", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+90, "F9 next F10 auto-all", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+108, "/ full shortcut help", SIDEBAR_TEXT)
|
||||
draw_sidebar_shortcut_line(26, base_y+140, "Press / for all shortcuts", SIDEBAR_FOOTER)
|
||||
}
|
||||
|
||||
draw_confirm_overlay :: proc(action: Pending_Confirm_Action) {
|
||||
sw := rl.GetScreenWidth()
|
||||
sh := rl.GetScreenHeight()
|
||||
rec := rl.Rectangle{x = f32((sw-520)/2), y = f32((sh-230)/2), width = 520, height = 230}
|
||||
rl.DrawRectangle(0, 0, sw, sh, BG_OVERLAY)
|
||||
draw_card(rec)
|
||||
rl.DrawRectangleRounded(rl.Rectangle{x = rec.x, y = rec.y, width = rec.width, height = 8}, 0.08, 8, CONFIRM_ACCENT)
|
||||
x := i32(rec.x) + 30
|
||||
y := i32(rec.y) + 34
|
||||
rl.DrawText("Confirm destructive action", x, y, 26, CONFIRM_TITLE)
|
||||
draw_text_fitted(fmt.tprintf("You have unsaved changes. Do you want to %s?", pending_action_name(action)), x, y+42, 18, 470, 8, CONFIRM_BODY)
|
||||
rl.DrawText("Enter/Y confirm • Esc/N cancel", x, y+72, 16, CONFIRM_HINT)
|
||||
}
|
||||
165
odin/src/gui/path_helpers.odin
Normal file
165
odin/src/gui/path_helpers.odin
Normal 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
973
odin/src/gui/runtime.odin
Normal file
@ -0,0 +1,973 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import rl "vendor:raylib"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
import "../ui"
|
||||
|
||||
run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
|
||||
controller := ui.new_controller(state^)
|
||||
defer ui.dispose_job_manager(&controller.jobs)
|
||||
|
||||
rl.SetConfigFlags({.WINDOW_RESIZABLE, .WINDOW_UNDECORATED})
|
||||
rl.InitWindow(1240, 820, "comic-odin gui")
|
||||
defer rl.CloseWindow()
|
||||
monitor := rl.GetCurrentMonitor()
|
||||
monitor_pos := rl.GetMonitorPosition(monitor)
|
||||
rl.SetWindowPosition(i32(monitor_pos.x), i32(monitor_pos.y))
|
||||
rl.SetWindowSize(rl.GetMonitorWidth(monitor), rl.GetMonitorHeight(monitor))
|
||||
rl.SetWindowState({.BORDERLESS_WINDOWED_MODE})
|
||||
rl.SetTargetFPS(60)
|
||||
|
||||
selected_field := 0 // 0 idea, 1 genre, 2 audience, 3 export path, 4 local pages, 5 project path, 6 autosave interval
|
||||
export_path := "./gui_export.pdf"
|
||||
project_path := "./gui_project.comic.json"
|
||||
local_script_pages := "2"
|
||||
autosave_interval_text := "20"
|
||||
export_format: core.Export_Format = .PDF
|
||||
use_deepseek_script := false
|
||||
status_msg := fmt.aprintf("GUI ready")
|
||||
is_dirty := false
|
||||
autosave_enabled := true
|
||||
autosave_interval_s: f64 = 20
|
||||
last_autosave_at := rl.GetTime()
|
||||
last_save_at: f64 = -1
|
||||
last_export_at: f64 = -1
|
||||
action_log: Action_Log
|
||||
log_show_lines: i32 = 6
|
||||
log_oldest_first := false
|
||||
summary_opts := Summary_View_Options{}
|
||||
show_help_overlay := false
|
||||
show_confirm_overlay := false
|
||||
pending_confirm_action: Pending_Confirm_Action = .None
|
||||
push_status(&status_msg, &action_log, status_msg)
|
||||
defer action_log_dispose(&action_log)
|
||||
defer delete(status_msg)
|
||||
|
||||
for !rl.WindowShouldClose() {
|
||||
screen_w_loop := rl.GetScreenWidth()
|
||||
screen_h_loop := rl.GetScreenHeight()
|
||||
compact_mode := screen_h_loop < 860
|
||||
cfg_loop := shared.load_config()
|
||||
has_deepseek_key := len(cfg_loop.deepseek_api_key) > 0
|
||||
main_w_loop := screen_w_loop - 282 - 20
|
||||
if main_w_loop < 960 {
|
||||
main_w_loop = 960
|
||||
}
|
||||
status_w_loop := (main_w_loop - 2) / 2
|
||||
log_x_loop := 282 + status_w_loop + 2
|
||||
lower_y_loop := screen_h_loop - 252
|
||||
if lower_y_loop < 450 {
|
||||
lower_y_loop = 450
|
||||
}
|
||||
|
||||
idea_rec := rl.Rectangle{x = 420, y = 90, width = f32(main_w_loop-460), height = 36}
|
||||
genre_rec := rl.Rectangle{x = 420, y = 146, width = f32(main_w_loop-460), height = 36}
|
||||
audience_rec := rl.Rectangle{x = 420, y = 202, width = f32(main_w_loop-460), height = 36}
|
||||
export_rec := rl.Rectangle{x = 420, y = 258, width = f32(main_w_loop-460), height = 36}
|
||||
project_rec := rl.Rectangle{x = 420, y = 314, width = f32(main_w_loop-460), height = 36}
|
||||
pages_rec := rl.Rectangle{x = f32(screen_w_loop - 120), y = 22, width = 100, height = 28}
|
||||
|
||||
fmt_pdf_btn := rl.Rectangle{x = 420, y = 400, width = 80, height = 30}
|
||||
fmt_png_btn := rl.Rectangle{x = 510, y = 400, width = 80, height = 30}
|
||||
fmt_cbz_btn := rl.Rectangle{x = 600, y = 400, width = 80, height = 30}
|
||||
script_src_local_btn := rl.Rectangle{x = 800, y = 400, width = 92, height = 30}
|
||||
script_src_deepseek_btn := rl.Rectangle{x = 898, y = 400, width = 120, height = 30}
|
||||
|
||||
// Main Actions Row 1 (y=470)
|
||||
new_btn := rl.Rectangle{x = 290, y = 470, width = 140, height = 38}
|
||||
script_btn := rl.Rectangle{x = 440, y = 470, width = 230, height = 38}
|
||||
panels_btn := rl.Rectangle{x = 680, y = 470, width = 230, height = 38}
|
||||
layout_btn := rl.Rectangle{x = 920, y = 470, width = 140, height = 38}
|
||||
export_btn := rl.Rectangle{x = 1070, y = 470, width = 140, height = 38}
|
||||
|
||||
// Secondary Actions Row 2 (y=518)
|
||||
save_btn := rl.Rectangle{x = 290, y = 518, width = 160, height = 38}
|
||||
open_btn := rl.Rectangle{x = 460, y = 518, width = 160, height = 38}
|
||||
next_btn := rl.Rectangle{x = 630, y = 518, width = 160, height = 38}
|
||||
auto_btn := rl.Rectangle{x = 800, y = 518, width = 160, height = 38}
|
||||
auto_save_btn := rl.Rectangle{x = 970, y = 518, width = 240, height = 38}
|
||||
|
||||
// Utility Strip Row 3 (y=566)
|
||||
autosave_btn := rl.Rectangle{x = 290, y = 566, width = 160, height = 34}
|
||||
autosave_rec := rl.Rectangle{x = 600, y = 568, width = 70, height = 30}
|
||||
autosave_15_btn := rl.Rectangle{x = 680, y = 568, width = 44, height = 30}
|
||||
autosave_30_btn := rl.Rectangle{x = 730, y = 568, width = 44, height = 30}
|
||||
autosave_60_btn := rl.Rectangle{x = 780, y = 568, width = 44, height = 30}
|
||||
help_btn := rl.Rectangle{x = 850, y = 566, width = 110, height = 34}
|
||||
clear_field_btn := rl.Rectangle{x = 970, y = 566, width = 110, height = 34}
|
||||
reset_helpers_btn := rl.Rectangle{x = 1090, y = 566, width = 120, height = 34}
|
||||
|
||||
// Path Fixes (now below project inputs)
|
||||
export_copy_btn := rl.Rectangle{x = 420, y = 360, width = 110, height = 24}
|
||||
export_preset_btn := rl.Rectangle{x = 540, y = 360, width = 110, height = 24}
|
||||
path_fix_btn := rl.Rectangle{x = 660, y = 360, width = 110, height = 24}
|
||||
project_fix_btn := rl.Rectangle{x = 780, y = 360, width = 110, height = 24}
|
||||
project_from_export_btn := rl.Rectangle{x = 900, y = 360, width = 110, height = 24}
|
||||
export_project_btn := rl.Rectangle{x = 1020, y = 360, width = 110, height = 24}
|
||||
|
||||
log_reset_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
report_file_btn := rl.Rectangle{x = f32(log_x_loop + 92), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
log_copy_btn := rl.Rectangle{x = f32(log_x_loop + 166), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
script_copy_page_btn := rl.Rectangle{x = f32(log_x_loop + 18), y = f32(lower_y_loop + 2), width = 96, height = 26}
|
||||
script_copy_all_btn := rl.Rectangle{x = f32(log_x_loop + 120), y = f32(lower_y_loop + 2), width = 86, height = 26}
|
||||
diag_file_btn := rl.Rectangle{x = f32(log_x_loop + 240), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
status_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
log_clear_btn := rl.Rectangle{x = f32(log_x_loop + 388), y = f32(lower_y_loop + 2), width = 68, height = 26}
|
||||
diag_copy_btn := rl.Rectangle{x = f32(log_x_loop + 314), y = f32(lower_y_loop + 32), width = 68, height = 26}
|
||||
confirm_base_x := (screen_w_loop - 520) / 2
|
||||
confirm_base_y := (screen_h_loop - 230) / 2
|
||||
confirm_yes_btn := rl.Rectangle{x = f32(confirm_base_x + 180), y = f32(confirm_base_y + 154), width = 140, height = 34}
|
||||
confirm_no_btn := rl.Rectangle{x = f32(confirm_base_x + 330), y = f32(confirm_base_y + 154), width = 140, height = 34}
|
||||
path_fix_project_status_btn := rl.Rectangle{x = 638, y = 556, width = 32, height = 20}
|
||||
path_fix_export_status_btn := rl.Rectangle{x = 674, y = 556, width = 32, height = 20}
|
||||
path_fix_all_status_btn := rl.Rectangle{x = 710, y = 556, width = 34, height = 20}
|
||||
|
||||
summary_show_btn := rl.Rectangle{x = f32(282 + status_w_loop - 178), y = f32(lower_y_loop + 18), width = 78, height = 24}
|
||||
summary_sort_btn := rl.Rectangle{x = f32(282 + status_w_loop - 94), y = f32(lower_y_loop + 18), width = 78, height = 24}
|
||||
summary_prev_btn := rl.Rectangle{x = f32(300), y = f32(lower_y_loop + 18), width = 52, height = 24}
|
||||
summary_next_btn := rl.Rectangle{x = f32(358), y = f32(lower_y_loop + 18), width = 52, height = 24}
|
||||
|
||||
if rl.IsKeyPressed(.SLASH) {
|
||||
toggle_help_overlay(&show_help_overlay)
|
||||
}
|
||||
if rl.IsKeyPressed(.ESCAPE) {
|
||||
close_help_overlay_if_open(&show_help_overlay)
|
||||
}
|
||||
interaction_locked := show_help_overlay || show_confirm_overlay
|
||||
|
||||
nav_story := rl.Rectangle{x = 16, y = 90, width = 228, height = 32}
|
||||
nav_script := rl.Rectangle{x = 16, y = 126, width = 228, height = 32}
|
||||
nav_chars := rl.Rectangle{x = 16, y = 162, width = 228, height = 32}
|
||||
nav_panels := rl.Rectangle{x = 16, y = 198, width = 228, height = 32}
|
||||
nav_layout := rl.Rectangle{x = 16, y = 234, width = 228, height = 32}
|
||||
nav_bubbles := rl.Rectangle{x = 16, y = 270, width = 228, height = 32}
|
||||
nav_export := rl.Rectangle{x = 16, y = 306, width = 228, height = 32}
|
||||
nav_community := rl.Rectangle{x = 16, y = 342, width = 228, height = 32}
|
||||
|
||||
if !interaction_locked {
|
||||
if rl.IsKeyPressed(.ONE) { _ = ui.navigate_to_screen(&controller, .Story) }
|
||||
if rl.IsKeyPressed(.TWO) { _ = ui.navigate_to_screen(&controller, .Script) }
|
||||
if rl.IsKeyPressed(.THREE) { _ = ui.navigate_to_screen(&controller, .Characters) }
|
||||
if rl.IsKeyPressed(.FOUR) { _ = ui.navigate_to_screen(&controller, .Panels) }
|
||||
if rl.IsKeyPressed(.FIVE) { _ = ui.navigate_to_screen(&controller, .Layout) }
|
||||
if rl.IsKeyPressed(.SIX) { _ = ui.navigate_to_screen(&controller, .Bubbles) }
|
||||
if rl.IsKeyPressed(.SEVEN) { _ = ui.navigate_to_screen(&controller, .Export) }
|
||||
if rl.IsKeyPressed(.EIGHT) { _ = ui.navigate_to_screen(&controller, .Community) }
|
||||
|
||||
if button_clicked(nav_story) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Story))
|
||||
}
|
||||
if button_clicked(nav_script) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Script))
|
||||
}
|
||||
if button_clicked(nav_chars) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Characters))
|
||||
}
|
||||
if button_clicked(nav_panels) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Panels))
|
||||
}
|
||||
if button_clicked(nav_layout) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Layout))
|
||||
}
|
||||
if button_clicked(nav_bubbles) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Bubbles))
|
||||
}
|
||||
if button_clicked(nav_export) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Export))
|
||||
}
|
||||
if button_clicked(nav_community) {
|
||||
push_status(&status_msg, &action_log, navigate_screen_with_status(&controller, .Community))
|
||||
}
|
||||
|
||||
if rl.IsKeyPressed(.TAB) { selected_field = (selected_field + 1) % 7 }
|
||||
if rl.IsKeyPressed(.F1) { selected_field = 0 }
|
||||
if rl.IsKeyPressed(.F2) { selected_field = 1 }
|
||||
if rl.IsKeyPressed(.F3) { selected_field = 2 }
|
||||
if rl.IsKeyPressed(.F4) { selected_field = 3 }
|
||||
if rl.IsKeyPressed(.F11) { selected_field = 4 }
|
||||
if rl.IsKeyPressed(.F12) { selected_field = 5 }
|
||||
if button_clicked(idea_rec) { selected_field = 0 }
|
||||
if button_clicked(genre_rec) { selected_field = 1 }
|
||||
if button_clicked(audience_rec) { selected_field = 2 }
|
||||
if button_clicked(export_rec) { selected_field = 3 }
|
||||
if button_clicked(pages_rec) { selected_field = 4 }
|
||||
if button_clicked(project_rec) { selected_field = 5 }
|
||||
if button_clicked(autosave_rec) { selected_field = 6 }
|
||||
if button_clicked(autosave_15_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15))
|
||||
}
|
||||
if button_clicked(autosave_30_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30))
|
||||
}
|
||||
if button_clicked(autosave_60_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60))
|
||||
}
|
||||
}
|
||||
|
||||
pages_count := parse_pages_or_default(local_script_pages, 2)
|
||||
autosave_secs := parse_autosave_interval(autosave_interval_text, 20)
|
||||
autosave_interval_s = f64(autosave_secs)
|
||||
if selected_field != 6 && len(strings.trim_space(autosave_interval_text)) == 0 {
|
||||
autosave_interval_text = fmt.aprintf("%d", autosave_secs)
|
||||
}
|
||||
shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT)
|
||||
can_generate_panels := len(controller.state.script.pages) > 0
|
||||
can_layout := len(controller.state.panel_images) > 0
|
||||
can_export := len(controller.state.page_layouts) > 0 && len(controller.state.panel_images) > 0
|
||||
project_path_ok := project_path_is_normalized(project_path)
|
||||
export_path_ok := export_path_matches_format(export_path, export_format)
|
||||
|
||||
if show_confirm_overlay {
|
||||
confirm_yes := button_clicked(confirm_yes_btn) || rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.Y)
|
||||
confirm_no := button_clicked(confirm_no_btn) || rl.IsKeyPressed(.ESCAPE) || rl.IsKeyPressed(.N)
|
||||
if confirm_no {
|
||||
show_confirm_overlay = false
|
||||
pending_confirm_action = .None
|
||||
push_status(&status_msg, &action_log, "Cancelled destructive action")
|
||||
} else if confirm_yes {
|
||||
action := pending_confirm_action
|
||||
show_confirm_overlay = false
|
||||
pending_confirm_action = .None
|
||||
push_status(&status_msg, &action_log, resolve_confirm_action_with_message(action, &controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
|
||||
}
|
||||
}
|
||||
|
||||
if !interaction_locked {
|
||||
if button_clicked(export_copy_btn) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard"))
|
||||
}
|
||||
if button_clicked(export_preset_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format))
|
||||
}
|
||||
if button_clicked(path_fix_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if button_clicked(project_fix_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if button_clicked(project_from_export_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path))
|
||||
}
|
||||
if button_clicked(export_project_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format))
|
||||
}
|
||||
if button_clicked(path_fix_project_status_btn) && !project_path_ok {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if button_clicked(path_fix_export_status_btn) && !export_path_ok {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if button_clicked(path_fix_all_status_btn) && (!project_path_ok || !export_path_ok) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format))
|
||||
}
|
||||
if button_clicked(fmt_pdf_btn) {
|
||||
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PDF, &is_dirty))
|
||||
}
|
||||
if button_clicked(fmt_png_btn) {
|
||||
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .PNG, &is_dirty))
|
||||
}
|
||||
if button_clicked(fmt_cbz_btn) {
|
||||
push_status(&status_msg, &action_log, set_export_format_with_message(&export_format, &export_path, .CBZ, &is_dirty))
|
||||
}
|
||||
if button_clicked(script_src_local_btn) {
|
||||
use_deepseek_script = false
|
||||
push_status(&status_msg, &action_log, "Script source: Local")
|
||||
}
|
||||
if button_clicked(script_src_deepseek_btn) {
|
||||
if !has_deepseek_key {
|
||||
push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)")
|
||||
} else {
|
||||
use_deepseek_script = true
|
||||
push_status(&status_msg, &action_log, "Script source: DeepSeek")
|
||||
}
|
||||
}
|
||||
|
||||
if button_clicked(summary_show_btn) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if button_clicked(summary_sort_btn) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(summary_prev_btn) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor -= 1
|
||||
if summary_opts.script_page_cursor < 0 {
|
||||
summary_opts.script_page_cursor = page_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(summary_next_btn) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor += 1
|
||||
if summary_opts.script_page_cursor >= page_count {
|
||||
summary_opts.script_page_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && button_clicked(summary_prev_btn) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor -= 1
|
||||
if summary_opts.panel_cursor < 0 {
|
||||
summary_opts.panel_cursor = panel_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && button_clicked(summary_next_btn) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor += 1
|
||||
if summary_opts.panel_cursor >= panel_count {
|
||||
summary_opts.panel_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
|
||||
if button_clicked(new_btn) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, false))
|
||||
}
|
||||
}
|
||||
if button_clicked(save_btn) {
|
||||
push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project"))
|
||||
}
|
||||
if button_clicked(open_btn) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
|
||||
}
|
||||
}
|
||||
if button_clicked(auto_save_btn) {
|
||||
push_status(&status_msg, &action_log, run_auto_all_save_action(&controller, &project_path, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at, &last_autosave_at, &last_save_at))
|
||||
}
|
||||
if button_clicked(autosave_btn) {
|
||||
push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled))
|
||||
}
|
||||
if button_clicked(help_btn) {
|
||||
toggle_help_overlay(&show_help_overlay)
|
||||
}
|
||||
if button_clicked(clear_field_btn) {
|
||||
push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
|
||||
}
|
||||
if button_clicked(reset_helpers_btn) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format))
|
||||
}
|
||||
if controller.active_screen != .Script {
|
||||
if button_clicked(log_reset_btn) {
|
||||
push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first))
|
||||
}
|
||||
if button_clicked(report_file_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx))
|
||||
}
|
||||
if button_clicked(log_copy_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx))
|
||||
}
|
||||
if button_clicked(diag_file_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
if button_clicked(log_clear_btn) {
|
||||
set_status(&status_msg, clear_action_log_with_message(&action_log))
|
||||
}
|
||||
if button_clicked(status_copy_btn) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard"))
|
||||
}
|
||||
if button_clicked(diag_copy_btn) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(script_copy_page_btn) {
|
||||
page_text := build_script_page_detail_text(controller.state, summary_opts.script_page_cursor)
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(page_text, "Copied script page detail"))
|
||||
delete(page_text)
|
||||
}
|
||||
if controller.active_screen == .Script && button_clicked(script_copy_all_btn) {
|
||||
full_text := build_full_script_text(controller.state)
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(full_text, "Copied full script"))
|
||||
delete(full_text)
|
||||
}
|
||||
if button_clicked(script_btn) {
|
||||
push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty))
|
||||
}
|
||||
if button_clicked(panels_btn) {
|
||||
push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty))
|
||||
}
|
||||
if button_clicked(layout_btn) {
|
||||
push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty))
|
||||
}
|
||||
if button_clicked(export_btn) {
|
||||
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
|
||||
}
|
||||
if button_clicked(next_btn) {
|
||||
push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
if button_clicked(auto_btn) {
|
||||
push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
|
||||
if rl.IsKeyPressed(.F5) {
|
||||
push_status(&status_msg, &action_log, run_script_action(&controller, pages_count, use_deepseek_script, &is_dirty))
|
||||
}
|
||||
if rl.IsKeyPressed(.F6) {
|
||||
push_status(&status_msg, &action_log, run_panels_action(&controller, can_generate_panels, &is_dirty))
|
||||
}
|
||||
if rl.IsKeyPressed(.F7) {
|
||||
push_status(&status_msg, &action_log, run_layout_action(&controller, can_layout, &is_dirty))
|
||||
}
|
||||
if rl.IsKeyPressed(.F8) {
|
||||
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
|
||||
}
|
||||
if rl.IsKeyPressed(.F9) {
|
||||
push_status(&status_msg, &action_log, run_next_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
if rl.IsKeyPressed(.F10) {
|
||||
push_status(&status_msg, &action_log, run_auto_all_action(&controller, &export_path, export_format, pages_count, use_deepseek_script, &is_dirty, &last_export_at))
|
||||
}
|
||||
|
||||
ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)
|
||||
if ctrl_down && rl.IsKeyPressed(.S) {
|
||||
push_status(&status_msg, &action_log, save_project_session_with_message(&project_path, controller.state, &is_dirty, &last_autosave_at, &last_save_at, "Saved project"))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.N) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Reset_Project, "Confirm reset?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, reset_project_session(&controller, &is_dirty, &last_autosave_at, true))
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.O) {
|
||||
if is_dirty && !shift_down {
|
||||
push_status(&status_msg, &action_log, request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_confirm_action, .Open_Project, "Confirm open?"))
|
||||
} else {
|
||||
push_status(&status_msg, &action_log, open_project_session(&controller, &project_path, &export_path, export_format, &is_dirty, &last_autosave_at))
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.E) {
|
||||
push_status(&status_msg, &action_log, run_export_action(&controller, &export_path, export_format, can_export, &is_dirty, &last_export_at))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.G) {
|
||||
if use_deepseek_script {
|
||||
use_deepseek_script = false
|
||||
push_status(&status_msg, &action_log, "Script source: Local")
|
||||
} else {
|
||||
if !has_deepseek_key {
|
||||
push_status(&status_msg, &action_log, "DeepSeek key missing (set DEEPSEEK_API_KEY)")
|
||||
} else {
|
||||
use_deepseek_script = true
|
||||
push_status(&status_msg, &action_log, "Script source: DeepSeek")
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.H) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_show_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.J) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, toggle_summary_sort_if_supported(controller.active_screen, &summary_opts))
|
||||
}
|
||||
if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor -= 1
|
||||
if summary_opts.script_page_cursor < 0 {
|
||||
summary_opts.script_page_cursor = page_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Script && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count > 0 {
|
||||
summary_opts.script_page_cursor += 1
|
||||
if summary_opts.script_page_cursor >= page_count {
|
||||
summary_opts.script_page_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing script page %d/%d", summary_opts.script_page_cursor+1, page_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.LEFT_BRACKET) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor -= 1
|
||||
if summary_opts.panel_cursor < 0 {
|
||||
summary_opts.panel_cursor = panel_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
if controller.active_screen == .Panels && ctrl_down && rl.IsKeyPressed(.RIGHT_BRACKET) {
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count > 0 {
|
||||
summary_opts.panel_cursor += 1
|
||||
if summary_opts.panel_cursor >= panel_count {
|
||||
summary_opts.panel_cursor = 0
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.MINUS) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs-5))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.EQUAL) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, autosave_secs+5))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.SEVEN) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 15))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.EIGHT) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 30))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.NINE) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_autosave_interval_text(&autosave_interval_text, 60))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.ZERO) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, export_format))
|
||||
}
|
||||
if ctrl_down && !shift_down && rl.IsKeyPressed(.L) {
|
||||
set_status(&status_msg, clear_action_log_with_message(&action_log))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.L) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_action_log_snapshot_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.Z) {
|
||||
push_status(&status_msg, &action_log, reset_log_view_with_message(&log_show_lines, &log_oldest_first))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.W) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_session_report_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.V) {
|
||||
push_status_if_nonempty(&status_msg, &action_log, paste_clipboard_into_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
|
||||
}
|
||||
if ctrl_down && rl.IsKeyPressed(.BACKSPACE) {
|
||||
push_status(&status_msg, &action_log, clear_selected_field_with_message(selected_field, &controller.state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.I) {
|
||||
field_text := selected_field_value(selected_field, controller.state, export_path, local_script_pages, project_path, autosave_interval_text)
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(field_text, "Copied selected field to clipboard"))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.C) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(status_msg, "Copied status to clipboard"))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.Y) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, copy_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.T) {
|
||||
push_status(&status_msg, &action_log, toggle_log_lines_with_message(&log_show_lines))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.B) {
|
||||
push_status(&status_msg, &action_log, toggle_log_order_with_message(&log_oldest_first))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.R) {
|
||||
diag_ctx := make_diagnostics_action_context(&controller, &action_log, is_dirty, autosave_enabled, project_path_ok, export_path_ok, autosave_secs, project_path, export_path, log_show_lines, log_oldest_first)
|
||||
push_status(&status_msg, &action_log, write_diagnostics_with_message(diag_ctx))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.X) {
|
||||
push_status(&status_msg, &action_log, copy_text_with_status(export_path, "Copied export path to clipboard"))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.P) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_preset_with_message(&export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.D) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_export_path_from_project_with_message(&export_path, project_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.G) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, set_project_path_from_export_with_message(&project_path, export_path))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.J) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.K) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_project_path_with_message(&project_path))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.M) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.U) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, fix_all_paths_with_message(&project_path, &export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.F) {
|
||||
push_dirty_status(&is_dirty, &status_msg, &action_log, normalize_export_path_with_message(&export_path, export_format))
|
||||
}
|
||||
if ctrl_down && shift_down && rl.IsKeyPressed(.A) {
|
||||
push_status(&status_msg, &action_log, toggle_autosave_with_message(&autosave_enabled))
|
||||
}
|
||||
|
||||
if !interaction_locked {
|
||||
for {
|
||||
ch := rl.GetCharPressed()
|
||||
if ch == 0 { break }
|
||||
if ch < 32 || ch > 126 { continue }
|
||||
if selected_field == 6 && (ch < '0' || ch > '9') {
|
||||
continue
|
||||
}
|
||||
switch selected_field {
|
||||
case 0: append_char(&controller.state.story_idea, ch)
|
||||
case 1: append_char(&controller.state.story_genre, ch)
|
||||
case 2: append_char(&controller.state.target_audience, ch)
|
||||
case 3: append_char(&export_path, ch)
|
||||
case 4: append_char(&local_script_pages, ch)
|
||||
case 5: append_char(&project_path, ch)
|
||||
case 6: append_char(&autosave_interval_text, ch)
|
||||
}
|
||||
is_dirty = true
|
||||
}
|
||||
if rl.IsKeyPressed(.BACKSPACE) {
|
||||
switch selected_field {
|
||||
case 0: pop_char(&controller.state.story_idea)
|
||||
case 1: pop_char(&controller.state.story_genre)
|
||||
case 2: pop_char(&controller.state.target_audience)
|
||||
case 3: pop_char(&export_path)
|
||||
case 4: pop_char(&local_script_pages)
|
||||
case 5: pop_char(&project_path)
|
||||
case 6: pop_char(&autosave_interval_text)
|
||||
}
|
||||
is_dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
push_status_if_nonempty(&status_msg, &action_log, autosave_tick_with_message(&project_path, controller.state, autosave_enabled, &is_dirty, &last_autosave_at, &last_save_at, autosave_interval_s))
|
||||
|
||||
rl.BeginDrawing()
|
||||
rl.ClearBackground(BG_BASE)
|
||||
screen_w := rl.GetScreenWidth()
|
||||
screen_h := rl.GetScreenHeight()
|
||||
main_w := screen_w - 282 - 20
|
||||
if main_w < 960 {
|
||||
main_w = 960
|
||||
}
|
||||
|
||||
rl.DrawRectangle(0, 0, 260, screen_h, BG_SIDEBAR)
|
||||
rl.DrawRectangle(260, 0, screen_w-260, 72, BG_TOPBAR)
|
||||
rl.DrawLine(260, 72, screen_w, 72, BORDER_DIVIDER)
|
||||
rl.DrawLine(260, 0, 260, screen_h, BORDER_DIVIDER)
|
||||
|
||||
draw_card(rl.Rectangle{x = 14, y = 10, width = 236, height = 64})
|
||||
rl.DrawText("comic-odin", 24, 22, 23, BRAND_TITLE)
|
||||
rl.DrawText("Native GUI", 24, 46, 14, BRAND_SUBTITLE)
|
||||
draw_section_title(24, 68, "Screens")
|
||||
draw_card(rl.Rectangle{x = 14, y = 86, width = 236, height = 296})
|
||||
draw_nav_item(nav_story, "1 Story", controller.active_screen == .Story)
|
||||
draw_nav_item(nav_script, "2 Script", controller.active_screen == .Script)
|
||||
draw_nav_item(nav_chars, "3 Characters", controller.active_screen == .Characters)
|
||||
draw_nav_item(nav_panels, "4 Panels", controller.active_screen == .Panels)
|
||||
draw_nav_item(nav_layout, "5 Layout", controller.active_screen == .Layout)
|
||||
draw_nav_item(nav_bubbles, "6 Bubbles", controller.active_screen == .Bubbles)
|
||||
draw_nav_item(nav_export, "7 Export", controller.active_screen == .Export)
|
||||
draw_nav_item(nav_community, "8 Community", controller.active_screen == .Community)
|
||||
draw_sidebar_shortcuts(screen_h_loop)
|
||||
|
||||
// --- Pipeline Stepper in Topbar ---
|
||||
draw_card(rl.Rectangle{x = 282, y = 12, width = f32(main_w), height = 48})
|
||||
rl.DrawText(fmt.ctprintf("%s", ui.screen_name(controller.active_screen)), 300, 26, 20, BRAND_TITLE)
|
||||
|
||||
script_ok := len(controller.state.script.pages) > 0
|
||||
panels_ok := len(controller.state.panel_images) > 0
|
||||
layout_ok := len(controller.state.page_layouts) > 0
|
||||
export_ok := panels_ok && layout_ok
|
||||
|
||||
step_x := i32(460)
|
||||
step_y := i32(36)
|
||||
step_spacing := i32(140)
|
||||
|
||||
// Draw connecting lines first
|
||||
for i in 0..<3 {
|
||||
x1 := step_x + i32(i)*step_spacing + 24
|
||||
x2 := step_x + i32(i+1)*step_spacing - 24
|
||||
c := STEP_LINE_TODO
|
||||
if (i == 0 && panels_ok) || (i == 1 && layout_ok) || (i == 2 && export_ok) {
|
||||
c = STEP_LINE_DONE
|
||||
}
|
||||
rl.DrawLineEx(rl.Vector2{f32(x1), f32(step_y)}, rl.Vector2{f32(x2), f32(step_y)}, 2.0, c)
|
||||
}
|
||||
|
||||
// Helper to draw a single pipeline step
|
||||
draw_step := proc(x, y: i32, label: string, done: bool) {
|
||||
fill := STEP_TODO_FILL
|
||||
border := STEP_TODO_BORDER
|
||||
text_col := STEP_LABEL_TODO
|
||||
if done {
|
||||
fill = STEP_DONE_FILL
|
||||
border = STEP_DONE_BORDER
|
||||
text_col = STEP_LABEL_DONE
|
||||
}
|
||||
rl.DrawCircle(x, y, 10, fill)
|
||||
rl.DrawCircleLines(x, y, 10, border)
|
||||
// Draw checkmark if done
|
||||
if done {
|
||||
rl.DrawLineEx(rl.Vector2{f32(x-3), f32(y+1)}, rl.Vector2{f32(x-1), f32(y+4)}, 2.0, BG_BASE)
|
||||
rl.DrawLineEx(rl.Vector2{f32(x-1), f32(y+4)}, rl.Vector2{f32(x+4), f32(y-3)}, 2.0, BG_BASE)
|
||||
}
|
||||
draw_text_fitted(label, x - 24, y + 16, 14, 48, 7, text_col)
|
||||
}
|
||||
|
||||
draw_step(step_x, step_y, "Script", script_ok)
|
||||
draw_step(step_x + step_spacing, step_y, "Panels", panels_ok)
|
||||
draw_step(step_x + step_spacing*2, step_y, "Layout", layout_ok)
|
||||
draw_step(step_x + step_spacing*3, step_y, "Export", export_ok)
|
||||
|
||||
// Pages input on far right of topbar
|
||||
rl.DrawText("Local script pages", screen_w_loop - 250, 26, 16, TEXT_SECONDARY)
|
||||
draw_input_field(pages_rec, local_script_pages, selected_field == 4)
|
||||
|
||||
draw_card(rl.Rectangle{x = 282, y = 82, width = f32(main_w), height = 356})
|
||||
draw_card(rl.Rectangle{x = 282, y = 460, width = f32(main_w), height = 110})
|
||||
draw_section_title(300, 92, "Project Setup")
|
||||
draw_section_title(300, 470, "Actions")
|
||||
|
||||
rl.DrawText("Story Idea", 290, 96, 16, TEXT_SECONDARY)
|
||||
draw_input_field(idea_rec, controller.state.story_idea, selected_field == 0)
|
||||
|
||||
rl.DrawText("Genre", 290, 152, 16, TEXT_SECONDARY)
|
||||
draw_input_field(genre_rec, controller.state.story_genre, selected_field == 1)
|
||||
|
||||
rl.DrawText("Audience", 290, 208, 16, TEXT_SECONDARY)
|
||||
draw_input_field(audience_rec, controller.state.target_audience, selected_field == 2)
|
||||
|
||||
rl.DrawText("Export Path", 290, 264, 16, TEXT_SECONDARY)
|
||||
draw_input_field(export_rec, export_path, selected_field == 3)
|
||||
|
||||
rl.DrawText("Project Path", 290, 320, 16, TEXT_SECONDARY)
|
||||
draw_input_field(project_rec, project_path, selected_field == 5)
|
||||
|
||||
// Compact utility row below inputs
|
||||
draw_small_button(export_copy_btn, "Copy Export")
|
||||
draw_small_button(export_preset_btn, "Preset Ext")
|
||||
draw_small_button(path_fix_btn, "Fix Exp Ext")
|
||||
draw_small_button(project_fix_btn, "Fix Proj Ext")
|
||||
draw_small_button(project_from_export_btn, "Proj From Exp")
|
||||
draw_small_button(export_project_btn, "Exp From Proj")
|
||||
|
||||
rl.DrawText("Format", 290, 404, 16, TEXT_SECONDARY)
|
||||
draw_nav_item(fmt_pdf_btn, "PDF", export_format == .PDF)
|
||||
draw_nav_item(fmt_png_btn, "PNG", export_format == .PNG)
|
||||
draw_nav_item(fmt_cbz_btn, "CBZ", export_format == .CBZ)
|
||||
|
||||
rl.DrawText("Script Source", 700, 404, 16, TEXT_SECONDARY)
|
||||
draw_nav_item(script_src_local_btn, "Local", !use_deepseek_script)
|
||||
draw_nav_item(script_src_deepseek_btn, "DeepSeek", use_deepseek_script)
|
||||
if !has_deepseek_key {
|
||||
draw_summary_subline(1024, 408, "set DEEPSEEK_API_KEY", KEY_MISSING_COLOR)
|
||||
}
|
||||
|
||||
next_hint := gui_next_hint_with_source(controller, use_deepseek_script)
|
||||
recommended_label := recommended_label_from_hint(next_hint)
|
||||
script_btn_label := "Generate Script Local"
|
||||
if use_deepseek_script {
|
||||
script_btn_label = "Generate Script"
|
||||
}
|
||||
|
||||
draw_button_warning(new_btn, "New Project")
|
||||
if recommended_label == "Generate Script" || recommended_label == "Generate Script Local" {
|
||||
draw_button_recommended(script_btn, script_btn_label)
|
||||
} else {
|
||||
draw_button(script_btn, script_btn_label)
|
||||
}
|
||||
if recommended_label == "Generate Panels Local" {
|
||||
draw_button_recommended(panels_btn, "Generate Panels Local")
|
||||
} else {
|
||||
draw_button_state(panels_btn, "Generate Panels Local", can_generate_panels)
|
||||
}
|
||||
if recommended_label == "Layout" {
|
||||
draw_button_recommended(layout_btn, "Layout Pages")
|
||||
} else {
|
||||
draw_button_state(layout_btn, "Layout Pages", can_layout)
|
||||
}
|
||||
if recommended_label == "Export" {
|
||||
draw_button_recommended(export_btn, "Export")
|
||||
} else {
|
||||
draw_button_state(export_btn, "Export", can_export)
|
||||
}
|
||||
|
||||
draw_button_soft_accent(save_btn, "Save")
|
||||
draw_button_soft_accent(open_btn, "Open")
|
||||
draw_button_primary(next_btn, "Next Step")
|
||||
draw_button_primary(auto_btn, "Auto-All")
|
||||
draw_button_primary(auto_save_btn, "Auto-All + Save")
|
||||
|
||||
draw_button_soft_accent(autosave_btn, autosave_enabled ? "Autosave: yes" : "Autosave: no")
|
||||
rl.DrawText("Interval(s)", 500, 574, 16, TEXT_SECONDARY)
|
||||
draw_input_field(autosave_rec, autosave_interval_text, selected_field == 6)
|
||||
draw_small_button(autosave_15_btn, "15")
|
||||
draw_small_button(autosave_30_btn, "30")
|
||||
draw_small_button(autosave_60_btn, "60")
|
||||
draw_button_soft_accent(help_btn, "Help (/)")
|
||||
draw_button(clear_field_btn, "Clear Field")
|
||||
draw_button_soft_accent(reset_helpers_btn, "Reset Helpers")
|
||||
|
||||
label := "idea"
|
||||
if selected_field == 1 { label = "genre" }
|
||||
if selected_field == 2 { label = "audience" }
|
||||
if selected_field == 3 { label = "export path" }
|
||||
if selected_field == 4 { label = "local pages" }
|
||||
if selected_field == 5 { label = "project path" }
|
||||
if selected_field == 6 { label = "autosave interval" }
|
||||
if !compact_mode {
|
||||
hint_msg := button_readiness_hint(rl.GetMousePosition(), panels_btn, layout_btn, export_btn, can_generate_panels, can_layout, can_export)
|
||||
if len(hint_msg) > 0 {
|
||||
draw_hint_pill(rl.Rectangle{x = 1000, y = 580, width = 210, height = 22}, fmt.tprintf("%s", hint_msg), true)
|
||||
}
|
||||
}
|
||||
|
||||
draw_card(rl.Rectangle{x = 282, y = f32(lower_y_loop-140), width = f32(status_w_loop), height = 160})
|
||||
now_draw := rl.GetTime()
|
||||
status_y := lower_y_loop - 126
|
||||
rl.DrawText("Status", 300, status_y, 19, BRAND_TITLE)
|
||||
rl.DrawLine(370, status_y+10, i32(282+status_w_loop)-14, status_y+10, BORDER_DIVIDER)
|
||||
draw_text_fitted(status_msg, 300, status_y+26, 18, int(status_w_loop-36), 8, status_text_color(status_msg))
|
||||
draw_readiness_row(controller, 300, status_y+50)
|
||||
ready_count, total_count := ready_stage_count(controller)
|
||||
progress := f32(0)
|
||||
if total_count > 0 {
|
||||
progress = f32(ready_count) / f32(total_count)
|
||||
}
|
||||
draw_progress_bar(300, status_y+84, status_w_loop-26, progress)
|
||||
draw_text_fitted(fmt.tprintf("Pipeline: %d/%d", ready_count, total_count), 300, status_y+102, 14, 122, 7, TEXT_TERTIARY)
|
||||
draw_text_fitted(fmt.tprintf("Next: %s", gui_next_hint_with_source(controller, use_deepseek_script)), 430, status_y+102, 14, int(status_w_loop-166), 7, TEXT_SECONDARY)
|
||||
draw_status_badge(rl.Rectangle{x = 300, y = f32(status_y+120), width = 118, height = 22}, fmt.tprintf("Dirty: %s", yn(is_dirty)), !is_dirty)
|
||||
draw_status_badge(rl.Rectangle{x = 430, y = f32(status_y+120), width = 188, height = 22}, fmt.tprintf("Autosave: %s (%ds)", yn(autosave_enabled), autosave_secs), autosave_enabled)
|
||||
save_meta := "save: never"
|
||||
if last_save_at >= 0 {
|
||||
save_meta = fmt.tprintf("save: %.0fs", now_draw-last_save_at)
|
||||
}
|
||||
export_meta := "export: never"
|
||||
if last_export_at >= 0 {
|
||||
export_meta = fmt.tprintf("export: %.0fs", now_draw-last_export_at)
|
||||
}
|
||||
draw_text_fitted(fmt.tprintf("%s | %s", save_meta, export_meta), 630, status_y+124, 13, int(status_w_loop-26), 7, TEXT_TERTIARY)
|
||||
path_fix_project_status_btn.y = f32(status_y + 114)
|
||||
path_fix_export_status_btn.y = f32(status_y + 114)
|
||||
path_fix_all_status_btn.y = f32(status_y + 114)
|
||||
draw_small_button_state(path_fix_project_status_btn, "P", !project_path_ok)
|
||||
draw_small_button_state(path_fix_export_status_btn, "E", !export_path_ok)
|
||||
draw_small_button_state(path_fix_all_status_btn, "PE", !project_path_ok || !export_path_ok)
|
||||
|
||||
draw_screen_summary(controller, export_path, 300, lower_y_loop+12, status_w_loop-8, summary_opts)
|
||||
if controller.active_screen == .Script || controller.active_screen == .Layout || controller.active_screen == .Panels {
|
||||
if controller.active_screen == .Script || controller.active_screen == .Layout {
|
||||
show_txt := "Top"
|
||||
sort_txt := "Asc"
|
||||
if controller.active_screen == .Script {
|
||||
if summary_opts.script_show_all { show_txt = "All" }
|
||||
if summary_opts.script_desc { sort_txt = "Desc" }
|
||||
} else {
|
||||
if summary_opts.layout_show_all { show_txt = "All" }
|
||||
if summary_opts.layout_desc { sort_txt = "Desc" }
|
||||
}
|
||||
show_btn_label := "Show:Top"
|
||||
if show_txt == "All" {
|
||||
show_btn_label = "Show:All"
|
||||
}
|
||||
sort_btn_label := "Sort:Asc"
|
||||
if sort_txt == "Desc" {
|
||||
sort_btn_label = "Sort:Desc"
|
||||
}
|
||||
draw_small_button(summary_show_btn, show_btn_label)
|
||||
draw_small_button(summary_sort_btn, sort_btn_label)
|
||||
}
|
||||
if controller.active_screen == .Script {
|
||||
draw_small_button(summary_prev_btn, "< Pg")
|
||||
draw_small_button(summary_next_btn, "Pg >")
|
||||
} else if controller.active_screen == .Panels {
|
||||
draw_small_button(summary_prev_btn, "< Pn")
|
||||
draw_small_button(summary_next_btn, "Pn >")
|
||||
}
|
||||
if !compact_mode {
|
||||
hint_label := "Ctrl+[ / Ctrl+]"
|
||||
if controller.active_screen == .Script || controller.active_screen == .Layout {
|
||||
hint_label = "Ctrl+H / Ctrl+J"
|
||||
}
|
||||
draw_hint_pill(rl.Rectangle{x = f32(282 + status_w_loop - 186), y = f32(lower_y_loop + 46), width = 172, height = 20}, hint_label, false)
|
||||
}
|
||||
}
|
||||
if !compact_mode {
|
||||
draw_hint_pill(rl.Rectangle{x = 300, y = f32(lower_y_loop + 206), width = f32(status_w_loop-18), height = 24}, "Tip: New/Open show confirm modal when dirty (Shift still quick-confirms)", true)
|
||||
}
|
||||
|
||||
if controller.active_screen == .Script {
|
||||
draw_script_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.script_page_cursor)
|
||||
draw_small_button(script_copy_page_btn, "Copy Page")
|
||||
draw_small_button(script_copy_all_btn, "Copy All")
|
||||
draw_text_fitted("Ctrl+[ / Ctrl+] page nav", log_x_loop+216, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-228), 7, TEXT_TERTIARY)
|
||||
} else if controller.active_screen == .Panels {
|
||||
retry, new_cursor := draw_panels_detail_panel(controller, log_x_loop, lower_y_loop, main_w_loop-status_w_loop-2, 200, summary_opts.panel_cursor)
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if summary_opts.panel_cursor != new_cursor {
|
||||
summary_opts.panel_cursor = new_cursor
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
if retry {
|
||||
panel, _, ok := panel_by_flat_index(controller.state.script, summary_opts.panel_cursor)
|
||||
if ok {
|
||||
msg := action_regenerate_panel(&controller, panel.panel_id)
|
||||
push_status(&status_msg, &action_log, msg)
|
||||
}
|
||||
}
|
||||
|
||||
wheel := rl.GetMouseWheelMove()
|
||||
if wheel != 0 && panel_count > 0 {
|
||||
summary_opts.panel_cursor -= int(wheel)
|
||||
if summary_opts.panel_cursor < 0 {
|
||||
summary_opts.panel_cursor = 0
|
||||
}
|
||||
if summary_opts.panel_cursor >= panel_count {
|
||||
summary_opts.panel_cursor = panel_count - 1
|
||||
}
|
||||
push_status(&status_msg, &action_log, fmt.tprintf("Viewing panel %d/%d", summary_opts.panel_cursor+1, panel_count))
|
||||
}
|
||||
|
||||
draw_text_fitted("Ctrl+[ / Ctrl+] panel nav", log_x_loop+18, lower_y_loop+8, 13, int(main_w_loop-status_w_loop-34), 7, TEXT_TERTIARY)
|
||||
} else {
|
||||
draw_card(rl.Rectangle{x = f32(log_x_loop), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-2), height = 200})
|
||||
draw_section_title(log_x_loop+18, lower_y_loop+6, "Action Log")
|
||||
draw_subtle_strip(rl.Rectangle{x = f32(log_x_loop+12), y = f32(lower_y_loop), width = f32(main_w_loop-status_w_loop-26), height = 40})
|
||||
draw_small_button(log_reset_btn, "Default")
|
||||
draw_small_button(report_file_btn, "Report")
|
||||
draw_small_button(log_copy_btn, "LogCopy")
|
||||
draw_small_button(diag_file_btn, "DiagFile")
|
||||
draw_small_button(status_copy_btn, "Copy")
|
||||
draw_small_button(log_clear_btn, "Clear")
|
||||
draw_small_button(diag_copy_btn, "Diag")
|
||||
order_label := "newest"
|
||||
if log_oldest_first {
|
||||
order_label = "oldest"
|
||||
}
|
||||
draw_text_fitted(fmt.tprintf("View: %d lines, %s first", log_show_lines, order_label), log_x_loop+18, lower_y_loop+36, 13, 216, 7, TEXT_TERTIARY)
|
||||
draw_action_log(action_log, log_x_loop+18, lower_y_loop+52, log_show_lines, log_oldest_first)
|
||||
}
|
||||
draw_toast(action_log, log_x_loop+8, 70, main_w_loop-status_w_loop-18)
|
||||
if show_help_overlay {
|
||||
draw_help_overlay()
|
||||
}
|
||||
if show_confirm_overlay {
|
||||
draw_confirm_overlay(pending_confirm_action)
|
||||
draw_button_danger(confirm_yes_btn, "Confirm")
|
||||
draw_button_soft_accent(confirm_no_btn, "Cancel")
|
||||
}
|
||||
|
||||
rl.EndDrawing()
|
||||
}
|
||||
|
||||
core.dispose_state(state)
|
||||
state^ = controller.state
|
||||
controller.state = core.Comic_State{}
|
||||
return shared.ok()
|
||||
}
|
||||
212
odin/src/gui/session_helpers.odin
Normal file
212
odin/src/gui/session_helpers.odin
Normal file
@ -0,0 +1,212 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
import "../core"
|
||||
import "../shared"
|
||||
import "../ui"
|
||||
|
||||
set_status :: proc(status: ^string, msg: string) {
|
||||
delete(status^)
|
||||
status^ = fmt.aprintf("%s", msg)
|
||||
}
|
||||
|
||||
push_status :: proc(status: ^string, log: ^Action_Log, msg: string) {
|
||||
set_status(status, msg)
|
||||
action_log_push(log, status^)
|
||||
}
|
||||
|
||||
push_status_if_nonempty :: proc(status: ^string, log: ^Action_Log, msg: string) {
|
||||
if len(msg) > 0 {
|
||||
push_status(status, log, msg)
|
||||
}
|
||||
}
|
||||
|
||||
push_dirty_status :: proc(is_dirty: ^bool, status: ^string, log: ^Action_Log, msg: string) {
|
||||
is_dirty^ = true
|
||||
push_status(status, log, msg)
|
||||
}
|
||||
|
||||
screen_status_label :: proc(screen: ui.App_Screen) -> string {
|
||||
switch screen {
|
||||
case .Story: return "Story"
|
||||
case .Script: return "Script"
|
||||
case .Characters: return "Characters"
|
||||
case .Panels: return "Panels"
|
||||
case .Layout: return "Layout"
|
||||
case .Bubbles: return "Bubbles"
|
||||
case .Export: return "Export"
|
||||
case .Community: return "Community"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
navigate_screen_with_status :: proc(controller: ^ui.App_Controller, screen: ui.App_Screen) -> string {
|
||||
err := ui.navigate_to_screen(controller, screen)
|
||||
if !shared.is_ok(err) {
|
||||
return err.message
|
||||
}
|
||||
return fmt.aprintf("Screen: %s", screen_status_label(screen))
|
||||
}
|
||||
|
||||
request_confirmation :: proc(show_confirm_overlay, show_help_overlay: ^bool, pending_confirm_action: ^Pending_Confirm_Action, action: Pending_Confirm_Action, prompt: string) -> string {
|
||||
show_confirm_overlay^ = true
|
||||
show_help_overlay^ = false
|
||||
pending_confirm_action^ = action
|
||||
return prompt
|
||||
}
|
||||
|
||||
toggle_autosave_with_message :: proc(autosave_enabled: ^bool) -> string {
|
||||
autosave_enabled^ = !autosave_enabled^
|
||||
return fmt.aprintf("Autosave: %s", yn(autosave_enabled^))
|
||||
}
|
||||
|
||||
reset_helper_fields_with_message :: proc(export_path, local_script_pages, autosave_interval_text: ^string, f: core.Export_Format) -> string {
|
||||
reset_helper_fields(export_path, local_script_pages, autosave_interval_text, f)
|
||||
return "Reset helper fields to defaults"
|
||||
}
|
||||
|
||||
toggle_help_overlay :: proc(show_help_overlay: ^bool) {
|
||||
show_help_overlay^ = !show_help_overlay^
|
||||
}
|
||||
|
||||
close_help_overlay_if_open :: proc(show_help_overlay: ^bool) {
|
||||
if show_help_overlay^ {
|
||||
show_help_overlay^ = false
|
||||
}
|
||||
}
|
||||
|
||||
clear_action_log_with_message :: proc(log: ^Action_Log) -> string {
|
||||
action_log_dispose(log)
|
||||
return "Action log cleared"
|
||||
}
|
||||
|
||||
reset_log_view :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) {
|
||||
log_show_lines^ = 6
|
||||
log_oldest_first^ = false
|
||||
}
|
||||
|
||||
reset_log_view_with_message :: proc(log_show_lines: ^i32, log_oldest_first: ^bool) -> string {
|
||||
reset_log_view(log_show_lines, log_oldest_first)
|
||||
return "Reset log view"
|
||||
}
|
||||
|
||||
toggle_log_lines_with_message :: proc(log_show_lines: ^i32) -> string {
|
||||
if log_show_lines^ == 6 {
|
||||
log_show_lines^ = 4
|
||||
} else {
|
||||
log_show_lines^ = 6
|
||||
}
|
||||
return fmt.aprintf("Log lines: %d", log_show_lines^)
|
||||
}
|
||||
|
||||
toggle_log_order_with_message :: proc(log_oldest_first: ^bool) -> string {
|
||||
log_oldest_first^ = !log_oldest_first^
|
||||
order := "newest"
|
||||
if log_oldest_first^ {
|
||||
order = "oldest"
|
||||
}
|
||||
return fmt.aprintf("Log order: %s first", order)
|
||||
}
|
||||
|
||||
selected_field_value :: proc(selected_field: int, state: core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: string) -> string {
|
||||
switch selected_field {
|
||||
case 0: return state.story_idea
|
||||
case 1: return state.story_genre
|
||||
case 2: return state.target_audience
|
||||
case 3: return export_path
|
||||
case 4: return local_script_pages
|
||||
case 5: return project_path
|
||||
case 6: return autosave_interval_text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
clear_selected_field :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string) -> bool {
|
||||
switch selected_field {
|
||||
case 0:
|
||||
if len(state.story_idea) == 0 { return false }
|
||||
state.story_idea = ""
|
||||
case 1:
|
||||
if len(state.story_genre) == 0 { return false }
|
||||
state.story_genre = ""
|
||||
case 2:
|
||||
if len(state.target_audience) == 0 { return false }
|
||||
state.target_audience = ""
|
||||
case 3:
|
||||
if len(export_path^) == 0 { return false }
|
||||
export_path^ = ""
|
||||
case 4:
|
||||
if len(local_script_pages^) == 0 { return false }
|
||||
local_script_pages^ = ""
|
||||
case 5:
|
||||
if len(project_path^) == 0 { return false }
|
||||
project_path^ = ""
|
||||
case 6:
|
||||
if len(autosave_interval_text^) == 0 { return false }
|
||||
autosave_interval_text^ = ""
|
||||
case:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
clear_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string {
|
||||
if clear_selected_field(selected_field, state, export_path, local_script_pages, project_path, autosave_interval_text) {
|
||||
is_dirty^ = true
|
||||
return "Cleared selected field"
|
||||
}
|
||||
return "Selected field already empty"
|
||||
}
|
||||
|
||||
paste_clipboard_into_selected_field_with_message :: proc(selected_field: int, state: ^core.Comic_State, export_path, local_script_pages, project_path, autosave_interval_text: ^string, is_dirty: ^bool) -> string {
|
||||
clip_raw := rl.GetClipboardText()
|
||||
if clip_raw == nil {
|
||||
return ""
|
||||
}
|
||||
clip_text := fmt.aprintf("%s", clip_raw)
|
||||
switch selected_field {
|
||||
case 0: state.story_idea = clip_text
|
||||
case 1: state.story_genre = clip_text
|
||||
case 2: state.target_audience = clip_text
|
||||
case 3: export_path^ = clip_text
|
||||
case 4: local_script_pages^ = clip_text
|
||||
case 5: project_path^ = clip_text
|
||||
case 6: autosave_interval_text^ = clip_text
|
||||
}
|
||||
is_dirty^ = true
|
||||
return "Pasted clipboard into selected field"
|
||||
}
|
||||
|
||||
Action_Log :: struct {
|
||||
entries: [8]string,
|
||||
owned: [8]bool,
|
||||
entry_times: [8]f64,
|
||||
count: int,
|
||||
last_push_at: f64,
|
||||
}
|
||||
|
||||
action_log_push :: proc(log: ^Action_Log, msg: string) {
|
||||
now := rl.GetTime()
|
||||
idx := log.count % len(log.entries)
|
||||
if log.owned[idx] {
|
||||
delete(log.entries[idx])
|
||||
}
|
||||
log.entries[idx] = fmt.aprintf("%s", msg)
|
||||
log.owned[idx] = true
|
||||
log.entry_times[idx] = now
|
||||
log.count += 1
|
||||
log.last_push_at = now
|
||||
}
|
||||
|
||||
action_log_dispose :: proc(log: ^Action_Log) {
|
||||
for i in 0..<len(log.entries) {
|
||||
if log.owned[i] {
|
||||
delete(log.entries[i])
|
||||
log.owned[i] = false
|
||||
}
|
||||
log.entry_times[i] = 0
|
||||
}
|
||||
log.count = 0
|
||||
log.last_push_at = 0
|
||||
}
|
||||
443
odin/src/gui/summary_views.odin
Normal file
443
odin/src/gui/summary_views.odin
Normal file
@ -0,0 +1,443 @@
|
||||
package gui
|
||||
|
||||
import "core:fmt"
|
||||
import rl "vendor:raylib"
|
||||
import "../core"
|
||||
import "../ui"
|
||||
|
||||
draw_stat_chip :: proc(x, y: i32, label: string, value: int) {
|
||||
rec := rl.Rectangle{x = f32(x), y = f32(y), width = 80, height = 28}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, CHIP_BG)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, CHIP_BORDER)
|
||||
draw_text_fitted(label, x+10, y+8, 12, 40, 6, CHIP_TEXT)
|
||||
draw_text_fitted(fmt.tprintf("%d", value), x+56, y+6, 14, 20, 7, TEXT_PRIMARY)
|
||||
}
|
||||
|
||||
draw_readiness_chip :: proc(x, y, w: i32, label: string, ok: bool) {
|
||||
bg := UNREADY_BG
|
||||
border := UNREADY_BORDER
|
||||
fg := UNREADY_TEXT
|
||||
if ok {
|
||||
bg = READY_BG
|
||||
border = READY_BORDER
|
||||
fg = READY_TEXT
|
||||
}
|
||||
rec := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 26}
|
||||
rl.DrawRectangleRounded(rec, RADIUS_CHIP, 8, bg)
|
||||
rl.DrawRectangleRoundedLinesEx(rec, RADIUS_CHIP, 8, 1.0, border)
|
||||
prefix := "○"
|
||||
if ok {
|
||||
prefix = "●"
|
||||
}
|
||||
draw_text_fitted(fmt.tprintf("%s %s", prefix, label), x+10, y+7, 12, int(w-20), 6, fg)
|
||||
}
|
||||
|
||||
ready_stage_count :: proc(controller: ui.App_Controller) -> (ready: int, total: int) {
|
||||
script_ok := len(controller.state.script.pages) > 0
|
||||
panels_ok := len(controller.state.panel_images) > 0
|
||||
layout_ok := len(controller.state.page_layouts) > 0
|
||||
export_ok := panels_ok && layout_ok
|
||||
ready = 0
|
||||
if script_ok { ready += 1 }
|
||||
if panels_ok { ready += 1 }
|
||||
if layout_ok { ready += 1 }
|
||||
if export_ok { ready += 1 }
|
||||
return ready, 4
|
||||
}
|
||||
|
||||
draw_progress_bar :: proc(x, y, w: i32, progress: f32) {
|
||||
track := rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = 12}
|
||||
rl.DrawRectangleRounded(track, RADIUS_BAR, 8, PROGRESS_TRACK)
|
||||
fill_w := i32(f32(w) * progress)
|
||||
if fill_w > 0 {
|
||||
fill := rl.Rectangle{x = f32(x), y = f32(y), width = f32(fill_w), height = 12}
|
||||
rl.DrawRectangleRounded(fill, RADIUS_BAR, 8, PROGRESS_FILL)
|
||||
}
|
||||
}
|
||||
|
||||
draw_readiness_row :: proc(controller: ui.App_Controller, x, y: i32) {
|
||||
script_ok := len(controller.state.script.pages) > 0
|
||||
panels_ok := len(controller.state.panel_images) > 0
|
||||
layout_ok := len(controller.state.page_layouts) > 0
|
||||
export_ok := panels_ok && layout_ok
|
||||
draw_readiness_chip(x, y, 120, "Script", script_ok)
|
||||
draw_readiness_chip(x+126, y, 120, "Panels", panels_ok)
|
||||
draw_readiness_chip(x+252, y, 120, "Layout", layout_ok)
|
||||
draw_readiness_chip(x+378, y, 120, "Export", export_ok)
|
||||
}
|
||||
|
||||
export_block_reason :: proc(state: core.Comic_State) -> string {
|
||||
if len(state.panel_images) == 0 && len(state.page_layouts) == 0 {
|
||||
return "need panels + layout"
|
||||
}
|
||||
if len(state.panel_images) == 0 {
|
||||
return "need panels"
|
||||
}
|
||||
if len(state.page_layouts) == 0 {
|
||||
return "need layout"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
draw_screen_summary :: proc(controller: ui.App_Controller, export_path: string, x, y, w: i32, opts: Summary_View_Options) {
|
||||
draw_card(rl.Rectangle{x = f32(x-18), y = f32(y-12), width = f32(w), height = 200})
|
||||
rl.DrawText("Screen Summary", x, y, 22, SUMMARY_TITLE)
|
||||
chip_base := x + w - 258
|
||||
if chip_base < x+210 {
|
||||
chip_base = x + 210
|
||||
}
|
||||
draw_stat_chip(chip_base, y-4, "Pages", len(controller.state.script.pages))
|
||||
draw_stat_chip(chip_base+86, y-4, "Panels", len(controller.state.panel_images))
|
||||
draw_stat_chip(chip_base+172, y-4, "Layout", len(controller.state.page_layouts))
|
||||
|
||||
switch controller.active_screen {
|
||||
case .Story:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Idea length: %d chars", len(controller.state.story_idea)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Genre: %s", controller.state.story_genre), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Audience: %s", controller.state.target_audience), rl.DARKGRAY)
|
||||
rl.DrawText("Use Generate Script Local to begin", x, y+112, 18, SUMMARY_HINT)
|
||||
case .Script:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Title: %s", controller.state.script.title), rl.DARKGRAY)
|
||||
page_count := len(controller.state.script.pages)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Pages: %d | Characters: %d", page_count, len(controller.state.script.characters)), rl.DARKGRAY)
|
||||
if page_count == 0 {
|
||||
rl.DrawText("No script pages yet. Generate Script Local to continue.", x, y+86, 18, SUMMARY_HINT)
|
||||
} else {
|
||||
cursor := opts.script_page_cursor
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
if cursor >= page_count {
|
||||
cursor = page_count - 1
|
||||
}
|
||||
page := controller.state.script.pages[cursor]
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Viewing page %d/%d (script page #%d)", cursor+1, page_count, page.page_number), SUMMARY_ACCENT)
|
||||
draw_summary_line(x, y+100, fmt.tprintf("Panels on page: %d", len(page.panels)), rl.DARKGRAY)
|
||||
line_y := y + 124
|
||||
show_panels := len(page.panels)
|
||||
if show_panels > 2 {
|
||||
show_panels = 2
|
||||
}
|
||||
for i in 0..<show_panels {
|
||||
pn := page.panels[i]
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
draw_summary_subline(x, line_y+i32(i*28), fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-36), 7), SUMMARY_SUBLINE)
|
||||
if len(pn.dialogue) > 0 {
|
||||
draw_summary_subline(x+12, line_y+i32(i*28)+14, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-54), 7), SUMMARY_DIM)
|
||||
}
|
||||
}
|
||||
if len(page.panels) > show_panels {
|
||||
draw_summary_subline(x+w-158, y+166, fmt.tprintf("+%d more panels", len(page.panels)-show_panels), SUMMARY_ACCENT)
|
||||
}
|
||||
}
|
||||
case .Characters:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Character count: %d", len(controller.state.characters)), rl.DARKGRAY)
|
||||
rl.DrawText("Character editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
|
||||
rl.DrawText("Use script generation to populate", x, y+78, 18, rl.DARKGRAY)
|
||||
case .Panels:
|
||||
script_panel_count := count_script_panels(controller.state.script)
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Panel images: %d", len(controller.state.panel_images)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Script panels: %d", script_panel_count), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Script pages: %d", len(controller.state.script.pages)), rl.DARKGRAY)
|
||||
if script_panel_count == 0 {
|
||||
rl.DrawText("No script panels yet. Generate Script first.", x, y+112, 18, SUMMARY_HINT)
|
||||
} else {
|
||||
pidx := clamp_panel_cursor(script_panel_count, opts.panel_cursor)
|
||||
panel, page_num, _ := panel_by_flat_index(controller.state.script, pidx)
|
||||
status := "missing"
|
||||
if _, has_img := controller.state.panel_images[panel.panel_id]; has_img {
|
||||
status = "ready"
|
||||
}
|
||||
draw_summary_line(x, y+102, fmt.tprintf("Viewing panel %d/%d • page %d # %d", pidx+1, script_panel_count, page_num, panel.panel_number), SUMMARY_ACCENT)
|
||||
draw_summary_subline(x, y+124, fmt.tprintf("%s • %s", panel.panel_id, status), SUMMARY_SUBLINE)
|
||||
}
|
||||
case .Layout:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Layout pages: %d", len(controller.state.page_layouts)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Page size: %v", controller.state.page_size), rl.DARKGRAY)
|
||||
layout_show := len(controller.state.page_layouts)
|
||||
if !opts.layout_show_all && layout_show > 3 { layout_show = 3 }
|
||||
if layout_show == 0 {
|
||||
rl.DrawText("No layouts yet. Use Layout Auto after panels are ready.", x, y+86, 18, SUMMARY_HINT)
|
||||
} else {
|
||||
if opts.layout_desc {
|
||||
for i in 0..<layout_show {
|
||||
idx := len(controller.state.page_layouts)-1-i
|
||||
l := controller.state.page_layouts[idx]
|
||||
draw_summary_line(x, y+78+i32(i*22), fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels)), rl.DARKGRAY)
|
||||
}
|
||||
} else {
|
||||
for i in 0..<layout_show {
|
||||
l := controller.state.page_layouts[i]
|
||||
draw_summary_line(x, y+78+i32(i*22), fmt.tprintf("- P%d: %s (%d)", l.page_number, l.pattern_id, len(l.panels)), rl.DARKGRAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .Bubbles:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Bubble maps: %d", len(controller.state.speech_bubbles)), rl.DARKGRAY)
|
||||
rl.DrawText("Bubble editor is scaffolded", x, y+54, 18, rl.DARKGRAY)
|
||||
case .Export:
|
||||
draw_summary_line(x, y+30, fmt.tprintf("Format: %v", controller.state.export_format), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+54, fmt.tprintf("Layouts: %d | Panels: %d", len(controller.state.page_layouts), len(controller.state.panel_images)), rl.DARKGRAY)
|
||||
draw_summary_line(x, y+78, fmt.tprintf("Target: %s", export_path), rl.DARKGRAY)
|
||||
if len(controller.state.page_layouts) > 0 {
|
||||
last := controller.state.page_layouts[len(controller.state.page_layouts)-1]
|
||||
draw_summary_subline(x, y+102, fmt.tprintf("Last layout pattern: %s", last.pattern_id), SUMMARY_DIM)
|
||||
}
|
||||
reason := export_block_reason(controller.state)
|
||||
if len(reason) > 0 {
|
||||
draw_summary_line(x, y+124, fmt.tprintf("Export blocked: %s", reason), ERROR)
|
||||
} else {
|
||||
rl.DrawText("Use Export button or Ctrl+E", x, y+124, 18, SUMMARY_HINT)
|
||||
}
|
||||
case .Community:
|
||||
rl.DrawText("Community features coming soon", x, y+30, 18, rl.DARKGRAY)
|
||||
rl.DrawText("Current focus: local GUI workflows", x, y+54, 18, rl.DARKGRAY)
|
||||
}
|
||||
}
|
||||
|
||||
clamp_script_cursor :: proc(page_count, cursor: int) -> int {
|
||||
if page_count <= 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor < 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor >= page_count {
|
||||
return page_count - 1
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
clamp_panel_cursor :: proc(panel_count, cursor: int) -> int {
|
||||
if panel_count <= 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor < 0 {
|
||||
return 0
|
||||
}
|
||||
if cursor >= panel_count {
|
||||
return panel_count - 1
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
panel_by_flat_index :: proc(script: core.Comic_Script, panel_idx: int) -> (core.Panel, int, bool) {
|
||||
if panel_idx < 0 {
|
||||
return core.Panel{}, 0, false
|
||||
}
|
||||
flat := 0
|
||||
for page in script.pages {
|
||||
for panel in page.panels {
|
||||
if flat == panel_idx {
|
||||
return panel, page.page_number, true
|
||||
}
|
||||
flat += 1
|
||||
}
|
||||
}
|
||||
return core.Panel{}, 0, false
|
||||
}
|
||||
|
||||
build_script_page_detail_text :: proc(state: core.Comic_State, cursor: int) -> string {
|
||||
page_count := len(state.script.pages)
|
||||
if page_count == 0 {
|
||||
return fmt.aprintf("No script pages available.")
|
||||
}
|
||||
idx := clamp_script_cursor(page_count, cursor)
|
||||
page := state.script.pages[idx]
|
||||
out := fmt.aprintf("Title: %s\nPage %d/%d (script page #%d)\nPanels: %d", state.script.title, idx+1, page_count, page.page_number, len(page.panels))
|
||||
for pn in page.panels {
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
next := fmt.aprintf("%s\n\nPanel %d [%v]\n%s", out, pn.panel_number, pn.shot_type, desc)
|
||||
delete(out)
|
||||
out = next
|
||||
for d in pn.dialogue {
|
||||
line := fmt.aprintf("%s\n- %s: %s", out, d.speaker_id, d.text)
|
||||
delete(out)
|
||||
out = line
|
||||
}
|
||||
if len(pn.caption) > 0 {
|
||||
line := fmt.aprintf("%s\n caption: %s", out, pn.caption)
|
||||
delete(out)
|
||||
out = line
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
build_full_script_text :: proc(state: core.Comic_State) -> string {
|
||||
page_count := len(state.script.pages)
|
||||
if page_count == 0 {
|
||||
return fmt.aprintf("No script pages available.")
|
||||
}
|
||||
out := fmt.aprintf("Title: %s\nSynopsis: %s\nCharacters: %d\nPages: %d", state.script.title, state.script.synopsis, len(state.script.characters), page_count)
|
||||
for page in state.script.pages {
|
||||
head := fmt.aprintf("%s\n\n=== Page %d (%d panels) ===", out, page.page_number, len(page.panels))
|
||||
delete(out)
|
||||
out = head
|
||||
for pn in page.panels {
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
row := fmt.aprintf("%s\nPanel %d: %s", out, pn.panel_number, desc)
|
||||
delete(out)
|
||||
out = row
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
draw_script_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) {
|
||||
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
|
||||
draw_section_title(x+18, y+6, "Script Detail")
|
||||
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
|
||||
page_count := len(controller.state.script.pages)
|
||||
if page_count == 0 {
|
||||
draw_summary_line(x+18, y+46, "No script pages yet. Run Generate Script Local.", SUMMARY_HINT)
|
||||
return
|
||||
}
|
||||
idx := clamp_script_cursor(page_count, cursor)
|
||||
page := controller.state.script.pages[idx]
|
||||
draw_summary_line(x+18, y+46, fmt.tprintf("Page %d/%d (#%d) • panels %d", idx+1, page_count, page.page_number, len(page.panels)), SUMMARY_ACCENT)
|
||||
line_y := y + 70
|
||||
line_step: i32 = 20
|
||||
line_max: i32 = (h - 84) / line_step
|
||||
lines_used: i32 = 0
|
||||
for pn in page.panels {
|
||||
if lines_used >= line_max {
|
||||
break
|
||||
}
|
||||
desc := pn.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
draw_summary_subline(x+18, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("• P%d: %s", pn.panel_number, desc), int(w-40), 7), SUMMARY_SUBLINE)
|
||||
lines_used += 1
|
||||
if len(pn.dialogue) > 0 && lines_used < line_max {
|
||||
draw_summary_subline(x+30, line_y+lines_used*line_step, fit_text_for_width(fmt.tprintf("\"%s\"", pn.dialogue[0].text), int(w-52), 7), SUMMARY_DIM)
|
||||
lines_used += 1
|
||||
}
|
||||
}
|
||||
if len(page.panels) > 0 && lines_used >= line_max {
|
||||
draw_summary_subline(x+w-140, y+h-18, "…more", SUMMARY_ACCENT)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
draw_panels_detail_panel :: proc(controller: ui.App_Controller, x, y, w, h: i32, cursor: int) -> (retry_clicked: bool, new_cursor: int) {
|
||||
new_cursor = cursor
|
||||
retry_clicked = false
|
||||
|
||||
draw_card(rl.Rectangle{x = f32(x), y = f32(y), width = f32(w), height = f32(h)})
|
||||
draw_section_title(x+18, y+6, "Panel Results")
|
||||
draw_subtle_strip(rl.Rectangle{x = f32(x+12), y = f32(y), width = f32(w-24), height = 34})
|
||||
panel_count := count_script_panels(controller.state.script)
|
||||
if panel_count == 0 {
|
||||
draw_summary_line(x+18, y+46, "No script panels yet. Generate Script first.", SUMMARY_HINT)
|
||||
return
|
||||
}
|
||||
idx := clamp_panel_cursor(panel_count, cursor)
|
||||
panel, page_num, ok := panel_by_flat_index(controller.state.script, idx)
|
||||
if !ok {
|
||||
draw_summary_line(x+18, y+46, "Panel index out of range.", ERROR)
|
||||
return
|
||||
}
|
||||
img, has_img := controller.state.panel_images[panel.panel_id]
|
||||
err_msg, has_err := controller.state.panel_errors[panel.panel_id]
|
||||
status := "missing"
|
||||
status_color := WARNING
|
||||
if has_err {
|
||||
status = "error"
|
||||
status_color = ERROR
|
||||
}
|
||||
if has_img {
|
||||
status = "ready"
|
||||
status_color = SUCCESS
|
||||
}
|
||||
draw_summary_line(x+18, y+46, fmt.tprintf("Panel %d/%d • page %d # %d • %s", idx+1, panel_count, page_num, panel.panel_number, status), status_color)
|
||||
|
||||
btn_label := "Regenerate"
|
||||
if status != "ready" {
|
||||
btn_label = "Generate"
|
||||
}
|
||||
btn_rec := rl.Rectangle{x = f32(x + w - 100), y = f32(y+42), width = 80, height = 24}
|
||||
draw_small_button_state(btn_rec, btn_label, true)
|
||||
if button_clicked(btn_rec) {
|
||||
retry_clicked = true
|
||||
}
|
||||
|
||||
draw_summary_subline(x+18, y+66, fit_text_for_width(fmt.tprintf("id: %s", panel.panel_id), int(w-120), 7), SUMMARY_SUBLINE)
|
||||
if has_err {
|
||||
draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("err: %s", err_msg), int(w-36), 7), ERROR)
|
||||
} else if has_img {
|
||||
draw_summary_subline(x+18, y+84, fit_text_for_width(fmt.tprintf("img: %dx%d seed:%d", img.width, img.height, img.seed), int(w-36), 7), SUMMARY_DIM)
|
||||
} else {
|
||||
draw_summary_subline(x+18, y+84, "img: not generated", SUMMARY_DIM)
|
||||
}
|
||||
desc := panel.description
|
||||
if len(desc) == 0 {
|
||||
desc = "(no description)"
|
||||
}
|
||||
draw_summary_subline(x+18, y+104, fit_text_for_width(fmt.tprintf("desc: %s", desc), int(w-36), 7), SUMMARY_SUBLINE)
|
||||
if has_img {
|
||||
draw_summary_subline(x+18, y+124, fit_text_for_width(fmt.tprintf("src: %s", img.url), int(w-36), 7), SUMMARY_DIM)
|
||||
}
|
||||
|
||||
list_y := y + 146
|
||||
row_h: i32 = 18
|
||||
rows: i32 = (h - 154) / row_h
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
start := idx - int(rows/2)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + int(rows)
|
||||
if end > panel_count {
|
||||
end = panel_count
|
||||
start = end - int(rows)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
line: i32 = 0
|
||||
for i in start..<end {
|
||||
row_panel, row_page, row_ok := panel_by_flat_index(controller.state.script, i)
|
||||
if !row_ok {
|
||||
continue
|
||||
}
|
||||
mark := " "
|
||||
row_color := TEXT_TERTIARY
|
||||
if i == idx {
|
||||
mark = ">"
|
||||
row_color = TEXT_PRIMARY
|
||||
}
|
||||
ready := "missing"
|
||||
if _, err_exists := controller.state.panel_errors[row_panel.panel_id]; err_exists {
|
||||
ready = "error"
|
||||
}
|
||||
if _, exists := controller.state.panel_images[row_panel.panel_id]; exists {
|
||||
ready = "ready"
|
||||
}
|
||||
|
||||
row_rec := rl.Rectangle{x = f32(x+18), y = f32(list_y+line*row_h), width = f32(w-36), height = f32(row_h)}
|
||||
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && rl.IsMouseButtonPressed(.LEFT) {
|
||||
new_cursor = i
|
||||
}
|
||||
if rl.CheckCollisionPointRec(rl.GetMousePosition(), row_rec) && row_color == TEXT_TERTIARY {
|
||||
row_color = TEXT_SECONDARY
|
||||
}
|
||||
|
||||
draw_summary_subline(x+18, list_y+line*row_h+2, fit_text_for_width(fmt.tprintf("%s %02d p%d#%d %s", mark, i+1, row_page, row_panel.panel_number, ready), int(w-36), 7), row_color)
|
||||
line += 1
|
||||
}
|
||||
return
|
||||
}
|
||||
23
odin/src/gui/text_helpers.odin
Normal file
23
odin/src/gui/text_helpers.odin
Normal 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
210
odin/src/gui/theme.odin
Normal 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
17
odin/src/gui/types.odin
Normal 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
76
odin/src/gui/widgets.odin
Normal 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)
|
||||
}
|
||||
29
odin/src/shared/config.odin
Normal file
29
odin/src/shared/config.odin
Normal 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()
|
||||
}
|
||||
60
odin/src/shared/errors.odin
Normal file
60
odin/src/shared/errors.odin
Normal 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
|
||||
}
|
||||
76
odin/src/ui/controller.odin
Normal file
76
odin/src/ui/controller.odin
Normal 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
19
odin/src/ui/dispose.odin
Normal 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
105
odin/src/ui/jobs.odin
Normal 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
|
||||
}
|
||||
39
odin/src/ui/navigation.odin
Normal file
39
odin/src/ui/navigation.odin
Normal 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
60
odin/src/ui/runtime.odin
Normal 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
48
odin/src/ui/screens.odin
Normal 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
91
odin/src/ui/views.odin
Normal 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
|
||||
}
|
||||
103
odin/tests/adapters_phase2.odin
Normal file
103
odin/tests/adapters_phase2.odin
Normal 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")
|
||||
}
|
||||
526
odin/tests/app_cli_phase6.odin
Normal file
526
odin/tests/app_cli_phase6.odin
Normal 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)
|
||||
}
|
||||
}
|
||||
84
odin/tests/core_phase1.odin
Normal file
84
odin/tests/core_phase1.odin
Normal 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")
|
||||
}
|
||||
10
odin/tests/core_smoke.odin
Normal file
10
odin/tests/core_smoke.odin
Normal 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")
|
||||
}
|
||||
96
odin/tests/export_phase3.odin
Normal file
96
odin/tests/export_phase3.odin
Normal 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-")
|
||||
}
|
||||
695
odin/tests/gui_helpers_phase28.odin
Normal file
695
odin/tests/gui_helpers_phase28.odin
Normal 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")
|
||||
}
|
||||
58
odin/tests/hardening_phase5.odin
Normal file
58
odin/tests/hardening_phase5.odin
Normal 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")
|
||||
}
|
||||
56
odin/tests/storage_phase3.odin
Normal file
56
odin/tests/storage_phase3.odin
Normal 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
61
odin/tests/ui_phase4.odin
Normal 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")
|
||||
}
|
||||
45
odin/tests/ui_render_phase4.odin
Normal file
45
odin/tests/ui_render_phase4.odin
Normal 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
22
package-lock.json
generated
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user