Skip to content

Commit 9f8253d

Browse files
ghirparaclaude
andcommitted
Add Screen Layout Composer with preview accuracy, asset drag-and-drop, and locked-screen workflow
- New ScreenLayoutComposer component: click-to-add widget palette, live preview canvas, widget tree with drag-and-drop reordering, properties panel, and generated Ren'Py screen code with copy button - New lib/screenCodeGenerator.ts: pure function generating screen block code from ScreenLayoutComposition with full widget/container/positioning support - Preview accuracy: per-type rendering for all widget types (text, bar as progress track, input with cursor, image/imagebutton with actual asset display, null as dashed spacer, empty containers as dashed outlines) - Asset drag-and-drop: drag images from Image Assets panel directly onto image/imagebutton widgets in preview or onto the Image Path field in Properties to auto-fill path and show live image preview - Tree UX: child count badges, quick-add (+) button on container rows, collapse/expand all, Delete key to remove selected widget - Locked-screen workflow: composer detects when a screen name has been pasted into code (via analysis engine); shows amber banner with Go to Code and Duplicate to Edit actions; three-panel body is view-only when locked - Duplicate action: creates an editable copy with auto-incremented name (e.g. foo_screen → foo_screen (1)); available from both the banner and the Composers pane item - Composers pane: screen layouts section with in-code badge, go-to-definition arrow, duplicate, and delete buttons per item - ScreenManager (Scrns pane) is now display-only — screen creation moved entirely to the Screen Layout Composer workflow - types.ts: ScreenWidget, ScreenLayoutComposition, ScreenWidgetType types; imageDataUrl field on ScreenWidget for preview-only asset URLs - ImageManager: include application/renpy-image-dataurl in drag data so Screen Layout Composer can display dragged images immediately Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4242532 commit 9f8253d

File tree

7 files changed

+1497
-99
lines changed

7 files changed

+1497
-99
lines changed

App.tsx

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import AudioEditorView from './components/AudioEditorView';
2020
import CharacterEditorView from './components/CharacterEditorView';
2121
import SceneComposer from './components/SceneComposer';
2222
import ImageMapComposer from './components/ImageMapComposer';
23+
import ScreenLayoutComposer from './components/ScreenLayoutComposer';
2324
import MarkdownPreviewView from './components/MarkdownPreviewView';
2425
import PunchlistManager from './components/PunchlistManager';
2526
import TabContextMenu from './components/TabContextMenu';
@@ -37,7 +38,7 @@ import type {
3738
Block, BlockGroup, Link, Position, FileSystemTreeNode, EditorTab,
3839
ToastMessage, IdeSettings, Theme, ProjectImage, RenpyAudio,
3940
ClipboardState, ImageMetadata, AudioMetadata, LabelNode, Character,
40-
AppSettings, ProjectSettings, StickyNote, SceneComposition, SceneSprite, ImageMapComposition, PunchlistMetadata, MouseGestureSettings,
41+
AppSettings, ProjectSettings, StickyNote, SceneComposition, SceneSprite, ImageMapComposition, ScreenLayoutComposition, PunchlistMetadata, MouseGestureSettings,
4142
ProjectLoadResult, ScannedImageAsset, ScannedAudioAsset, SerializedSprite, SerializedSceneComposition, UserSnippet
4243
} from './types';
4344
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
@@ -379,6 +380,9 @@ const App: React.FC = () => {
379380
// ImageMap Composer State
380381
const [imagemapCompositions, setImagemapCompositions] = useImmer<Record<string, ImageMapComposition>>({});
381382

383+
// Screen Layout Composer State
384+
const [screenLayoutCompositions, setScreenLayoutCompositions] = useImmer<Record<string, ScreenLayoutComposition>>({});
385+
382386
// Punchlist State
383387
const [punchlistMetadata, setPunchlistMetadata] = useImmer<Record<string, PunchlistMetadata>>({});
384388

@@ -675,6 +679,82 @@ const App: React.FC = () => {
675679
setHasUnsavedSettings(true);
676680
}, [setImagemapCompositions, activeTabId]);
677681

682+
// --- Screen Layout Composer Management ---
683+
const handleCreateScreenLayout = useCallback((initialName?: string) => {
684+
const id = `screenlayout-${Date.now()}`;
685+
const name = initialName || `screen_${Object.keys(screenLayoutCompositions).length + 1}`;
686+
687+
setScreenLayoutCompositions(draft => {
688+
draft[id] = {
689+
screenName: name,
690+
gameWidth: 1920,
691+
gameHeight: 1080,
692+
modal: false,
693+
zorder: 0,
694+
widgets: []
695+
};
696+
});
697+
698+
setOpenTabs(prev => [...prev, { id, type: 'screen-layout-composer', layoutId: id }]);
699+
setActiveTabId(id);
700+
setHasUnsavedSettings(true);
701+
}, [screenLayoutCompositions, setScreenLayoutCompositions]);
702+
703+
const handleOpenScreenLayout = useCallback((layoutId: string) => {
704+
setOpenTabs(prev => {
705+
if (!prev.find(t => t.id === layoutId)) {
706+
return [...prev, { id: layoutId, type: 'screen-layout-composer', layoutId }];
707+
}
708+
return prev;
709+
});
710+
setActiveTabId(layoutId);
711+
}, []);
712+
713+
const handleScreenLayoutUpdate = useCallback((layoutId: string, value: React.SetStateAction<ScreenLayoutComposition>) => {
714+
setScreenLayoutCompositions(draft => {
715+
const prev = draft[layoutId] || { screenName: '', gameWidth: 1920, gameHeight: 1080, modal: false, zorder: 0, widgets: [] };
716+
const next = typeof value === 'function' ? (value as (prevState: ScreenLayoutComposition) => ScreenLayoutComposition)(prev) : value;
717+
718+
if (JSON.stringify(prev) !== JSON.stringify(next)) {
719+
draft[layoutId] = next;
720+
setHasUnsavedSettings(true);
721+
}
722+
});
723+
}, [setScreenLayoutCompositions]);
724+
725+
const handleRenameScreenLayout = useCallback((layoutId: string, newName: string) => {
726+
setScreenLayoutCompositions(draft => {
727+
if (draft[layoutId] && draft[layoutId].screenName !== newName) {
728+
draft[layoutId].screenName = newName;
729+
setHasUnsavedSettings(true);
730+
}
731+
});
732+
}, [setScreenLayoutCompositions]);
733+
734+
const handleDuplicateScreenLayout = useCallback((layoutId: string) => {
735+
const original = screenLayoutCompositions[layoutId];
736+
if (!original) return;
737+
const existingNames = new Set(Object.values(screenLayoutCompositions).map(c => c.screenName));
738+
let counter = 1;
739+
let newName = `${original.screenName} (${counter})`;
740+
while (existingNames.has(newName)) { counter++; newName = `${original.screenName} (${counter})`; }
741+
const newId = `screenlayout-${Date.now()}`;
742+
setScreenLayoutCompositions(draft => { draft[newId] = { ...original, screenName: newName }; });
743+
setHasUnsavedSettings(true);
744+
setOpenTabs(prev => {
745+
if (!prev.find(t => t.id === newId)) return [...prev, { id: newId, type: 'screen-layout-composer', layoutId: newId }];
746+
return prev;
747+
});
748+
setActiveTabId(newId);
749+
}, [screenLayoutCompositions, setScreenLayoutCompositions]);
750+
751+
const handleDeleteScreenLayout = useCallback((layoutId: string) => {
752+
setScreenLayoutCompositions(draft => { delete draft[layoutId]; });
753+
754+
setOpenTabs(prev => prev.filter(t => t.id !== layoutId));
755+
if (activeTabId === layoutId) setActiveTabId('canvas');
756+
setHasUnsavedSettings(true);
757+
}, [setScreenLayoutCompositions, activeTabId]);
678758

679759
// --- Sync Explorer with Active Tab ---
680760
useEffect(() => {
@@ -1336,6 +1416,13 @@ const App: React.FC = () => {
13361416
setImagemapCompositions({});
13371417
}
13381418

1419+
// Restore Screen Layout Compositions
1420+
if (projectData.settings.screenLayoutCompositions) {
1421+
setScreenLayoutCompositions(projectData.settings.screenLayoutCompositions);
1422+
} else {
1423+
setScreenLayoutCompositions({});
1424+
}
1425+
13391426
// Restore Scan Directories
13401427
if (projectData.settings.scannedImagePaths) {
13411428
const paths = projectData.settings.scannedImagePaths;
@@ -1507,7 +1594,7 @@ const App: React.FC = () => {
15071594
setLoadingMessage('');
15081595
setLoadingProgress(0);
15091596
}
1510-
}, [setBlocks, setImages, setAudios, updateProjectSettings, addToast, setFileSystemTree, setStickyNotes, setCharacterProfiles, updateAppSettings, setSceneCompositions, setSceneNames, setPunchlistMetadata]);
1597+
}, [setBlocks, setImages, setAudios, updateProjectSettings, addToast, setFileSystemTree, setStickyNotes, setCharacterProfiles, updateAppSettings, setSceneCompositions, setSceneNames, setPunchlistMetadata, setImagemapCompositions, setScreenLayoutCompositions]);
15111598

15121599

15131600
const handleCancelLoad = useCallback(() => {
@@ -1846,6 +1933,7 @@ const App: React.FC = () => {
18461933
sceneCompositions: serializableScenes as unknown as Record<string, SceneComposition>,
18471934
sceneNames,
18481935
imagemapCompositions: serializableImagemaps,
1936+
screenLayoutCompositions,
18491937
scannedImagePaths: Array.from(imageScanDirectories.keys()),
18501938
scannedAudioPaths: Array.from(audioScanDirectories.keys()),
18511939
};
@@ -1856,7 +1944,7 @@ const App: React.FC = () => {
18561944
console.error("Failed to save IDE settings:", e);
18571945
addToast('Failed to save workspace settings', 'error');
18581946
}
1859-
}, [projectRootPath, projectSettings, openTabs, activeTabId, splitLayout, splitPrimarySize, secondaryOpenTabs, secondaryActiveTabId, stickyNotes, characterProfiles, addToast, sceneCompositions, sceneNames, imagemapCompositions, imageScanDirectories, audioScanDirectories, punchlistMetadata]);
1947+
}, [projectRootPath, projectSettings, openTabs, activeTabId, splitLayout, splitPrimarySize, secondaryOpenTabs, secondaryActiveTabId, stickyNotes, characterProfiles, addToast, sceneCompositions, sceneNames, imagemapCompositions, screenLayoutCompositions, imageScanDirectories, audioScanDirectories, punchlistMetadata]);
18601948

18611949

18621950
const handleSaveAll = useCallback(async () => {
@@ -2816,6 +2904,7 @@ const App: React.FC = () => {
28162904
if (tab.type === 'ai-generator') return 'AI Generator';
28172905
if (tab.type === 'scene-composer') return sceneNames[tab.sceneId!] || 'Scene';
28182906
if (tab.type === 'imagemap-composer') return imagemapCompositions[tab.imagemapId!]?.screenName || 'ImageMap';
2907+
if (tab.type === 'screen-layout-composer') return screenLayoutCompositions[tab.layoutId!]?.screenName || 'Screen Layout';
28192908
if (tab.type === 'character') return `Char: ${analysisResult.characters.get(tab.characterTag!)?.name || tab.characterTag}`;
28202909
if (tab.type === 'editor') return blocks.find(b => b.id === tab.blockId)?.title || 'Untitled';
28212910
if (tab.type === 'markdown') return tab.filePath?.split('/').pop() ?? 'Markdown';
@@ -2932,6 +3021,30 @@ const App: React.FC = () => {
29323021
labels={allLabels}
29333022
/>;
29343023
}
3024+
if (tab.type === 'screen-layout-composer' && tab.layoutId) {
3025+
const composition = screenLayoutCompositions[tab.layoutId] || {
3026+
screenName: 'new_screen',
3027+
gameWidth: 1920,
3028+
gameHeight: 1080,
3029+
modal: false,
3030+
zorder: 0,
3031+
widgets: []
3032+
};
3033+
const isLayoutLocked = analysisResult.screens.has(composition.screenName);
3034+
return <ScreenLayoutComposer
3035+
composition={composition}
3036+
onCompositionChange={(val) => handleScreenLayoutUpdate(tab.layoutId!, val)}
3037+
screenName={composition.screenName}
3038+
onRenameScreen={(newName) => handleRenameScreenLayout(tab.layoutId!, newName)}
3039+
labels={Object.keys(analysisResult.labels)}
3040+
isLocked={isLayoutLocked}
3041+
onDuplicate={() => handleDuplicateScreenLayout(tab.layoutId!)}
3042+
onGoToCode={isLayoutLocked ? () => {
3043+
const def = analysisResult.screens.get(composition.screenName);
3044+
if (def) handleOpenEditor(def.definedInBlockId, def.line);
3045+
} : undefined}
3046+
/>;
3047+
}
29353048
if (tab.type === 'markdown' && tab.filePath) {
29363049
return <MarkdownPreviewView
29373050
filePath={tab.filePath}
@@ -3235,7 +3348,6 @@ const App: React.FC = () => {
32353348
}
32363349
}}
32373350
onFindVariableUsages={(name) => handleFindUsages(name, 'variable')}
3238-
onAddScreen={(name) => handleCreateBlockConfirm(name, 'screen', 'game')}
32393351
onFindScreenDefinition={(name) => {
32403352
const def = analysisResult.screens.get(name);
32413353
if (def) handleOpenEditor(def.definedInBlockId, def.line);
@@ -3398,6 +3510,12 @@ const App: React.FC = () => {
33983510
onOpenImageMap={handleOpenImageMap}
33993511
onCreateImageMap={handleCreateImageMap}
34003512
onDeleteImageMap={handleDeleteImageMap}
3513+
// Screen Layout Props
3514+
screenLayouts={Object.keys(screenLayoutCompositions).map(id => ({ id, name: screenLayoutCompositions[id]?.screenName || 'Screen Layout' }))}
3515+
onOpenScreenLayout={handleOpenScreenLayout}
3516+
onCreateScreenLayout={handleCreateScreenLayout}
3517+
onDeleteScreenLayout={handleDeleteScreenLayout}
3518+
onDuplicateScreenLayout={handleDuplicateScreenLayout}
34013519
// Snippet Props
34023520
snippetCategoriesState={snippetCategoriesState}
34033521
onToggleSnippetCategory={handleToggleSnippetCategory}

components/ImageManager.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,9 @@ const ImageManager: React.FC<ImageManagerProps> = ({ images, metadata, scanDirec
178178
text: `show ${imageTag}`
179179
}));
180180
e.dataTransfer.setData('text/plain', `show ${imageTag}`);
181-
// Add specific path for Scene Composer drop target
181+
// Add specific path for Scene Composer / Screen Layout Composer drop targets
182182
e.dataTransfer.setData('application/renpy-image-path', image.filePath);
183+
if (image.dataUrl) e.dataTransfer.setData('application/renpy-image-dataurl', image.dataUrl);
183184
e.dataTransfer.effectAllowed = 'copy';
184185
};
185186

0 commit comments

Comments
 (0)