2 Commits
Author SHA1 Message Date
ryan ce46b5d97e feat: harmonize Prop and Stickman editor UI 2026-10-24 01:02:11 -04:00
ryan ddb298f631 Add visual harmonization tests for editor UI components and ground finish chooser
- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency.
- Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values.
- Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage.
- Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
2026-10-23 15:20:12 -04:00
69 changed files with 4913 additions and 596 deletions
+9
View File
@@ -5,6 +5,15 @@ model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
## 0. Editor Visual Style Guide
Before making, mocking, reviewing, or suggesting any Prop Editor or Stickman Editor UI/UX
change, read `docs/guides/editor_visual_style_guide.md`. Use its documented implemented
patterns and shared theme ownership as the starting point, then check relevant feature specs
and current controls for workflow-specific behavior. Clearly distinguish current behavior
from recommendations. If a proposal intentionally departs from the guide, explain why and
identify the departure in the proposal or spec. Follow explicit user direction when it differs.
## 1. Kid-Friendly UI Guardrails (10-Year-Old Persona)
* **Shared Stage Inspector UI:** Before proposing an in-stage selected-object inspector, read
+2
View File
@@ -7,6 +7,7 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)**
- `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness.
- Before proposing, mocking, reviewing, or suggesting UI/UX changes, `@uiux` reads `docs/guides/editor_visual_style_guide.md` and checks the relevant implemented controls/specs. Treat the guide as the current visual baseline; distinguish implemented behavior from recommendations and explain intentional departures. This also applies to direct `@uiux` requests outside the pipeline.
- For an in-stage selected-object inspector, `@uiux` first reviews `docs/specs/stage_panel_ui_system.md` and the current shared controls under `scripts/shared/stage_ui/`; proposals should build on that system unless the interaction needs justify a specific exception.
- Follow the concept-count and visual-mockup protocol in `.codex/agents/uiux.toml`; it also applies when the user asks `@uiux` for ideas outside a formal pipeline run.
- `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
@@ -15,6 +16,7 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
2. **Phase 0b: UI Spec Generation**
- User approves an option or provides feedback.
- `@uiux` outputs the UI/UX specification (control layouts, widget behaviors, visual feedback states, and cursor logic).
- Carry forward the style guide's applicable patterns and token ownership. If the approved direction departs from them, record the reason and the specific difference in the spec.
- For Stage inspector work, name which shared components and theme tokens the design uses or proposes to extend. Treat dialogs and other editor panels as separate workflows unless their interaction model fits the inspector kit.
3. **Phase 1a: Architecture Proposal & Clarification**
+4
View File
@@ -78,6 +78,10 @@ the configured `run/main_scene`.
custom UI/UX agent defined in `.codex/agents/uiux.toml`. Treat that prefix as an explicit
delegation request. The UI/UX agent is read-only; carry its design guidance into implementation
separately when implementation is requested.
- Before making, mocking, reviewing, or suggesting Prop or Stickman UI/UX changes, have `@uiux`
read `docs/guides/editor_visual_style_guide.md` and consult the relevant implemented patterns
and feature specs. Treat the guide as the current visual baseline; call out intentional
departures and distinguish current behavior from recommendations.
- For new or revised child-facing dialogs, popups, and short setup flows, follow the
[Dialog Design Principles](docs/specs/dialog_design_principles.md). Use rounded containers
and controls, clear borders, blue primary actions with bold white labels, comfortable button
@@ -88,6 +88,9 @@ This document tracks known technical debt, optimization opportunities, and minor
| 76 | **Camera-aware Sky redraws vector motifs while framing changes** — the former static Sky drew motifs only when the preset or viewport changed. | Low | Planned | The Phase 19 Sky addition keeps one screen-space `StageSkyView`, redraws only when a bound camera's effective framing changes, and caches reusable cloud/moon curve samples where practical. Measure sustained pan/zoom cost in a rendered Stage before adding a raster or shader path. The fixed reference frame and bounded response can saturate at distant camera positions; that is an accepted composition trade-off, not scene state to persist. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md#phase-19-addition-camera-aware-sky-details`. (2026-10-21) |
| 77 | **Starter surfaces guarantee a finite pan envelope, not arbitrary camera framing** — the two bundled sets use a fixed authored width to keep their scene data simple. | Low | Implemented; visual review pending | The authored envelope is a 1,280-logical-pixel viewport at zoom 0.5, ±1,000 world units of travel, and 100 units of margin per side. User review prompted an opaque Town Grass Background ground and separate scale-1 tile rows. Twenty-five 192-unit tiles cover the 4,760-unit minimum; Asphalt has 27 tiles (5,184 units) so the road extends one tile beyond the sidewalk at both ends. Navigation merges contiguous tile tops and routes the 32-unit curb step at the exposed ends without merging Ground objects or colliders. The focused suite passes 515 assertions, including the curb step and PLAY landing. The camera remains unrestricted, so wider viewports, farther pans, and zoomed-out shots can reveal finite ends. Rendered seam review remains pending. See docs/specs/phase_19_starter_scene_composition.md. (2026-10-21)
| 78 | **Plain Background adds a second backdrop kind while Sky remains a separate renderer** — live Stage, starter previews, and chooser cards must agree on kind and remembered color. | Medium | Spec prepared; implementation pending | Phase 19 composes one shared `StageBackgroundView` from the existing Sky view and an opaque fill. Scene v1.7 stores active kind, last Sky ID, and confirmed plain color; all older scenes migrate as Sky. `SandboxStage` owns the transient custom-picker draft, which must never enter Save or autosave. The existing chooser's four-Sky layout and 42 px compact swatches need targeted extension, and grid/gizmo contrast needs theme-backed treatment for light and dark fills. Rendered pan/zoom/resize, legacy migration, and every draft-cancel exit remain verification gates. See `docs/specs/phase_19_plain_background.md`. (2026-10-22) |
| 79 | **Editor chrome has two existing token sources and scene-level literals** — PropTheme owns current Prop UI values while Stickman scenes hardcode bars, labels, and borders. | Medium | Spec prepared; implementation pending | Add one validated `EditorVisualTheme` source for shared semantic chrome and editor-local `Theme` instances. Retain PropTheme for prop material/primitive/canvas values and keep Stickman canvas drawing tokens in a separate namespace. Migrate legacy Prop `ui` accessors deliberately so a chrome role has one final authority; use SandboxTheme values as a visual reference without a runtime dependency or Stage inspector component reuse. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
| 80 | **Stickman panel nodes currently own both document shapes and local view state** — rebuilding a panel for a focused layout would reset selection, zoom, and pan and risk save/preview drift. | Medium | Spec prepared; implementation pending | Move the same ten BodyPartPanel instances between Overview and Focus hosts, retain one permanent WholeStickmanPreview sibling, and defer switching while a drag or popup is active. The controller owns session-only view choice and active part; manually chosen views survive resize, and narrow Overview remains scrollable. A future document-model extraction is warranted only if new editor features make live-node ownership limiting. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
| 81 | **Stickman authored transforms lack a complete dirty-state boundary** — preview translation emits `part_moved`, while rotation and scale currently have no commit signal; Save/Load/Clear do not track unsaved work. | Medium | Spec prepared; implementation pending | Add a controller-observable transform commit path for position, rotation, and scale, plus `_dirty`/`_current_path` and Save/Discard Changes/Cancel transactions for replacement and close. Keep Overview/Focus, guide, grid, selection, and zoom/pan as session UI state that never marks dirty. Preserve the `.stk` format. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
---
+386
View File
@@ -0,0 +1,386 @@
# Editor Visual Style Guide
**Scope:** Establish a practical visual and interaction direction for the Prop Editor and Stickman Editor, using the Sandbox Stage UI as a product reference.
**Status:** Guidance for current and future editor UI. Prop and Stickman share `EditorVisualTheme` for editor chrome and selected reusable controls; Stage-only layouts and components remain Stage-specific unless explicitly generalized.
## 1. Product direction
Both editors should feel like approachable creative workspaces in the same Stickman Studio product:
- Keep the canvas or preview as the main work surface. Put tools and settings around it, not on top of the artwork unless a temporary task requires an overlay.
- Make the current task and mode easy to recognize. Use direct labels such as **Arrange**, **Reshape**, **Attach**, **Save**, and **Cancel**.
- Prefer visible feedback on the object over abstract state: selection outlines, vertex handles, previews, mode coloring, and status text.
- Preserve a stable layout while the child works. Opening an inspector section, showing help, or changing a mode should not make the active control jump away from the pointer.
- Make recovery and destructive actions predictable. Show what is about to happen and keep a visible way to cancel.
- Share a visual language, not necessarily identical panel layouts. A vector canvas, a modal dialog, an in-stage inspector card, and a stickman-part panel have different jobs.
The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-facing language, control states, safe viewport behavior, and theme tokens. Its in-stage inspector and capture bar are Stage patterns; they should be adapted only when an editor workflow has the same interaction needs.
## 2. Current implementation map
| Surface | Current implementation | Reuse implication |
| --- | --- | --- |
| Sandbox Stage | SandboxTheme loads validated tokens from [sandbox_theme.json](../../sandbox_theme.json). Stage inspector controls live under [scripts/shared/stage_ui](../../scripts/shared/stage_ui/). | Use as the product reference for semantic colors, readable type, focus, action roles, and reusable theme access. |
| Stage inspector | The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) targets in-stage selected-object cards. | Do not insert StageInspectorPanel into editor inspectors by default. Borrow fitting rules and state behavior; use its components only if the layout and ownership match. |
| Stage and editor dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) draws the shared confirmation card over the active scene while retaining native dialog signals. The style reads `popups.confirmation` from SandboxTheme. | Use this shared card for Prop and Stickman confirmation, recovery, and short action dialogs. Native file picker content remains native. |
| Shared editor chrome | [EditorVisualTheme](../../scripts/shared/editor_ui/editor_visual_theme.gd) loads validated tokens from [editor_visual_theme.json](../../editor_visual_theme.json) and builds each editor's local control Theme. `EditorSelectionDropdown` and `EditorContextMenu` supply shared selection and canvas-menu styling. | Use shared semantic tokens and controls for matching editor workflows. Keep editor-specific document behavior in its owner. |
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json) for material, geometry, and canvas defaults; editor chrome comes from `EditorVisualTheme`. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep Prop artwork/material values and canvas contrast in `PropTheme`; use shared editor tokens for chrome, inspector states, and selection dropdown styling. |
| Stickman Editor | [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd) uses `EditorVisualTheme` for chrome and coordinates ten BodyPartPanels in a two-column Overview beside the Whole Stickman preview, plus a Focus view for one large panel. | Overview columns and panels expand to the workspace size, retaining a 230 × 160 px minimum and scrolling when space is short. Keep the splitter draggable, preserve panel state, and show the part title inside its panel in both views. |
| Child-facing dialogs | [Dialog Design Principles](../specs/dialog_design_principles.md) documents the rounded, labeled-action pattern and stable resizing behavior. | Apply these rules to editor dialogs, adapting ownership and host behavior to each editor. |
The Prop Editor user guide at [prop_editor_user_guide.md](prop_editor_user_guide.md) describes its current controls and layout. Use it to avoid documenting proposed changes as existing behavior.
## 3. Shared visual system
### 3.1 Semantic color roles
Use color to reinforce meaning consistently. Keep labels, shapes, icons, or state text alongside color so meaning survives grayscale, low contrast, and color-vision differences.
| Role | Sandbox Stage reference | Editor guidance |
| --- | --- | --- |
| Main background | global.bg_color #171B24F2 | Use a dark neutral around the work area; avoid competing with authored art. |
| Raised surface | global.surface_color #252B38FA | Use for toolbars, inspector shells, and cards. Distinguish adjacent surfaces by value, border, or spacing. |
| Primary text | global.text_color #F5F7FA | Use for titles, control names, and important values. |
| Secondary text | global.muted_text_color #AAB2C0 | Use for helper text and secondary status; keep readable at the chosen size. |
| EDIT accent | mode_colors.edit #22C6FF | Shared editor accent for active modes, selection accents, and editor guidance through `EditorVisualTheme`. |
| DIRECT accent | mode_colors.direct #FFB300 | Reserved for the Stage's directing mode. Do not use as a generic warning color in editors. |
| PLAY accent | mode_colors.play #33DD77 | Reserved for Stage playback state. |
| Primary action | Stage dialog/action blue #087DBA | Use for the single most important forward action in a dialog. |
| Destructive action | Stage danger #E66B75 with dark red surfaces | Use only for destructive actions; pair with explicit wording and a cancel path. |
| Focus | Stage dialog focus #8BD9FF | Give keyboard focus an outline that remains visible over selected, hovered, and dark states. |
| Canvas grid | Stage tokens vary with canvas treatment; Prop canvas currently uses #FFFFFF26. | Keep grid lines subtle. The work, selection, and handles must dominate the grid. |
These values are current Stage reference values, not a mandate to paste literal hex values into editor controls. Resolve colors through validated theme accessors. If an editor needs a warmer or quieter local accent, define a semantic editor token and document its mapping to Stage meaning.
### 3.2 Shared chrome and intentional editor differences
Both standalone editors now use `EditorVisualTheme` for shared chrome roles, sizing, and local control Themes. Keep these domain-specific values in their existing owner:
- `PropTheme` retains material tints, primitive defaults, outline widths, and the Prop canvas surface/grid. Keep its neutral charcoal canvas distinct from the blue-tinted editor panels. Stickman canvas grid and selection colors remain in `EditorVisualTheme.stickman_canvas` tokens.
- `EditorVisualTheme` owns common editor surfaces, text, EDIT accent, focus, action roles, menu sizes, and minimum targets. `SandboxTheme` continues to own Stage-specific panels and dialog tokens.
Preserve these boundaries when extending the UI: share semantic chrome roles where workflows match, and keep canvas, material, and runtime-domain colors local to their owner.
### 3.3 Type, spacing, and shape
Use these Stage examples as starting points, then tune them for the denser editor workspace:
- Stage inspector hierarchy: title **24 px**, section **20 px**, body **20 px**, helper **16 px**.
- Stage inspector shell: **12 px** padding, **8 px** header gap, **12 px** section gap, **10 px** corner radius.
- Stage choice row: **56 px** minimum height; Stage toggle row: **64 px** minimum, increasing when helper text is visible.
- Large child-facing dialogs: title about **28–30 px**, body about **20 px**, button about **20 px**, **2 px** borders, **12–18 px** corner radii, and generous footer spacing.
- Stage generally uses dark surfaces with a restrained border. The dialog guide's stronger borders and rounder controls are for modal emphasis, not a requirement to put every editor widget inside a thick outline.
For compact inspectors, shorten the hierarchy only after checking it at the intended window size. Prefer a clear label and value over shrinking type until it becomes difficult to read. Use container spacing and alignment rather than per-control position nudges.
### 3.4 State styling contract
Every interactive control should have distinct, inspectable states:
| State | Expected feedback |
| --- | --- |
| Neutral | Stable surface and legible label; no implied selection. |
| Hover | Small surface or border change. Must not resemble selected or focused. |
| Selected / active | Persistent fill, border, pressed state, or explicit status. Keep the label unchanged when possible. |
| Keyboard focus | High-contrast focus ring or border that is visible even over selected fill. |
| Disabled | Control cannot activate, remains readable, and explains why when the reason is not obvious. |
| Dragging / live preview | Immediate object feedback; indicate the active handle or preview and make cancellation discoverable. |
| Error / invalid | Explain the problem near the affected task and offer a concrete next step. Do not rely on red alone. |
| Saved / dirty | Show a concise, persistent status such as **Saved** / **Unsaved changes** where it helps prevent lost work. |
For drawing handles, keep a contrasting backing/halo where the canvas can contain arbitrary colors. The Prop canvas's two-tone selection halo and the Stage gizmo backing are useful precedents. Selection should be readable at all zoom levels; keep handle sizes in screen space when scaling the canvas.
## 4. Layout and reuse rules
### 4.1 Use the right pattern for the job
- **In-stage selected-object inspector:** follow StageInspectorPanel, choice groups, and toggle rows in the [Stage UI spec](../specs/stage_panel_ui_system.md). Its title/Close header, fixed header with scrolling body, and safe-rect fitting are specific to a floating selection card.
- **Editor inspector:** favor a persistent column beside the canvas, with labeled groups and vertical scrolling. The Prop Editor already uses a split container and scrollable inspector. Separate sections with rounded cards, readable headings, colorful section icons, and concise helper copy; keep related values grouped in compact tiles. Reuse Stage's hierarchy and state clarity, but do not force the floating Stage card structure into this column.
- **Temporary targeting step:** the Stage capture bar is for a short-lived click-on-stage task. An editor can borrow the prompt-plus-visible-Cancel contract when it has a similar temporary canvas capture step, but should not copy the bar just because it is visible in Stage.
- **Modal dialog:** follow [Dialog Design Principles](../specs/dialog_design_principles.md). Keep a labeled **Cancel**, **Done**, or **Back** action; use one visually clear primary action; preserve the clicked control's anchor when content grows; cap height and scroll content on short viewports.
- **Canvas tools:** keep mode and document controls outside the draw area where possible. Put contextual handles and temporary prompts near the object only if they do not obscure the task.
### 4.2 Reuse through tokens and small components
1. Put repeated colors, font sizes, minimum sizes, spacing, radii, border widths, and state colors in a validated theme source.
2. Use typed accessors with sensible fallbacks. Avoid repeating raw values across script-built controls and .tscn files.
3. Apply per-control StyleBox overrides. Do not mutate a cached/shared Theme to style one editor or one accent variant.
4. Extract a reusable control only after the behavior or presentation appears in at least two appropriate contexts. Keep domain rules and document mutations in the editor controller.
5. Prefer native Button, ButtonGroup, CheckBox, OptionButton, and LineEdit semantics. Custom-drawn controls should separate visual drawing from input and expose a clear typed API.
6. Keep canvas-specific rendering tokens separate from chrome tokens. Grid, artwork, shape fill, selection halo, vertex handles, and preview ghosts have different contrast jobs.
7. Do not serialize UI state unless it is part of the authored document. Tool preference, document content, preview-only state, and persistence should be deliberately separated.
Both standalone editors build their chrome Theme from `EditorVisualTheme`; Prop also uses `PropTheme` for prop-specific art and material defaults. Stage remains the reference for fitting, dialog hierarchy, and child-facing language, but its in-stage inspector layout is not a default editor-inspector component.
### 4.3 Responsive workspace behavior
- Preserve canvas priority. When space is constrained, inspector content scrolls before drawing tools or essential canvas controls are clipped.
- Keep splitters draggable and give both sides a useful minimum. Prop Editor has the outer canvas/inspector split and an Attach preview split; these are good workspace patterns to retain.
- Use container-driven rows (FlowContainer, HBoxContainer, VBoxContainer) instead of fixed offsets for control groups that can wrap or grow with theme fonts.
- Keep top actions and mode controls reachable at smaller window sizes. Wrap or stack controls only when necessary, and preserve their order and mode meaning.
- Keep toolbars, status bars, and dialogs inside safe margins. A dynamic dialog should grow from a stable anchor and scroll rather than jump or recenter after each choice.
- Stickman Overview uses two equal, flexible columns of five body-part drawing panels beside the Whole Stickman preview. Let both columns fill the Overview pane and share extra width; let the five rows share extra height while preserving a 230 × 160 px minimum panel size. Scroll the Overview when the viewport is shorter than its minimum content size.
- Keep the Overview/preview splitter draggable. On narrow windows, use the existing Focus view to give one body-part panel the available space; keep Whole Stickman access available through its dedicated control. Switching views must preserve each panel instance, authored geometry, selection, and view transform. In Focus, use the title inside the active panel as the only canvas heading; do not repeat it above the panel.
- Validate the layout at wide and narrow windows, with raised fonts and resized panels, before claiming responsive behavior.
### 4.4 Shared confirmation dialog style
Use the Sandbox Stage confirmation card for child-facing confirmation, recovery, and short action dialogs in both standalone editors. The shared implementation is `StageDialogStyler`, with color, sizing, and spacing values from `SandboxTheme.popups.confirmation`.
- Draw a dim backdrop and a centered, rounded dark card. Use the themed card width (560 px target), 22 px inset, 18 px corners, and 2 px border; fit the card inside the safe viewport.
- Put a contextual icon badge and a left-aligned 28 px title in the header. Put the message in a left-aligned 20 px body. Wrap longer copy and scroll the body if the viewport is short.
- Order actions as primary, destructive, then **Cancel**. Use Stage blue for Save, Restore, and other forward actions; use the danger treatment for Discard, Start Over, and other destructive choices; use the neutral surface for Cancel. Make action labels at least 52 px high and 112 px wide, with 12 px gaps.
- Keep the visible **Cancel** action even when Escape is supported. Do not show a native title-bar **×**. Focus the primary action when present; for a destructive-only prompt, focus Cancel.
- Keep the native `AcceptDialog` or `ConfirmationDialog` as the signal source and open its shared overlay through `StageDialogStyler`. This keeps confirmation, custom-action, cancellation, keyboard, and safe-viewport behavior consistent.
- Apply this pattern to Prop recovery, unsaved changes before New/Close/Stage return, off-origin save choices, anchor rename, and error acknowledgement; apply it to Stickman Start Over, unsaved changes, grid setup, and error acknowledgement. File pickers keep their native file-selection interface.
Treat the card as one shared product pattern. Do not create editor-local confirmation palettes or button-role variants.
## 5. Editor flows and state maps
The following maps describe the current interaction architecture at a level useful for future visual work. They are not proposed additions to file formats.
### 5.1 Prop Editor flow
Current primary flow, based on [prop_editor.gd](../../scripts/editors/prop/prop_editor.gd) and [prop_canvas.gd](../../scripts/editors/prop/prop_canvas.gd):
~~~mermaid
flowchart TD
Open[Open Prop Editor] --> Recovery{Recover autosave?}
Recovery -->|Restore| Dirty[Restore document as dirty]
Recovery -->|Discard / none| New[New or loaded document]
Dirty --> Arrange[Arrange shapes]
New --> Arrange
Arrange --> Reshape[Reshape vertices]
Arrange --> Attach[Attach anchors and preview]
Reshape --> Arrange
Attach --> Arrange
Attach --> Reshape
Arrange --> Save[Save / Save As]
Reshape --> Save
Attach --> Save
Save --> Clean[Saved status; autosave cleared]
Arrange --> Exit{New / close with changes?}
Reshape --> Exit
Attach --> Exit
Exit -->|Save| Save
Exit -->|Discard| Close[Close or start new]
Exit -->|Cancel| CurrentMode[Return to current mode]
~~~
Presentation rules for this flow:
- Arrange, Reshape, and Attach are persistent tool modes. Keep the active mode visually selected and keep mode labels stable.
- Mode changes may change available controls and inspector sections. Explain missing controls through a short label or helper only when the reason is not obvious from the mode.
- Shape/anchor edits change the document and should visibly change dirty status. Preview motion, visibility toggles, and other session-only controls should not imply the document was edited.
- Saving should confirm completion in the status area or title state. Errors should identify the failed file action and retain the child's work.
- New and close with unsaved changes offer explicit **Save**, **Discard**, and **Cancel** outcomes. For actions that could replace work, name the consequence in the prompt.
- In Attach mode, show the selected anchor and its editable handles clearly on the canvas, and keep the private stickman/prop preview visually separate from the authoring surface.
### 5.2 Stickman Editor flow
Current primary flow, based on [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd), [body_part_panel.gd](../../scripts/editors/stickman/body_part_panel.gd), and [whole_stickman_preview.gd](../../scripts/editors/stickman/whole_stickman_preview.gd):
~~~mermaid
flowchart LR
LoadOrStart[Load or start stickman] --> Parts[Choose a body-part drawing panel]
Parts --> Draw[Draw and color one or more shapes]
Draw --> Select[Select shape or vertex]
Select --> Preview[Inspect whole-stickman assembly]
Preview --> Guide[Toggle pose guide / reset views]
Guide --> Parts
Parts --> Save[Save stickman]
Save --> Continue[Continue editing]
LoadOrStart --> Clear[Clear with confirmation]
Clear --> Parts
~~~
Presentation rules for this flow:
- Make the active body-part panel and active shape unambiguous. Selecting a shape in one panel clears selection in the others.
- Keep local drawing context and the assembled Whole Stickman preview distinct but visually related. A change in one body part should have a prompt, visible effect in the assembly preview.
- Use the pose guide as quiet construction help beneath selection gizmos. Guide colors distinguish left/right limbs; the guide must not capture pointer input or obscure active edits.
- Keep color editing and recent colors close to the relevant part, and confirm a color only through the explicit dialog action.
- Keep grid/snap and guide visibility available as tool preferences, visually separated from authored shape color and geometry.
### 5.3 Reusable dialog transaction
For either editor, the expected child-facing sequence is:
1. State the action in the title and one short sentence.
2. Show the effect or consequence when it may be surprising.
3. Present one primary action, any needed destructive action, and a visible **Cancel**. If the only forward-looking choice is destructive (such as Start Over), keep it in the danger role and focus Cancel initially.
4. Keep focus in the dialog, preserve the current anchor during resize, and return focus to the invoking control after close where practical.
5. On success, close the dialog and update the affected editor state once. On cancel, leave the document unchanged.
This is a target interaction contract for editor dialogs. Existing controls should be checked individually before being treated as compliant.
## 6. Interaction, feedback, and child-facing language
### 6.1 Input and feedback
- Support mouse and touch-size targets; target at least **44 × 44 logical pixels** for child-facing actions. Prefer Stage's **48 px** action target where the layout allows.
- Keep keyboard navigation visible and complete: Tab reaches meaningful controls, Enter/Space activates them, Escape cancels temporary tasks, and focus does not disappear behind a selected style.
- Keep right-click and keyboard shortcuts as optional accelerators. A visible menu item or button should remain available for important operations.
- Keep the top-level File/Edit/View menu titles plain and use familiar emoji beside readable action labels inside their dropdowns. Match the Sandbox Stage menu scale: 22 px menu-bar labels and 26 px popup labels. Group file actions as New/Open/Recent, then Save/Save As, with separators before a distinct Rename or Start Over action where present. Use the restrained charcoal popup surface for menu-bar dropdowns; reserve the raised Studio List surface for canvas right-click menus.
- Style single-choice editor pickers as a dark field with the chosen label and chevron. Popup rows keep readable text and may include a meaningful domain icon, as in Prop material and shape choices; do not show radio/check glyphs or an empty indicator lane. The current choice gets a full teal surface and cyan outline. Keep hover and keyboard focus visible, and give the field and rows at least 44 logical pixels of height. Use the shared `EditorSelectionDropdown` control for Prop and Stickman editor pickers.
- Standardize Prop and Stickman canvas context menus on the **Studio List** pattern: a compact vertical list on a raised editor surface, with a subtle border, rounded corners, readable 20 px labels, and 48 px rows. Resolve surface, border, text, hover, EDIT accent, focus, and destructive colors through `EditorVisualTheme` tokens rather than local color literals.
- Group related actions with spacing and separators, and keep each menu's actions specific to the current mode and hit target. Hover uses a lighter surface with a slim EDIT-cyan cue; keyboard focus has a distinct, high-contrast focus outline. Open with no focused row so mouse users do not mistake an action for a selection; show the focus outline after Up/Down navigation, and clear it when the pointer takes over. Give destructive actions explicit labels, separate them from regular actions, and place them last when practical; do not use color as their only cue.
- Keep labels as the source of meaning and use icons sparingly and consistently. Open the menu near the pointer, keep the clicked point or shape edge visible, and clamp the menu within the viewport.
- For dragging, preview changes continuously, show which handle is active, commit on release, and provide a clear cancel path when the operation is consequential or multi-step.
- Avoid hit-test surprises: decorative labels/icons ignore mouse input; a large row toggle has one activation target; a canvas guide is visual only.
- Preserve the world point under the cursor during zoom. Keep selected gizmos and handles visible above artwork and grid.
- Use both visual and textual feedback for snap, mode, selected state, disabled reason, save state, and errors.
The Stage panel's full-row toggle and stable help layout are useful behaviors when a row switch is appropriate. In an editor inspector, apply the same principles without copying the Stage card's floating shell.
### 6.2 Copy and labels
Prefer concise, concrete verbs and familiar terms:
| Prefer | Avoid |
| --- | --- |
| **Arrange**, **Reshape**, **Attach** | Technical labels such as “Transform mode” or “Edit points mode” as the main child-facing label |
| **Add Shape**, **Erase Shape**, **Create Point**, **Remove Point** | Ambiguous labels such as “Apply” when the result can be named |
| **Save**, **Save As…**, **Load**, **Cancel** | Icon-only actions with no tooltip or accessible label |
| “This shape was reshaped.” | A disabled Radius field with no explanation |
| “Choose where this prop attaches.” | Internal terms such as “anchor payload” |
| “Unsaved changes. Save before starting a new prop?” | Generic “Are you sure?” without naming the consequence |
Emoji can help distinguish common actions, as used by both editor menus, but should be an optional cue rather than the only meaning. Keep the text label understandable if an emoji font is unavailable. Use the same wording for the same action in menu, dialog, toolbar, tooltip, and user guide.
### 6.3 Icons and emoji
- Use one icon or emoji to help scan a familiar action; do not put emoji on every label by default.
- Keep the text label as the source of meaning. Provide a tooltip or accessible name for icon-only canvas tools.
- Use icons consistently for the same action across menus, toolbars, empty states, and dialogs. Avoid changing icon meaning between editors.
- Avoid using an emoji's color as the only state cue; fonts and platforms may render it differently.
- Both editors use icons beside readable menu action labels, with separators between distinct groups. Keep top-level File/Edit/View titles plain; place icons on actions and use the same symbol for the same operation across editors.
- In the Prop inspector, use colorful, flat icons for section headings, material choices, 2D shape choices, and artwork tags. Keep text labels authoritative. Show tag selection with the selected surface and border; do not add a separate check badge. Shape icons must depict planar 2D geometry.
### 6.4 Empty, loading, and error states
The current editors have different levels of state guidance. The following table separates known behavior from recommended presentation:
| State | Current reference | Recommended presentation |
| --- | --- | --- |
| New/empty Prop canvas | Arrange has an add-shape context menu and an Arrange-only empty-canvas hint; Reshape does not offer adding a shape. | Keep the canvas mostly clear. Show a short Arrange hint only when it helps discover the next action; hide it as soon as artwork exists. Do not show a dead-end hint in Reshape or Attach. |
| Empty Prop inspector selection | Inspector sections depend on mode and selected shape/anchor. | State what to select or create when the empty inspector would otherwise look broken. Keep the message short and do not add a second competing empty-canvas prompt. |
| Prop load/recovery | Load failures use an error dialog; autosave recovery offers Restore or Discard. | Name the file/action in the message, preserve the current artwork on failure, and keep Restore/Discard outcomes distinct. Loading feedback should not imply that the document is already saved. |
| Stickman panel before drawing | The ten body-part panels are the main workspace; the panel script owns drawing and selection behavior. | Give each blank panel a concise, low-contrast drawing cue if discovery testing shows it is needed. Remove it on first shape so it does not compete with artwork. |
| Stickman load/clear | Load and save use file dialogs; Clear has a confirmation dialog; error dialogs report failures. | Keep the current document intact on a failed load. State the consequence in Clear confirmation and offer a visible cancel path. |
| Save / dirty state | Prop Editor has status/title and autosave behavior; Stickman Editor's status bar reports cursor coordinates, snap state, and save state. | Keep save status easy to find in both editors. Do not treat autosave as a user-visible successful save unless the file was actually saved to the chosen destination. |
Do not add spinners or blocking overlays for synchronous work without evidence that the operation takes long enough to need them. If an operation becomes asynchronous, keep progress near the task and preserve access to cancellation where safe.
### 6.5 Motion and reduced motion
- Use motion to show cause and effect: a brief selection flash, a panel entering, or a successful action cue. Keep it short and do not delay the next input.
- Do not animate every hover or value change. Motion should help the child understand an edit, not decorate idle controls.
- Keep persistent mode and selection state visible after any transition ends.
- Respect a reduced-motion preference for nonessential transitions and flashes. Provide an equally clear static state.
- The current code has short visual effects such as the Prop canvas's new-point flash and Stage completion effects. A unified reduced-motion preference is not established by the referenced theme/specs; treat it as a future product-level decision.
## 7. Theme and implementation checklist
Before adding or revising an editor control:
### Design
- [ ] Is the control's job clear and is it in the correct workspace area?
- [ ] Does it follow the correct pattern: persistent inspector, modal dialog, canvas tool, or temporary capture?
- [ ] Are neutral, hover, selected, focused, disabled, dragging, and error states defined?
- [ ] Does selected state remain distinct from hover and focus?
- [ ] Are colors backed by labels, icons, position, or shape cues where appropriate?
- [ ] Are labels child-readable and consistent with the existing action vocabulary?
- [ ] Can the layout handle smaller windows, longer names, and larger font presets?
### Theme
- [ ] Are colors, type sizes, spacing, radii, borders, and target sizes tokens rather than repeated literals?
- [ ] Does the token use a semantic name rather than a control-specific unexplained hex?
- [ ] Does it have a validated accessor, fallback, and range clamp where applicable?
- [ ] Are per-control style changes local and isolated from a shared cached Theme?
- [ ] Is the difference from Stage tokens intentional and documented?
### Interaction
- [ ] Can pointer, touch, and keyboard users reach and activate the control?
- [ ] Does a disabled control explain why, if not self-evident?
- [ ] Does temporary work have an obvious Cancel/undo path?
- [ ] Does a control's position remain stable while help, validation, or mode content changes?
- [ ] Does changing a tool preference avoid marking the document dirty unless that is intentional?
- [ ] Does success/failure produce visible feedback without hiding the canvas or losing authored work?
### Architecture
- [ ] Does the editor controller own document mutation, persistence, and dirty state?
- [ ] Does a reusable control emit a typed intent and display a supplied value?
- [ ] Is new reuse actually shared by more than one appropriate UI context?
- [ ] Are UI preferences, preview-only state, and document data kept separate?
- [ ] Are new theme values represented in both default config and committed JSON?
- [ ] Are existing user guides/specs updated only after the behavior is verified?
### Implementation recipe
For a new editor UI element, use this sequence:
1. Name its job and state owner. Decide whether the editor controller, canvas, or reusable view owns the value and mutation.
2. Identify the closest reference pattern: Stage inspector, Stage dialog, Prop Editor mode control, Prop canvas selection, or Stickman body-part panel. Record what should carry over and what should not.
3. Define the semantic tokens and states before styling. Reuse existing token names where their meaning matches; add new names to the theme defaults and JSON with validation/fallback behavior.
4. Build with Godot containers and native controls first. Add a custom control only when it provides repeated behavior or custom canvas drawing.
5. Wire typed intent signals to the owning controller. Refresh controls from document state without emitting a second mutation.
6. Check the smallest supported window, larger type, long labels, all modes, selected/disabled/focused states, and save/error/cancel paths.
7. Capture before/after screenshots or a short manual checklist. Update the user guide after the behavior is accepted.
This is recommended implementation order, not a claim that every current editor control follows it.
### Recommended harmonization order
1. **Maintain token ownership.** `EditorVisualTheme` owns shared standalone-editor chrome; `PropTheme` owns prop-domain and canvas defaults; `SandboxTheme` owns Stage-specific UI. Document any intentional override at the token or component that owns it.
2. **Audit remaining overrides.** Check for editor chrome values that still bypass `EditorVisualTheme`; preserve the Prop canvas surface/material palette and Stickman canvas drawing tokens where they serve artwork contrast.
3. **Document responsive limits.** Record minimum usable widths, toolbar wrapping, how the two Stickman Overview columns share space beside the preview, and when Focus or scrolling takes over.
4. **Audit remaining state gaps.** Verify focus, disabled reasons, save/error status, destructive confirmation, and cancel behavior. Both editors use the shared `StageDialogStyler` card for confirmation, recovery, and short action dialogs.
5. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
This order is a maintenance checklist for the current shared chrome and editor-specific drawing patterns; it does not propose replacing the established theme architecture.
### Visual QA checklist
- [ ] Compare Prop and Stickman editor startup screens side by side with the Stage's dark surface and EDIT accent references.
- [ ] Check every mode with no selection, with a selected object, during drag, and after release/cancel.
- [ ] Check primary action, destructive action, disabled control, hover, keyboard focus, and persistent selected states.
- [ ] Check prop autosave recovery, load failure, unsaved New/Close, and successful save feedback.
- [ ] Check Stickman Clear confirmation, load failure, snap status, pose guide, and cross-panel selection.
- [ ] Resize to the smallest supported viewport and increase font size. Confirm canvas access, scroll access, and nonoverlapping controls.
- [ ] Verify visible keyboard order and Return/Space/Escape behavior; confirm focus returns to the invoking control where appropriate.
- [ ] Check grid, artwork, selection halo, vertex handles, pose guide, anchor markers, and preview ghosts against light and dark artwork.
- [ ] Check text contrast and state distinction with a contrast measurement and color-vision simulation; the current specs do not provide measured results.
- [ ] Repeat the checks with emoji rendering unavailable or replaced; action meaning must remain clear from text.
## 8. Recommended additional coverage
To make this guide a stronger long-term design reference, add these pieces as the editor UI evolves:
1. **Annotated screenshots at canonical window sizes.** Include each editor's initial screen, every mode, selection/reshape states, Attach preview, and one modal. Mark which visuals are current and which are target.
2. **A theme ownership map.** Document how `SandboxTheme`, `EditorVisualTheme`, and `PropTheme` divide Stage UI, shared editor chrome, and Prop domain/canvas values. Note intentional overrides and identify any remaining editor chrome that bypasses its shared token.
3. **Responsive breakpoints and minimum workspace sizes.** Record minimum usable canvas/inspector widths, when controls wrap, how the Attach preview stacks, and how the two Stickman body-panel columns share space with the Whole Stickman preview.
4. **Accessibility and contrast acceptance checks.** Specify contrast targets, keyboard tab order, focus visibility, touch target checks, color-vision-safe cues, and high-font-size behavior. The current references provide useful states and target sizes but do not establish a measured accessibility audit.
5. **Motion and sound rules.** Define when transitions, selection flashes, save feedback, and error feedback animate or use sound; keep them brief, informative, and respectful of reduced-motion preferences.
6. **Content and localization rules.** Include label length budgets, wrapping/truncation behavior, tooltip standards, pluralization, and translation expansion checks.
7. **Editor-specific control patterns.** Document mode bars, drawing canvases, color pickers, anchor lists, selection gizmos, empty states, and destructive file flows as reusable examples with their owning state and signals.
8. **Verification matrix.** Link each visual contract to a scene/test/manual check so future changes can distinguish an implemented pattern from a recommendation.
## 9. Reference files
- [AGENTS.md](../../AGENTS.md) — repository layout, editor architecture, shared Stage UI, and child-facing dialog guardrails.
- [Stage panel UI system](../specs/stage_panel_ui_system.md) — Stage inspector card, transient capture bar, component contracts, fitting, and acceptance criteria.
- [Dialog Design Principles](../specs/dialog_design_principles.md) — child-facing modal shell, action hierarchy, and stable resize behavior.
- [SandboxTheme implementation](../../scripts/stage/sandbox_theme.gd) and [SandboxTheme config](../../sandbox_theme.json).
- [PropTheme implementation](../../scripts/editors/prop/prop_theme.gd) and [PropTheme config](../../prop_theme.json).
- [Prop Editor controller](../../scripts/editors/prop/prop_editor.gd), [Prop Editor scene](../../scenes/editors/prop/prop_editor.tscn), and [Prop Editor guide](prop_editor_user_guide.md).
- [Prop canvas](../../scripts/editors/prop/prop_canvas.gd) — mode behavior, canvas gestures, selection handles, and context menus.
- [Stickman Editor controller](../../scripts/editors/stickman/stickman_editor.gd), [Stickman Editor scene](../../scenes/editors/stickman/stickman_editor.tscn), [BodyPartPanel](../../scripts/editors/stickman/body_part_panel.gd), and [Whole Stickman preview](../../scripts/editors/stickman/whole_stickman_preview.gd).
- [Stage dialog style helper](../../scripts/shared/stage_ui/stage_dialog_styler.gd) and [Stage capture bar](../../scripts/editors/stage/stage_use_target_capture_bar.gd).
- [Prop Editor UX specification](../specs/phase_9c_prop_editor_ux.md) — current approved Prop Editor mode, Attach preview, marker, and layout behavior.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,191 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1260" viewBox="0 0 1800 1260" role="img" aria-labelledby="title desc">
<title id="title">Editor visual harmonization: three design directions</title>
<desc id="desc">A comparison board showing Familiar Studio, Adaptive Studio, and Focused Maker concepts for the Prop and Stickman Editors.</desc>
<defs>
<style>
.bg { fill: #171b24; }
.card { fill: #202633; stroke: #465064; stroke-width: 2; }
.card-rec { fill: #202b39; stroke: #22c6ff; stroke-width: 3; }
.surface { fill: #252b38; stroke: #596477; stroke-width: 1.5; }
.work { fill: #171d28; stroke: #657184; stroke-width: 1.5; }
.canvas { fill: #20232b; stroke: #7f8ca0; stroke-width: 1.5; }
.cyan { fill: #22c6ff; }
.cyan-stroke { stroke: #22c6ff; }
.blue { fill: #087dba; }
.muted { fill: #aab2c0; }
.text { fill: #f5f7fa; font-family: Arial, sans-serif; }
.small { font-size: 17px; }
.body { font-size: 20px; }
.label { font-size: 18px; font-weight: 700; }
.h2 { font-size: 30px; font-weight: 700; }
.title { font-size: 40px; font-weight: 700; }
.chip { fill: #2d3748; stroke: #596477; stroke-width: 1; }
.active-chip { fill: #103b4c; stroke: #22c6ff; stroke-width: 2; }
.line { stroke: #667286; stroke-width: 1.5; }
.accent-line { stroke: #22c6ff; stroke-width: 3; }
.selected { fill: none; stroke: #22c6ff; stroke-width: 3; }
.outline { fill: none; stroke: #f5f7fa; stroke-width: 2; }
.figure { fill: none; stroke: #aab2c0; stroke-width: 4; stroke-linecap: round; }
</style>
</defs>
<rect class="bg" width="1800" height="1260"/>
<text class="text title" x="48" y="62">Two editors, one visual language</text>
<text class="text body muted" x="48" y="98">Phase 0a · Three layout directions · Shared tokens and accessible states in every option</text>
<!-- Concept A -->
<g transform="translate(42,132)">
<rect class="card" width="556" height="1080" rx="20"/>
<text class="text h2" x="26" y="46">A. Familiar Studio</text>
<text class="text small muted" x="26" y="76">Keep today’s workspace arrangement</text>
<rect class="chip" x="26" y="94" width="176" height="34" rx="17"/>
<text class="text label small" x="42" y="117">LOWEST CHANGE</text>
<text class="text label" x="26" y="166">PROP EDITOR · WIDE</text>
<rect class="surface" x="26" y="180" width="504" height="260" rx="12"/>
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Unsaved changes</tspan></text>
<rect class="active-chip" x="319" y="197" width="82" height="26" rx="13"/>
<text class="text small" x="330" y="216">Arrange</text>
<rect class="chip" x="405" y="197" width="68" height="26" rx="13"/>
<text class="text small" x="415" y="216">Reshape</text>
<rect class="chip" x="477" y="197" width="36" height="26" rx="13"/>
<text class="text small" x="484" y="216">…</text>
<rect class="canvas" x="36" y="237" width="322" height="192" rx="8"/>
<path class="figure" d="M196 278 L196 363 M196 297 L151 337 M196 297 L242 337 M196 363 L163 406 M196 363 L230 406"/>
<circle class="selected" cx="196" cy="278" r="16"/>
<rect class="surface" x="368" y="237" width="152" height="192" rx="8"/>
<text class="text small" x="381" y="263">Inspector</text>
<rect class="chip" x="380" y="275" width="128" height="34" rx="7"/>
<text class="text small" x="393" y="298">Fill color</text>
<rect class="chip" x="380" y="317" width="128" height="34" rx="7"/>
<text class="text small" x="393" y="340">Outline</text>
<rect class="blue" x="380" y="365" width="128" height="42" rx="9"/>
<text class="text small" x="403" y="392">Add Shape</text>
<text class="text label" x="26" y="484">STICKMAN EDITOR · WIDE</text>
<rect class="surface" x="26" y="498" width="504" height="330" rx="12"/>
<rect class="surface" x="36" y="508" width="484" height="35" rx="8"/>
<text class="text small" x="48" y="532">File Save View <tspan fill="#aab2c0">Saved</tspan></text>
<rect class="work" x="36" y="552" width="148" height="264" rx="8"/>
<rect class="work" x="194" y="552" width="148" height="264" rx="8"/>
<rect class="work" x="352" y="552" width="158" height="264" rx="8"/>
<text class="text small" x="48" y="577">Head · Torso</text>
<text class="text small" x="206" y="577">Arms · Legs</text>
<text class="text small" x="364" y="577">Whole Stickman</text>
<g fill="#252b38" stroke="#586477">
<rect x="46" y="591" width="58" height="62" rx="6"/><rect x="113" y="591" width="58" height="62" rx="6"/>
<rect x="46" y="662" width="58" height="62" rx="6"/><rect x="113" y="662" width="58" height="62" rx="6"/>
<rect x="46" y="733" width="58" height="62" rx="6"/><rect x="113" y="733" width="58" height="62" rx="6"/>
<rect x="204" y="591" width="58" height="62" rx="6"/><rect x="271" y="591" width="58" height="62" rx="6"/>
<rect x="204" y="662" width="58" height="62" rx="6"/><rect x="271" y="662" width="58" height="62" rx="6"/>
</g>
<rect class="selected" x="45" y="590" width="60" height="64" rx="7"/>
<path class="figure" d="M431 603 L431 708 M431 623 L405 651 M431 623 L457 651 M431 708 L411 760 M431 708 L451 760"/>
<text class="text small muted" x="26" y="858">Update styling, targets, focus, and dialogs;</text>
<text class="text small muted" x="26" y="882">keep all ten drawing panels visible.</text>
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
<text class="text label" x="26" y="955">Tradeoff</text>
<text class="text body" x="26" y="986">Least workflow change. The ten small canvases</text>
<text class="text body" x="26" y="1014">stay dense; narrow windows may hide the preview.</text>
</g>
<!-- Concept B -->
<g transform="translate(622,132)">
<rect class="card-rec" width="556" height="1080" rx="20"/>
<text class="text h2" x="26" y="46">B. Adaptive Studio</text>
<text class="text small muted" x="26" y="76">Keep overview; add a workspace that can grow</text>
<rect class="active-chip" x="26" y="94" width="198" height="34" rx="17"/>
<text class="text label small" x="42" y="117">RECOMMENDED</text>
<text class="text label" x="26" y="166">PROP EDITOR · WIDE</text>
<rect class="surface" x="26" y="180" width="504" height="220" rx="12"/>
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Saved</tspan></text>
<rect class="active-chip" x="312" y="197" width="82" height="26" rx="13"/>
<text class="text small" x="323" y="216">Arrange</text>
<rect class="chip" x="398" y="197" width="68" height="26" rx="13"/>
<text class="text small" x="408" y="216">Reshape</text>
<rect class="chip" x="470" y="197" width="42" height="26" rx="13"/>
<text class="text small" x="478" y="216">Attach</text>
<rect class="canvas" x="36" y="237" width="318" height="152" rx="8"/>
<path class="figure" d="M192 265 L192 337 M192 281 L154 312 M192 281 L230 312 M192 337 L164 375 M192 337 L221 375"/>
<circle class="selected" cx="192" cy="265" r="14"/>
<rect class="surface" x="364" y="237" width="156" height="152" rx="8"/>
<text class="text small" x="376" y="263">Scroll settings</text>
<rect class="chip" x="376" y="276" width="132" height="30" rx="7"/>
<rect class="chip" x="376" y="314" width="132" height="30" rx="7"/>
<rect class="blue" x="376" y="352" width="132" height="28" rx="7"/>
<text class="text small" x="399" y="372">Add Shape</text>
<text class="text label" x="26" y="446">STICKMAN · FOCUS ON PART</text>
<rect class="surface" x="26" y="460" width="504" height="354" rx="12"/>
<rect class="surface" x="36" y="470" width="484" height="36" rx="8"/>
<text class="text small" x="48" y="495">File Save <tspan fill="#aab2c0">Unsaved changes</tspan></text>
<rect class="chip" x="36" y="514" width="224" height="34" rx="8"/>
<text class="text small" x="95" y="537">Overview · 10 parts</text>
<rect class="active-chip" x="268" y="514" width="252" height="34" rx="8"/>
<text class="text small" x="330" y="537">Focus on Part</text>
<rect class="active-chip" x="36" y="556" width="484" height="38" rx="8"/>
<text class="text label" x="51" y="581">Body part: Head ▾</text>
<rect class="canvas" x="36" y="602" width="294" height="170" rx="8"/>
<text class="text small muted" x="50" y="625">Head drawing canvas</text>
<circle class="outline" cx="181" cy="688" r="43"/>
<circle class="selected" cx="181" cy="688" r="48"/>
<rect class="surface" x="340" y="602" width="180" height="170" rx="8"/>
<text class="text small" x="354" y="627">Whole Stickman</text>
<path class="figure" d="M430 643 L430 707 M430 656 L410 674 M430 656 L450 674 M430 707 L414 750 M430 707 L446 750"/>
<rect class="surface" x="36" y="780" width="484" height="26" rx="8"/>
<text class="text small" x="48" y="800">Add Shape Arrange Reshape Color + Future tools</text>
<text class="text small muted" x="26" y="848">Overview keeps all ten panels available; Focus makes</text>
<text class="text small muted" x="26" y="874">room for new tools and is useful at any window size.</text>
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
<text class="text label" x="26" y="955">Tradeoff</text>
<text class="text body" x="26" y="986">More workspace and state work; switching views</text>
<text class="text body" x="26" y="1014">must preserve selection and drawing context.</text>
</g>
<!-- Concept C -->
<g transform="translate(1202,132)">
<rect class="card" width="556" height="1080" rx="20"/>
<text class="text h2" x="26" y="46">C. Focused Maker</text>
<text class="text small muted" x="26" y="76">Make one task and canvas the center of both editors</text>
<rect class="chip" x="26" y="94" width="205" height="34" rx="17"/>
<text class="text label small" x="42" y="117">LARGEST REDESIGN</text>
<text class="text label" x="26" y="166">ONE SHARED WORKSPACE PATTERN</text>
<rect class="surface" x="26" y="180" width="504" height="552" rx="12"/>
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Saved</tspan></text>
<rect class="surface" x="36" y="238" width="484" height="50" rx="8"/>
<text class="text label" x="52" y="270">Choose task or body part ▾</text>
<rect class="canvas" x="36" y="298" width="318" height="338" rx="8"/>
<text class="text small muted" x="51" y="324">Large active canvas</text>
<path class="figure" d="M195 382 L195 500 M195 405 L151 444 M195 405 L239 444 M195 500 L160 570 M195 500 L230 570"/>
<circle class="selected" cx="195" cy="382" r="26"/>
<rect class="surface" x="364" y="298" width="156" height="338" rx="8"/>
<text class="text small" x="377" y="325">For this task</text>
<rect class="chip" x="376" y="340" width="132" height="39" rx="7"/>
<text class="text small" x="389" y="366">Color</text>
<rect class="chip" x="376" y="388" width="132" height="39" rx="7"/>
<text class="text small" x="389" y="414">Shape</text>
<rect class="blue" x="376" y="440" width="132" height="42" rx="8"/>
<text class="text small" x="392" y="467">Add Shape</text>
<rect class="chip" x="376" y="492" width="132" height="39" rx="7"/>
<text class="text small" x="389" y="518">Erase Shape</text>
<rect class="active-chip" x="36" y="646" width="484" height="72" rx="9"/>
<text class="text label" x="53" y="675">Preview drawer</text>
<text class="text small" x="53" y="702">Whole Stickman · Attach preview · Open</text>
<text class="text small muted" x="26" y="770">The same task-first frame in Prop and Stickman.</text>
<text class="text small muted" x="26" y="796">The preview opens only when needed.</text>
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
<text class="text label" x="26" y="955">Tradeoff</text>
<text class="text body" x="26" y="986">Strongest focus and consistency, but most users</text>
<text class="text body" x="26" y="1014">must relearn navigation; all ten parts are no</text>
<text class="text body" x="26" y="1042">longer visible at once.</text>
</g>
<text class="text small muted" x="48" y="1240">For discussion only · Palette and labels follow the Editor Visual Style Guide; this is not final UI styling.</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+2 -2
View File
@@ -21,8 +21,8 @@ Use this guide when creating or revising child-facing dialogs, popups, and short
## Theme and implementation
- Put reusable colors, sizes, spacing, radii, and border widths in `SandboxTheme` or `PropTheme` tokens. Apply local stylebox overrides instead of mutating a shared theme resource.
- For Sandbox Stage modal windows, use `StageDialogStyler` in `scripts/shared/stage_ui/stage_dialog_styler.gd` for the shared shell, typography, and primary, secondary, or destructive button roles. Custom Control-based dialogs can reuse its `panel_style()` and `style_button()` helpers.
- Render the shared modal surface as a Control in the stage UI CanvasLayer. Keep the native `AcceptDialog` or `ConfirmationDialog` hidden as the signal source; a full-screen native Window can replace the stage with a blank surface on some render paths.
- Use `StageDialogStyler` in `scripts/shared/stage_ui/stage_dialog_styler.gd` for Sandbox Stage and standalone Prop/Stickman confirmation, recovery, and short action dialogs. It owns the shared shell, typography, and primary, secondary, or destructive button roles through `SandboxTheme.popups.confirmation`. Custom Control-based dialogs can reuse its `panel_style()` and `style_button()` helpers.
- Render the shared modal surface as a Control in the active scene's UI CanvasLayer. Keep the native `AcceptDialog` or `ConfirmationDialog` hidden as the signal source; a full-screen native Window can replace the active scene with a blank surface on some render paths. Native `FileDialog` contents remain native.
- Keep larger text from clipping: use containers that can expand or wrap, and size the dialog after its visible content has been laid out.
- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state.
- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference.
+224
View File
@@ -0,0 +1,224 @@
# Editor visual harmonization — architecture and UI specification
> **Status:** Proposed for final user sign-off (2026-10-23). **Branch:** `feature/editor-visual-harmonization`. **Scope:** Prop Editor and Stickman Editor presentation, responsive workspaces, control feedback, dialogs, and save status. This specification does not change `.prop` or `.stk` formats or Stage inspector behavior.
## 1. Outcome and approved direction
The two editors should read as parts of one Stickman Studio product while keeping their distinct authoring tasks. The canvas remains the main work surface. The Prop Editor retains its Arrange, Reshape, and Attach modes, split canvas, Attach preview, and scrolling inspector. The Stickman Editor gains **Overview**, with all ten body-part panels available, and **Focus on Part**, with one larger live panel, a body-part picker, and the Whole Stickman preview. Focus can be chosen at any window size. It is the automatic default only when the minimum widths needed by Overview do not fit. Once a child chooses a view, that choice remains in effect for the session through window resizing.
The [Editor Visual Style Guide](../guides/editor_visual_style_guide.md) supplies the visual direction, and [Dialog Design Principles](dialog_design_principles.md) supplies modal behavior. [Stage panel UI system](stage_panel_ui_system.md) remains scoped to in-stage selected-object cards. Neither `StageInspectorPanel` nor its choice/toggle controls are transplanted into the persistent editor workspaces.
The implementation proceeds in staged slices under this one contract. Each slice should be reviewable without changing the saved document schema. Final handoff waits for the behavior and rendered visual checks in §10.
## 2. Current architecture and constraints
| Area | Current fact | Consequence |
| --- | --- | --- |
| Prop theme | `PropTheme` reads `prop_theme.json` with defaults, clamping, and typed accessors. `PropEditor` builds a local `Theme`; it also hardcodes some stylebox colors and gives mode buttons `FOCUS_NONE`. | Share chrome roles through one editor token source and restore a distinct keyboard focus state. Keep prop material, primitive, and canvas values under `PropTheme`. |
| Prop layout | `prop_editor.tscn` has fixed top-row offsets, outer `HSplitContainer`, scrolling inspector, and an inner Attach split. | Replace brittle top-row positioning with container sizing; keep both splitters and the inspector scroll. |
| Stickman state | `StickmanEditor` instantiates ten `BodyPartPanel` scenes, stores them by part name, and reads their live shapes for preview and save. Each panel also owns shape selection, zoom, pan, and popup state. | A view switch must move the same nodes. Calling `set_shape_data()` would reset selection, zoom, and pan. |
| Stickman layout | The scene has two five-panel `VBoxContainer` columns and one `WholeStickmanPreview` in a fixed three-column `HBoxContainer`. Each body panel has a 230 × 160 minimum. | Overview needs scroll access; Focus needs a separate host and width-based initial choice. |
| Stickman document feedback | Save and load serialize directly; no current path or dirty status is tracked. `WholeStickmanPreview.part_moved` reports translation, while rotation and scale have no commit signal. | Dirty tracking and unsaved-work flows need an explicit controller boundary covering every authored transform. |
| Attach drawing order | Anchor `z_index_offset` is an integer clamped to −5…5. `PropAttachPreview` assigns it to the preview prop's `z_index`; held runtime props apply it relative to the rig's index. | Keep the signed value and preview result. Zero means normal ordering, not a universal “front” preset. |
## 3. Theme ownership and visual contract
### 3.1 One shared editor chrome source
Add `scripts/shared/editor_ui/editor_visual_theme.gd` as `class_name EditorVisualTheme extends RefCounted` and `res://editor_visual_theme.json`. It owns **shared editor chrome**: background and raised surfaces, primary and muted text, EDIT accent, keyboard focus, primary/destructive actions, borders, typography, spacing, radii, and minimum control targets. Its loader follows the validated `PropTheme`/`SandboxTheme` pattern: immutable defaults, deep merge of the committed JSON, type validation, clamping, a warning once per invalid key, and a safe default when a file or value is missing. Do not read `SandboxTheme` at runtime to style either editor; its values are a product reference and its popup/inspector semantics belong to the Stage.
Both editor controllers hold an `EditorVisualTheme` instance and build/apply their own local Godot `Theme` or per-control style overrides. They never mutate `ThemeDB`, the project-wide GUI theme, or a cached shared `Theme` for one selected control. `PropEditor` retains `PropTheme` for material tints, primitive defaults, outline widths, and **prop canvas** colors. Stickman canvas grid, silhouette guide, and selection colors live in a separate `stickman_canvas` namespace with typed accessors; artwork colors are never overridden by chrome styling. Existing `PropTheme.ui` getters may forward during the staged migration, but the final chrome value has one authoritative source in `EditorVisualTheme`. Remove duplicate configuration values when callers have migrated.
Suggested typed contract (precise names may change in implementation while preserving ownership):
```gdscript
class_name EditorVisualTheme
extends RefCounted
const DEFAULT_PATH: String = "res://editor_visual_theme.json"
func load_from(path: String = DEFAULT_PATH) -> bool: ...
func chrome_color(role: StringName, editor: StringName = &"") -> Color: ...
func chrome_px(role: StringName, editor: StringName = &"") -> int: ...
func stickman_canvas_color(role: StringName) -> Color: ...
func control_theme(editor: StringName) -> Theme: ... # return an editor-local instance
```
The JSON payload is organized by semantic chrome and drawing purpose, for example:
```json
{
"version": "1.0",
"chrome": {
"colors": {"background": "#171B24F2", "surface": "#252B38FA", "focus": "#8BD9FF"},
"targets": {"button_min_height": 44, "mode_button_min_height": 56}
},
"editors": {"prop": {}, "stickman": {}},
"stickman_canvas": {"grid_color": "#FFFFFF26", "selection_core": "#FFFFFF", "selection_halo": "#000000D9"}
}
```
The complete committed config and script defaults must contain all roles in §3.2, not only these example keys. Resolution order is script defaults, committed shared JSON, then an explicit editor override for the requested editor. Known roles use named accessors or validated role IDs, never arbitrary dotted strings at call sites. An optional `editors.prop` / `editors.stickman` override is allowed only where density or composition has a documented need; shared meanings stay the same. Document any legacy `PropTheme.ui` accessor forwarding and removal in the implementation slice.
### 3.2 Baseline tokens and states
| Role | Starting value or rule | Use |
| --- | --- | --- |
| Main background / raised surface | Stage references `#171B24F2` / `#252B38FA` | Editor chrome; keep drawing canvases visually distinct. |
| Primary / muted text | `#F5F7FA` / `#AAB2C0` | Titles, labels, helper copy, disabled reasons. |
| EDIT accent | `#22C6FF` | Active editor mode, part/view choice, and selection accent. |
| Keyboard focus | `#8BD9FF`, at least a distinct 2 px outline | Visible over normal, hover, and pressed styles; native Tab/Space/Enter remain usable. |
| Primary / destructive action | `#087DBA` / `#E66B75` | One forward dialog action and explicit erase/discard actions. Destructive controls retain words and a cancel path. |
| Targets | At least 44 × 44 logical pixels for child-facing interactive controls; mode buttons may stay 56 px high | Increase Prop's current 36 px button/row minima; enlarge click areas without enlarging canvas handles visually. |
| Density | Body text about 18–20 px, section headers about 20–24 px; compact Overview part title may be about 16–18 px | Confirm fit at minimum supported viewport and larger type. Focus may use a larger part title. |
| Canvas selection | Bright core plus dark backing; handle size and hit area are screen-space | Preserve Prop's existing two-tone halo. Extend the same contrast principle to Stickman panel and preview gizmos. |
The final theme must distinguish neutral, hover, pressed/selected, keyboard focus, disabled, dragging/preview, error, saved, and dirty states. Focus and selection may coexist; focus must not paint over or hide the active mode, as the current Prop button style does. Icons or emoji may add cues but each action remains understandable from text alone. The grid, pose guide, authored shape colors, and material colors remain canvas/domain tokens.
## 4. Stickman adaptive workspace
### 4.1 Node hierarchy and state owners
```text
StickmanEditor : Control (document, save/load, dirty, active part, view choice)
└─ ChromeRoot : VBoxContainer
├─ MenuBar / name and view controls
├─ WorkspaceRow : HSplitContainer
│ ├─ BodyPartWorkspace : Control
│ │ ├─ OverviewScroll : ScrollContainer
│ │ │ └─ OverviewColumns : HBoxContainer
│ │ │ ├─ LeftColumn : VBoxContainer (five original panels)
│ │ │ └─ CenterColumn : VBoxContainer (five original panels)
│ │ └─ FocusHost : VBoxContainer
│ │ ├─ PartPicker : native choices, scroll/wrap as needed
│ │ └─ FocusPanelHost : Control (one original BodyPartPanel)
│ └─ WholeStickmanPreview : PanelContainer (script: WholeStickmanPreview; permanent sibling)
└─ StatusBar
```
There is exactly one `BodyPartPanel` instance per part and one `WholeStickmanPreview` instance. The preview remains a permanent sibling of the body-part workspace in `WorkspaceRow`; it is not duplicated or serialized for a view switch. The controller records each panel's original Overview parent and index. Entering Focus reparents the active panel into `FocusPanelHost`; leaving Focus restores it at the original index. Changing focused part restores the outgoing panel before moving the incoming panel. The controller's existing panel dictionary and signal connections remain stable.
`StickmanEditor` owns `enum WorkspaceView { OVERVIEW, FOCUS }`, `_active_part_name: String`, `_view_choice_was_manual: bool`, and the pending view/part request. `BodyPartPanel` owns its shapes, selected shape, zoom, and pan; the preview owns its assembly transform and zoom/pan. The part picker changes `_active_part_name` without calling `BodyPartPanel.select()`, which currently selects the topmost shape and would overwrite the child's shape selection. Clicking a panel updates the active part through the existing selection signal. The active part gets its title inside the panel and a visible border cue in Overview and Focus; Focus does not add a duplicate heading above the canvas.
Suggested typed additions:
```gdscript
# BodyPartPanel
signal presentation_safe
func can_switch_presentation() -> bool: ... # false during drag, pan, or popup
func add_shape(kind: StringName, at_world: Vector2) -> bool: ...
func open_selected_color_picker() -> void: ...
# StickmanEditor
func request_workspace_view(view: WorkspaceView, user_chosen: bool = true) -> void: ...
func request_active_part(part_name: String) -> void: ...
func _apply_pending_presentation() -> void: ...
```
The picker and view buttons emit typed intent (`view_requested(view: int)`, `part_requested(part_name: String)` if extracted as a small reusable control). The controller validates the part ID against `BODY_PART_NAMES`. No signal bus or saved UI resource is needed for these synchronous presentation decisions.
Keep one visible action row tied to the active part in either view. **Add Shape** opens the existing Line/Rectangle/Circle choices and places the new shape at the center of that panel's visible drawing area; **Color** opens the selected shape's existing picker and is disabled with a clear reason until a shape is selected. `BodyPartPanel` exposes small typed methods for those actions and remains the owner of shape mutation. The right-click menu stays available for point editing, ordering, copy/paste, mirroring, and erase; use readable wording and a short on-canvas hint so children can discover those secondary tools. This shared row avoids repeating full 44 px toolbars inside all ten Overview panels and shrinking their drawing areas.
### 4.2 Responsive rules and safe switching
The automatic initial view is calculated after the first layout pass from available workspace width and the **Overview** content minimum: both panel columns at their useful minimum widths, container gaps, and the preview's useful minimum width. Use the same stable reference widths when the window resizes; a hidden/expanded view must not alter the threshold. If Overview fits, the default is Overview; otherwise Focus. Automatic reevaluation applies only until a child chooses either view. A manual choice remains for the session, including across later resizing and document load/clear. Do not put it in `.stk` or `user://settings.json` in this phase.
Overview keeps all ten panels reachable, with vertical and, when needed, horizontal scrolling rather than shrinking drawing areas below their usable minimums. A manual Overview choice remains available at a narrow width. Focus is available at any width. Wide Focus shows a persistent, resizable Whole Stickman preview in the split beside the enlarged selected panel. Narrow Focus starts with the preview collapsed so the drawing panel keeps its minimum width; a visible **Whole Stickman** button opens and closes it. If both pane minima cannot fit at once, opening the preview gives it the available workspace and offers a visible **Back to Part** action; no control or panel may be clipped. Keep the preview node alive so its zoom, pan, guide, selection, and part transforms survive collapse and return.
Do not reparent a panel during vertex/shape drag, pan, context menu, or color popup. Store the requested view/part and apply it when the panel reports a safe point after the gesture or popup closes. This avoids a partial geometry commit and avoids discarding a color choice. A view switch must not call `set_shape_data()`, `clear_shape()`, `reset_view()`, or `select()`; those methods change selection or view context. In particular, `set_shape_data()` currently resets zoom/pan/selection. A presentation change changes no document data, emits no shape-changed signal, and does not mark the document dirty.
### 4.3 Data and event flow
```mermaid
flowchart LR
View[Overview / Focus request] --> Guard{Panel gesture or popup active?}
Guard -->|yes| Pending[Keep request pending]
Guard -->|no| Move[Reparent one live panel]
Pending -->|presentation_safe| Move
Move --> Refresh[Update picker, active cue, preview visibility]
Refresh --> Status[Document dirty state unchanged]
Edit[Draw or transform a part] --> Owner[Live BodyPartPanel / WholeStickmanPreview]
Owner --> Controller[StickmanEditor observes commit]
Controller --> Assembly[Refresh assembled preview]
Controller --> Dirty[Mark Unsaved changes]
```
For example, when the child focuses the left lower arm, its original panel moves from `LeftColumn` to `FocusPanelHost`. Its current `shapes`, selected shape index, `_zoom`, and `_pan_offset` remain in that node. Editing still triggers the controller's existing shape-changed handler; the preview reads from the same ten-node dictionary. Returning to Overview restores that panel to its original slot with the same drawing context.
## 5. Prop Editor workspace and controls
Keep the existing `PropCanvas`, scrolling inspector, outer canvas/inspector `HSplitContainer`, and inner Attach preview split. Convert fixed `TopRow`/`TopBar` offsets into container-driven chrome so Grid/Snap, the name field, and mode controls remain visible at larger fonts and narrow sizes. Tool rows may wrap through `FlowContainer`; keep action order stable. Maintain a useful canvas minimum and allow the inspector to scroll rather than clip. Preserve the clicked control's position when a mode reveals a section or Attach preview.
Restore keyboard focus on Arrange, Reshape, and Attach mode buttons. Give the local Button theme a focus outline that composes with the pressed/selected appearance; keep selected state persistent and text stable. Programmatic refresh uses `set_pressed_no_signal()` as today. Disabled controls explain nonobvious reasons near the control or via a clear tooltip. Arrange-only and Attach-only controls retain their existing mode gating, document mutation, and preview-only behavior.
Use visible labels and 44 px targets for the primary shape/anchor operations currently hidden in context menus where layout permits. Context menus remain useful shortcuts; the common child path for adding a shape, reshaping a point, and choosing a body part should have an obvious on-screen entry. Preserve Prop's existing two-tone halo and keep its screen-space handles and hit areas distinct. Anchor and preview markers need equivalent contrast against light and dark props. Canvas artwork and material tint values remain unchanged by chrome token migration.
## 6. Copy, dialogs, and save feedback
### 6.1 Child-facing wording
| Current wording | Target copy / rule | Behavioral reason |
| --- | --- | --- |
| Prop `Friction: 0.xx` | **Surface grip: 0.xx**, with optional helper “Higher values make sliding harder.” | Keep the exact `PhysicsMaterial.friction` value and its surface-contact meaning; do not imply hand gripping or change physics presets. |
| Attach `Z Offset` | **Held prop layer** with nearby “Negative: behind · 0: normal order · Positive: farther in front.” Keep the −5…5 integer control and show its visual effect in the preview. | `z_index_offset` is a signed render-layer offset in both preview and held runtime behavior. Zero may draw in front in the preview due tree order, so do not relabel zero as a fixed front/behind category. |
| Destructive commands | Prefer **Erase Shape**, **Start Over**, and explicit **Discard Changes** in dialogs. For Start Over, state “Start over with a blank stickman?” and keep a separate visible **Cancel** action. | Say which authored work is affected; pair with a visible Cancel path. |
| Technical axis labels | Keep X/Y when they are editable coordinates; use plain helper text where the operation is unclear. | Avoid silently changing geometry or transform semantics while updating words. |
The Prop Editor's existing dirty title/status and autosave/recovery flow remain authoritative. Improve status wording to clear **Saved** / **Unsaved changes** states; a failed save/load keeps the current document and shows an actionable reason. Preview-only choices, Show Stickman Preview, marker visibility, and mode/view changes do not mark dirty.
The Stickman Editor adds controller-owned `_current_path: String` and `_dirty: bool`. Name changes, shape edits, shape order, color edits, and committed Whole Stickman position/rotation/scale changes mark dirty; grid/snap, guide, zoom/pan, selection, part picker, and Overview/Focus do not. Add one transform-commit signal or equivalent controller-observable path to `WholeStickmanPreview` that covers translation, rotation, and scale, because its current `part_moved` signal covers only translation. Only a successful save clears dirty; a failed save preserves it. Successful load/clear initializes the expected clean state, and save/load continue to use the existing `.stk` schema and `ContentPaths.canonicalize()` behavior.
Before Stickman **Load**, **Clear**, or window close replaces unsaved work, show the child-facing **Save / Discard Changes / Cancel** choice. Save with no usable path opens the existing Save dialog and resumes the pending action only after successful save. Cancel or a failed save leaves the document and active view untouched. Clear continues to name that it removes the current stickman. Keep Prop's current New/Close/recovery transactions intact while restyling them. Both editors' dialogs follow the dialog guide: rounded shell, readable type, blue primary action, distinct danger action, 44 px targets, visible labeled dismissal, safe viewport fit, and stable anchor when contents change. Adapt `StageDialogStyler` visual rules; its Stage overlay host is not part of either editor.
## 7. Static contracts and document boundaries
| Target | Contract | Owner / boundary |
| --- | --- | --- |
| `EditorVisualTheme : RefCounted` | `load_from(path: String) -> bool`; typed semantic color/size accessors; local `Theme` construction or copies | Validated shared chrome only; no Stage runtime dependency. |
| `PropTheme : RefCounted` | Existing material, primitive, outline, and canvas accessors; temporary UI forwards only while callers migrate | Prop-specific presentation and authoring defaults; never physics material mass/friction/bounce values. |
| `PropEditor : Control` | Existing mode, file, autosave, inspector signals and handlers; consume shared chrome tokens | Prop document mutation, Save/Load, dirty, mode control, local Theme. |
| `PropCanvas : Control` | Existing typed mode and shape/anchor signals; selection drawing uses canvas tokens | Authoring input and visual feedback. |
| `StickmanEditor : Control` | Add `class_name StickmanEditor`; `WorkspaceView`, `request_workspace_view`, `request_active_part`; `_dirty`/pending document action | One live panel dictionary, responsive presentation, `.stk` save/load, dirty state. |
| `BodyPartPanel : PanelContainer` | Existing `shape_changed(Array)` and `shape_selected()`; `can_switch_presentation() -> bool`, `presentation_safe`, `add_shape(kind: StringName, at_world: Vector2) -> bool`, `open_selected_color_picker() -> void` | One part's shapes, selection, zoom/pan, gesture/popup safety, and shape operations. |
| `WholeStickmanPreview : PanelContainer` | Existing `part_moved(String, Vector2)`; add typed `part_transform_committed(part_name: String, position: Vector2, rotation_deg: float, scale: Vector2)` or equivalent | One assembled preview and document transform commit boundary. |
No new global bus, serialized `Resource`, duplicate canvas instance, or `.prop`/`.stk` version bump is needed. UI preferences remain session state unless a later feature specifically approves persistence. Saving gathers from the ten live panels and the one live preview, independent of which view is visible.
## 8. Staged implementation slices
1. **Shared foundation.** Add the shared editor chrome theme/config and typed accessors. Set semantic roles, focus/selection/disabled states, minimum targets, and scoped theme application. Keep Prop-specific canvas/material tokens. Confirm both config and defaults match.
2. **Prop Editor pass.** Migrate Prop chrome to shared roles, fix mode-button keyboard focus, raise touch targets, make top controls and inspector fit, refine labels/help, and style existing dialogs. Preserve its file and autosave behavior.
3. **Stickman workspace pass.** Apply the theme to Stickman chrome, introduce Overview/Focus and the body-part picker, move live panel instances safely, add scrolling and narrow preview collapse, preserve drawing context, and expose active-part feedback.
4. **Shared state and recovery pass.** Align canvas contrast and action discovery, add complete Stickman dirty/unsaved transactions, and finish dialog and status consistency. Resolve remaining measured layout and contrast issues from rendered review.
Keep one spec so theme roles, view state, and save behavior are reviewed together. Each slice should leave its editor usable and should report which acceptance outcomes below it satisfies. User-guide changes follow the spec-pipeline documentation phase after the user verifies the feature in the editor.
## 9. Architectural trade-offs and risks
| Decision | Benefit | Cost / mitigation |
| --- | --- | --- |
| Shared semantic editor source with local editor Themes | Consistent chrome and focus states without coupling to Stage internals | Migrate Prop's old `ui` values deliberately; prevent two active authorities for one chrome role. |
| Reparent one existing BodyPartPanel into Focus | Preserves shapes, selection, zoom/pan, signals, and current save architecture | Node movement during a gesture/popup is unsafe; defer until `presentation_safe`. Keep original parent/index map. |
| Permanent WholeStickmanPreview sibling | One source of assembly transform and preview view state | Narrow layout needs collapse/return controls and a minimum-width fallback. |
| Session-only manual view choice | A child's chosen workspace does not jump on resize | Automatic layout helps only until the first manual choice; narrow Overview requires scroll access. |
| One umbrella specification, staged slices | Prevents token, mode, and state contracts from drifting across separate documents | Each slice needs explicit acceptance accounting and user review. |
| Stickman dirty state added with transform commit | Protects edits during Load/Clear/Close | All authored mutation paths must be audited; translation-only signal is insufficient. |
Known dependencies: the untracked style guide and design-direction board are source references in this checkout; unrelated dirty Stage work must remain untouched. Theme and layout work can be implemented independently of that Stage work. Prop's in-Stage authoring mode also instantiates `PropEditor`, so chrome changes must preserve its host and hide/disable behavior. Long labels, emoji fallback, larger fonts, and small viewports may expose new minimum-size pressure; resolve by wrapping/scrolling and measured fitting, not by silently shrinking controls.
## 10. Observable acceptance and verification routes
| User path | Observable outcome | Verification route |
| --- | --- | --- |
| Open each editor at a wide window; Tab through menu, primary controls, mode/view buttons, inspector, and dialogs; activate with Space/Enter. | All focusable controls show a distinct focus ring, active mode/view remains visible under focus, and controls meet the agreed target size. | Rendered GUI capture plus real keyboard navigation at ordinary and larger font settings. |
| Open Stickman wide, select a part, use Add Shape and Color, draw in a second part, choose Focus on Part, change the focused part, then return to Overview. | The visible actions affect only the active part; Overview has all ten panels; Focus works at wide width; each part retains shapes, selected shape, zoom, and pan; preview updates from edits; no view switch marks dirty. | Interact through production controls; compare panel and preview state before/after switches and inspect rendered captures. |
| Open Stickman where Overview's measured minimum does not fit; choose Overview manually, resize wider and narrower, then choose Focus. | Focus is the initial automatic view; Overview remains reachable through scrolling; manual choice remains through resize; Focus preview collapses with visible Whole Stickman access and returns without resetting preview view state. | Rendered narrow/wide GUI session with actual resize and control input. Record viewport dimensions and pane sizes. |
| Begin a panel drag or open its color popup, request another view/part, then finish or dismiss that interaction. | The switch waits until the interaction is safe; geometry/color is neither partially committed nor lost; selected shape and pan/zoom remain. | Real input path through the panel and popup; inspect shapes and resulting selection after release/dismissal. |
| In Prop, switch Arrange/Reshape/Attach, resize, edit a shape and anchor, and open its preview. | Mode state, inspector sections, top Grid/Snap, canvas, and Attach preview remain reachable without clipped labels or jumping controls; existing document and preview-only dirty rules hold. | Rendered GUI captures of all modes at wide/narrow sizes; check actual edits and status. |
| In Prop Attach, set Held prop layer to −1, 0, and +1 and view the preview; save and reopen. | Negative places the held prop behind the rig, zero follows normal ordering, positive places it farther in front; numeric −5…5 values round-trip unchanged. | Preview capture for each value plus a small repeatable save/reopen scenario using an anchor and visible overlapping prop. Confirm runtime held ordering in a Stage scenario where a rig holds the same prop. |
| In Prop, choose a material preset and read Surface grip, then switch presets. | The displayed value equals `PhysicsMaterial.friction`; wording describes sliding resistance without changing physics values. | Inspector readback against preset values and rendered text check. |
| Edit a Stickman shape, move/rotate/scale a preview part, then Save; edit again and choose Load, Clear, or close. | Every authored mutation sets Unsaved changes; successful Save clears it; Save/Discard Changes/Cancel protects the current document and resumes only the chosen action. | Small repeatable `.stk` scenario: draw and transform two parts, save, reload, compare shape/transform data, then exercise each unsaved branch. Measure status and file content before/after. |
| Open save/load/recovery/clear/error dialogs; enlarge type or shorten the viewport. | Primary/destructive actions, visible Cancel/Back/Close, message, and focused control remain accessible; content scrolls or fits without abrupt recentering after a choice. | Rendered dialog states and real pointer/keyboard interactions; capture normal, error, and destructive branches. |
| Select artwork with light and dark fills in each editor, drag handles, and use the pose guide/Attach markers. | Selection, active handle, guide, and markers remain legible over both fills and at working zoom; grid stays quieter than the artwork. | Rendered pixel inspection at named scenes/states, with a contrast/color-vision check for text and UI state. |
Headless parsing or a successful scene launch alone does not establish these visual or interaction outcomes. During implementation, run targeted Godot import/parse and relevant existing regressions, then use a render-capable editor session and real controls for the visual paths. Record scene/state, viewport size, capture location, observed result, and any outcome that could not be verified. Stop for user verification after agent evidence is complete; update user guides only after the user confirms the feature works.
+52
View File
@@ -0,0 +1,52 @@
{
"version": "1.0",
"chrome": {
"colors": {
"background": "#171B24F2",
"surface": "#252B38FA",
"surface_hover": "#30394A",
"selected_surface": "#103B4D",
"text": "#F5F7FA",
"text_muted": "#AAB2C0",
"edit": "#22C6FF",
"preview": "#22C6FF66",
"focus": "#8BD9FF",
"action_primary": "#087DBA",
"destructive": "#E66B75",
"border": "#FFFFFF33",
"disabled": "#77808F",
"saved": "#8FD6A4",
"dirty": "#FFD166",
"error": "#FF8A8A"
},
"fonts": {
"body": 18,
"helper": 16,
"section": 22,
"title": 26,
"menu": 20,
"menu_bar": 22,
"menu_popup": 26,
"status": 16,
"mode": 24,
"prop_name": 20,
"context_menu": 20
},
"spacing": { "small": 8, "medium": 12, "large": 16 },
"radii": { "control": 8, "panel": 10 },
"borders": { "normal": 1, "focus": 2 },
"targets": {
"button_min_height": 44,
"row_min_height": 44,
"mode_button_min_height": 56,
"context_menu_row_height": 48,
"context_menu_min_width": 208
}
},
"editors": { "prop": {}, "stickman": {} },
"stickman_canvas": {
"grid_color": "#FFFFFF26",
"selection_core": "#FFFFFF",
"selection_halo": "#000000D9"
}
}
-24
View File
@@ -20,29 +20,5 @@
"grid_size": 15,
"background_color": "#1F1F1F",
"grid_color": "#FFFFFF26"
},
"ui": {
"fonts": {
"body": 18,
"header": 24,
"menu": 22,
"menu_bar": 22,
"mode": 26,
"status": 18,
"prop_name": 20,
"context_menu": 22
},
"hit_targets": {
"button_min_height": 36,
"row_min_height": 36,
"mode_button_min_height": 56
},
"colors": {
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF",
"inspector_background": "#262C39"
}
}
}
+25
View File
@@ -150,6 +150,31 @@
"danger_hover_surface": "#3B252C",
"danger_pressed_surface": "#4A252D"
},
"ground_finish_chooser": {
"card_width": 880,
"max_height_ratio": 0.86,
"screen_margin": 24,
"choice_height": 96,
"choice_preview_height": 48,
"choice_font_size": 18,
"choice_min_width": 104,
"choice_corner_radius": 12,
"compact_choice_height": 50,
"compact_choice_preview_height": 22,
"compact_choice_font_size": 16,
"compact_grid_gap": 6,
"compact_layout_max_width": 600,
"compact_layout_max_height": 460,
"compact_height_ratio": 0.89,
"preview_height": 128,
"compact_preview_height": 88,
"preview_min_width": 224,
"preview_corner_radius": 12,
"preview_surface": "#151C28",
"preview_border": "#39465B",
"grid_gap": 8,
"wide_layout_min_width": 760
},
"asset_selector": {
"backdrop_alpha": 0.55,
"cell_size": [190, 220],
+49 -66
View File
@@ -1,20 +1,8 @@
[gd_scene load_steps=4 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/editors/prop/prop_editor.gd" id="1_editor_script"]
[ext_resource type="Script" path="res://scripts/editors/prop/prop_canvas.gd" id="2_canvas_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_canvas"]
bg_color = Color(0.121569, 0.121569, 0.121569, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[node name="PropEditor" type="Control"]
layout_mode = 3
anchors_preset = 15
@@ -24,91 +12,89 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_editor_script")
[node name="TopRow" type="HBoxContainer" parent="."]
[node name="ChromeRoot" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchors_preset = 15
anchor_right = 1.0
offset_bottom = 44.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 6
[node name="MenuBar" type="MenuBar" parent="TopRow"]
[node name="TopRow" type="FlowContainer" parent="ChromeRoot"]
layout_mode = 2
custom_minimum_size = Vector2(0, 44)
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="MenuBar" type="MenuBar" parent="ChromeRoot/TopRow"]
unique_name_in_owner = true
layout_mode = 2
[node name="TopRightControls" type="FlowContainer" parent="ChromeRoot/TopRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="TopRightControls" type="HBoxContainer" parent="TopRow"]
unique_name_in_owner = true
[node name="TopBar" type="HBoxContainer" parent="ChromeRoot"]
layout_mode = 2
theme_override_constants/separation = 8
custom_minimum_size = Vector2(0, 44)
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 44.0
offset_bottom = 88.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
[node name="NameLabel" type="Label" parent="ChromeRoot/TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
text = "Prop Name:"
vertical_alignment = 1
[node name="PropNameEdit" type="LineEdit" parent="TopBar"]
[node name="PropNameEdit" type="LineEdit" parent="ChromeRoot/TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Prop"
[node name="MainLayout" type="HSplitContainer" parent="."]
[node name="MainLayout" type="HSplitContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 92.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="CanvasPanel" type="PanelContainer" parent="MainLayout"]
[node name="CanvasPanel" type="PanelContainer" parent="ChromeRoot/MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
custom_minimum_size = Vector2(260, 0)
theme_override_styles/panel = SubResource("StyleBox_canvas")
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
[node name="CanvasArea" type="VBoxContainer" parent="ChromeRoot/MainLayout/CanvasPanel"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="ModeBar" type="HBoxContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="ModeBar" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="SizeTools" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="SizeTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="AttachTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="AttachTools" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12
[node name="CanvasWorkspace" type="HSplitContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="CanvasWorkspace" type="HSplitContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
split_offset = 520
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
[node name="PropCanvas" type="Control" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -117,18 +103,18 @@ mouse_filter = 0
clip_contents = true
script = ExtResource("2_canvas_script")
[node name="InspectorColumn" type="PanelContainer" parent="MainLayout"]
[node name="InspectorColumn" type="PanelContainer" parent="ChromeRoot/MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
custom_minimum_size = Vector2(300, 0)
size_flags_horizontal = 3
[node name="InspectorScroll" type="ScrollContainer" parent="MainLayout/InspectorColumn"]
[node name="InspectorScroll" type="ScrollContainer" parent="ChromeRoot/MainLayout/InspectorColumn"]
layout_mode = 2
size_flags_vertical = 3
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn/InspectorScroll"]
[node name="InspectorList" type="VBoxContainer" parent="ChromeRoot/MainLayout/InspectorColumn/InspectorScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
@@ -154,18 +140,12 @@ filters = PackedStringArray("*.prop ; Prop Files")
unique_name_in_owner = true
title = "Error"
[node name="StatusBar" type="HBoxContainer" parent="."]
[node name="StatusBar" type="HBoxContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 1
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -28.0
grow_horizontal = 2
grow_vertical = 0
layout_mode = 2
custom_minimum_size = Vector2(0, 28)
[node name="SaveStatus" type="Label" parent="StatusBar"]
[node name="SaveStatus" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -173,9 +153,12 @@ text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="CursorCoords" type="Label" parent="StatusBar"]
[node name="CursorCoords" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = ""
horizontal_alignment = 2
vertical_alignment = 1
[node name="StageDialogLayer" type="CanvasLayer" parent="."]
layer = 100
+6 -18
View File
@@ -1,18 +1,7 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/editors/stickman/body_part_panel.gd" id="1_panel_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_panel"]
bg_color = Color(0.15, 0.15, 0.15, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[ext_resource type="Script" path="res://scripts/shared/editor_ui/editor_context_menu.gd" id="2_context_menu_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_drawing"]
bg_color = Color(0.12, 0.12, 0.12, 1)
@@ -26,8 +15,8 @@ border_color = Color(0.25, 0.25, 0.25, 1)
custom_minimum_size = Vector2(230, 160)
offset_right = 230.0
offset_bottom = 160.0
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_styles/panel = SubResource("StyleBox_panel")
script = ExtResource("1_panel_script")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
@@ -42,8 +31,6 @@ unique_name_in_owner = true
layout_mode = 2
text = "Body Part"
horizontal_alignment = 1
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
theme_override_font_sizes/font_size = 13
[node name="DrawingArea" type="Control" parent="VBoxContainer"]
unique_name_in_owner = true
@@ -53,9 +40,9 @@ mouse_filter = 1
clip_contents = true
theme_override_styles/panel = SubResource("StyleBox_drawing")
[node name="ContextMenu" type="PopupMenu" parent="."]
[node name="ContextMenu" type="PopupPanel" parent="."]
unique_name_in_owner = true
item_count = 0
script = ExtResource("2_context_menu_script")
[node name="ColorPickerPopup" type="PopupPanel" parent="."]
unique_name_in_owner = true
@@ -70,6 +57,7 @@ layout_mode = 2
custom_minimum_size = Vector2(300, 300)
[node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
alignment = 2
@@ -81,4 +69,4 @@ text = "Cancel"
[node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
unique_name_in_owner = true
layout_mode = 2
text = "OK"
text = "Use Color"
+168 -85
View File
@@ -2,18 +2,7 @@
[ext_resource type="Script" uid="uid://dlacmxk257ro" path="res://scripts/editors/stickman/stickman_editor.gd" id="1_editor_script"]
[ext_resource type="Script" uid="uid://codv3g6qw1tef" path="res://scripts/editors/stickman/whole_stickman_preview.gd" id="2_preview_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_preview"]
bg_color = Color(0.12, 0.12, 0.12, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[ext_resource type="Script" path="res://scripts/shared/editor_ui/editor_selection_dropdown.gd" id="3_selection_dropdown_script"]
[node name="StickmanEditor" type="Control"]
layout_mode = 3
@@ -24,85 +13,196 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_editor_script")
[node name="MenuBar" type="MenuBar" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_bottom = 32.0
grow_horizontal = 2
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 32.0
offset_bottom = 64.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
text = "Stickman Name:"
vertical_alignment = 1
[node name="StickmanNameEdit" type="LineEdit" parent="TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Stickman"
[node name="MainLayout" type="HBoxContainer" parent="."]
[node name="ChromeRoot" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 68.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 6
[node name="LeftColumn" type="VBoxContainer" parent="MainLayout"]
[node name="MenuBar" type="MenuBar" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 44)
[node name="TopBar" type="HBoxContainer" parent="ChromeRoot"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NameLabel" type="Label" parent="ChromeRoot/TopBar"]
layout_mode = 2
text = "Stickman Name:"
vertical_alignment = 1
[node name="StickmanNameEdit" type="LineEdit" parent="ChromeRoot/TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(180, 44)
placeholder_text = "Unnamed Stickman"
[node name="WorkspaceToolbar" type="FlowContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="OverviewButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Overview"
toggle_mode = true
[node name="FocusButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Focus on Part"
toggle_mode = true
[node name="PartPicker" type="MenuButton" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
script = ExtResource("3_selection_dropdown_script")
[node name="AddShapeButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Add Shape"
[node name="ColorButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Color"
disabled = true
[node name="WholeStickmanButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Whole Stickman"
visible = false
[node name="BackToPartButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Back to Part"
visible = false
[node name="WorkspaceRow" type="HSplitContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="BodyPartWorkspace" type="Control" parent="ChromeRoot/WorkspaceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
custom_minimum_size = Vector2(350, 260)
[node name="OverviewScroll" type="ScrollContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
horizontal_scroll_mode = 1
vertical_scroll_mode = 1
[node name="OverviewColumns" type="HBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="LeftColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="CenterColumn" type="VBoxContainer" parent="MainLayout"]
[node name="CenterColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="WholeStickmanPreview" type="PanelContainer" parent="MainLayout"]
[node name="FocusHost" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
visible = false
theme_override_constants/separation = 8
[node name="FocusPanelHost" type="Control" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
clip_contents = true
[node name="WholeStickmanPreview" type="PanelContainer" parent="ChromeRoot/WorkspaceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
theme_override_styles/panel = SubResource("StyleBox_preview")
custom_minimum_size = Vector2(320, 260)
script = ExtResource("2_preview_script")
[node name="VBoxContainer" type="VBoxContainer" parent="MainLayout/WholeStickmanPreview"]
[node name="PreviewColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview"]
layout_mode = 2
[node name="PreviewTitle" type="Label" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
[node name="PreviewTitle" type="Label" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview/PreviewColumn"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
theme_override_font_sizes/font_size = 14
text = "Whole Stickman"
horizontal_alignment = 1
[node name="PreviewArea" type="Control" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
[node name="PreviewArea" type="Control" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview/PreviewColumn"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 1
clip_contents = true
[node name="StatusBar" type="HBoxContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 32)
theme_override_constants/separation = 12
[node name="CursorCoords" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="SaveStatus" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = "New stickman"
horizontal_alignment = 1
vertical_alignment = 1
[node name="SnapStatus" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = "SNAP: OFF"
horizontal_alignment = 2
vertical_alignment = 1
[node name="SaveDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Save Stickman"
@@ -112,7 +212,7 @@ filters = PackedStringArray("*.stk ; Stickman Files")
[node name="LoadDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Open a File"
title = "Open a Stickman"
size = Vector2i(600, 450)
ok_button_text = "Open"
file_mode = 0
@@ -121,19 +221,24 @@ filters = PackedStringArray("*.stk ; Stickman Files")
[node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Clear Stickman"
ok_button_text = "Clear"
dialog_text = "Are you sure you want to clear the current stickman?
This action cannot be undone."
title = "Start Over?"
ok_button_text = "Start Over"
dialog_text = "This will clear your current stickman. Start over?"
[node name="UnsavedChangesDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Unsaved Changes"
ok_button_text = "Save"
dialog_text = "Save changes before continuing?"
[node name="ErrorDialog" type="AcceptDialog" parent="."]
unique_name_in_owner = true
title = "Error"
title = "Could Not Complete"
[node name="GridConfigDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Configure Grid"
ok_button_text = "OK"
ok_button_text = "Done"
[node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"]
layout_mode = 2
@@ -145,34 +250,12 @@ text = "Grid Size (pixels):"
[node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 44)
min_value = 1.0
max_value = 100.0
value = 15.0
step = 1.0
rounded = true
[node name="StatusBar" type="HBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -28.0
grow_horizontal = 2
grow_vertical = 0
[node name="CursorCoords" type="Label" parent="StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="SnapStatus" type="Label" parent="StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = "SNAP: OFF"
horizontal_alignment = 2
vertical_alignment = 1
[node name="StageDialogLayer" type="CanvasLayer" parent="."]
layer = 100
+69 -13
View File
@@ -76,6 +76,7 @@ const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropAnchorOverlayScript := preload("res://scripts/props/prop_anchor_overlay.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const WholeStickmanPreviewScript := preload("res://scripts/editors/stickman/whole_stickman_preview.gd")
const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
const SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -121,7 +122,8 @@ var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 15
var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _context_menu: PopupMenu
var _create_point_armed: bool = false
var _context_menu: EditorContextMenu
var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
@@ -188,7 +190,7 @@ func _ready() -> void:
_anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
_context_menu = PopupMenu.new()
_context_menu = EditorContextMenuScript.new() as EditorContextMenu
_context_menu.name = "ContextMenu"
add_child(_context_menu)
if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed):
@@ -214,6 +216,8 @@ func set_edit_mode(mode: int) -> void:
if _stage_size_preview_active:
cancel_stage_size_preview()
_edit_mode = m
if _edit_mode != EditMode.POINTS:
_create_point_armed = false
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
_drag_mode = DragMode.NONE
@@ -225,6 +229,21 @@ func set_edit_mode(mode: int) -> void:
func get_edit_mode() -> int:
return _edit_mode
func arm_create_point() -> void:
set_edit_mode(EditMode.POINTS)
_create_point_armed = true
queue_redraw()
func cancel_create_point() -> void:
_create_point_armed = false
queue_redraw()
func is_create_point_armed() -> bool:
return _create_point_armed
func set_anchor_data(data: Variant) -> void:
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.set_anchor_data(data)
@@ -965,7 +984,12 @@ func set_has_clipboard(state: bool) -> void:
## Sets the font size of the right-click context menu (Round 4, theme-driven).
func set_context_menu_font_size(size: int) -> void:
if _context_menu != null:
_context_menu.add_theme_font_size_override("font_size", size)
_context_menu.set_font_size(size)
func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void:
if _context_menu != null:
_context_menu.configure(visual_theme, editor)
func is_cursor_over_drawing(global_pos: Vector2) -> bool:
@@ -1254,7 +1278,10 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
return
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_handle_left_press(world_pos)
if _create_point_armed and _edit_mode == EditMode.POINTS:
_create_point_from_toolbar(world_pos)
else:
_handle_left_press(world_pos)
else:
_handle_left_release()
@@ -1314,8 +1341,8 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
if context_shape >= 0 and context_vertex >= 0:
_context_menu.set_meta("context_shape", context_shape)
_context_menu.set_meta("context_vertex", context_vertex)
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_destructive_item("Remove Point", MENU_REMOVE_POINT)
_context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
return
# Segment proximity -> Create Point (selects the shape first).
@@ -1327,7 +1354,7 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
queue_redraw()
shape_selected.emit(seg_shape)
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.popup_at(mb.global_position, _global_shape_target_rect(seg_shape))
return
# Interior body: select only, no menu.
@@ -1360,17 +1387,18 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
_context_menu.set_meta("click_position", world_pos)
queue_redraw()
shape_selected.emit(hit_shape)
_context_menu.add_item("Copy", MENU_COPY)
_context_menu.add_item("Copy Shape", MENU_COPY)
if _has_clipboard:
_context_menu.add_item("Paste", MENU_PASTE)
_context_menu.add_item("Erase", MENU_DELETE)
_context_menu.add_item("Paste Shape", MENU_PASTE)
_context_menu.add_separator()
_context_menu.add_item("Flip Left-Right", MENU_MIRROR_X)
_context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y)
_context_menu.add_separator()
_context_menu.add_item("Bring Forward", MENU_BRING_FORWARD)
_context_menu.add_item("Send Back", MENU_SEND_BACK)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_separator()
_context_menu.add_destructive_item("Erase Shape", MENU_DELETE)
_context_menu.popup_at(mb.global_position, _global_shape_target_rect(hit_shape))
return
# Empty canvas -> add primitives.
@@ -1382,8 +1410,8 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
_context_menu.add_item("Add Circle", MENU_ADD_CIRCLE)
if _has_clipboard:
_context_menu.add_separator()
_context_menu.add_item("Paste", MENU_PASTE)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_item("Paste Shape", MENU_PASTE)
_context_menu.popup_at(mb.global_position)
func _handle_left_press(world_pos: Vector2) -> void:
@@ -1464,6 +1492,21 @@ func _handle_left_press(world_pos: Vector2) -> void:
shape_selected.emit(-1)
func _create_point_from_toolbar(world_pos: Vector2) -> void:
var shape_idx: int = hit_test_segment(world_pos)
if shape_idx < 0:
return
_selected_shape_idx = shape_idx
shape_selected.emit(shape_idx)
var new_vertex_index: int = create_point_on_edge(world_pos)
if new_vertex_index < 0:
return
_create_point_armed = false
_drag_mode = DragMode.NONE
_emit_changed()
queue_redraw()
func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void:
match _drag_mode:
DragMode.VERTEX:
@@ -1821,6 +1864,19 @@ func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
var screen_pos := global_position + _pan_offset + world_pos * _zoom
return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0))
func _global_shape_target_rect(shape_idx: int) -> Rect2:
if shape_idx < 0 or shape_idx >= shapes.size():
return Rect2()
var world_bounds: Rect2 = _shape_bbox(shapes[shape_idx])
var screen_position := global_position + _pan_offset + world_bounds.position * _zoom
return Rect2(screen_position, world_bounds.size * _zoom).grow(4.0)
func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size)
if gs <= 0.0:
+361 -182
View File
@@ -12,6 +12,9 @@ extends Control
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const SandboxThemeScript := preload("res://scripts/stage/sandbox_theme.gd")
const StageDialogStylerScript := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
@@ -19,6 +22,7 @@ const ThumbnailCacheScript := preload("res://scripts/shared/thumbnails/thumbnail
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const PropAttachPreviewScene := preload("res://scenes/editors/prop/prop_attach_preview.tscn")
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const PropInspectorIconsScript := preload("res://scripts/editors/prop/prop_inspector_icons.gd")
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
@@ -92,10 +96,10 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
# ---------------------------------------------------------------------------
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _top_right: HBoxContainer = %TopRightControls
@onready var _top_right: FlowContainer = %TopRightControls
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: HBoxContainer = %ModeBar
@onready var _mode_bar: FlowContainer = %ModeBar
@onready var _size_tools: FlowContainer = %SizeTools
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@@ -114,6 +118,8 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
# ---------------------------------------------------------------------------
var _theme: PropTheme
var _editor_visual_theme: EditorVisualTheme
var _dialog_theme: SandboxTheme
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
@@ -126,11 +132,14 @@ var _thumb_cache: ThumbnailCache
var _ui_theme: Theme
# Inspector controls
var _preset_option: OptionButton
var _material_section: VBoxContainer
var _preset_option: PropInspectorDropdown
var _material_section: PanelContainer
var _mass_label: Label
var _mass_value: Label
var _friction_label: Label
var _friction_value: Label
var _bounce_label: Label
var _bounce_value: Label
var _fill_button: ColorPickerButton
var _outline_button: ColorPickerButton
var _width_spin: SpinBox
@@ -139,14 +148,16 @@ var _radius_row: Control
var _reshaped_note: Label
var _vertex_label: Label
var _delete_button: Button
var _shape_section: VBoxContainer
var _primitive_option: OptionButton
var _create_point_button: Button
var _create_point_hint: Label
var _shape_section: PanelContainer
var _primitive_option: PropInspectorDropdown
var _params_box: VBoxContainer
var _param_spins: Dictionary = {}
var _add_button: Button
var _updating_inspector: bool = false
var _tag_checks: Dictionary = {}
var _tag_section: VBoxContainer
var _tag_section: PanelContainer
var _stage_authoring: bool = false
var _stage_return_pending: bool = false
var _stage_place_button: Button
@@ -173,7 +184,7 @@ var _points_button: Button
var _attach_button: Button
# Add-shape inspector section (hidden in Reshape mode, Round 3)
var _add_section: VBoxContainer
var _add_section: PanelContainer
var _anchor_section: VBoxContainer
var _anchor_list: ItemList
var _anchor_role: OptionButton
@@ -212,6 +223,10 @@ var _recent_props: Array[String] = []
func _ready() -> void:
if not _stage_authoring:
get_tree().auto_accept_quit = false
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_dialog_theme = SandboxThemeScript.new()
_dialog_theme.load_from(SandboxThemeScript.DEFAULT_PATH)
_theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new()
@@ -248,6 +263,7 @@ func _ready() -> void:
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
_canvas.set_context_menu_visual_theme(_editor_visual_theme, &"prop")
_canvas.set_context_menu_font_size(_theme.font_context_menu())
_canvas.set_anchor_data(_anchors)
var origin_marker_color: Color = _theme.text_muted_color()
@@ -260,7 +276,7 @@ func _ready() -> void:
_cursor_coords.add_theme_font_size_override("font_size", _theme.font_status())
_save_status.add_theme_font_size_override("font_size", _theme.font_status())
_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER
_mode_bar.alignment = FlowContainer.ALIGNMENT_CENTER
_refresh_inspector_from_selection()
_sync_mode_buttons(_canvas.get_edit_mode())
@@ -311,7 +327,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
return
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_whole_prop_resize_dragging():
if _canvas.is_create_point_armed():
_canvas.cancel_create_point()
_update_create_point_feedback()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_dragging():
_canvas.cancel_whole_prop_resize()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_enabled():
@@ -337,8 +357,11 @@ func _update_save_status() -> void:
var name: String = _prop_name_edit.text if _prop_name_edit != null else ""
if name.is_empty():
name = "Unnamed Prop"
var suffix: String = " • Modified" if _dirty else ""
_save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix]
var has_saved_path: bool = not _current_path.is_empty()
var state: String = "Unsaved changes" if _dirty else ("Saved" if has_saved_path else "Not saved yet")
_save_status.text = "%s — %s — %s" % [name, PRESET_LABELS[_material_preset], state]
var status_color: Color = _editor_visual_theme.chrome_color(&"dirty", &"prop") if _dirty else (_editor_visual_theme.chrome_color(&"saved", &"prop") if has_saved_path else _editor_visual_theme.chrome_color(&"text_muted", &"prop"))
_save_status.add_theme_color_override("font_color", status_color)
_update_window_title()
@@ -372,10 +395,7 @@ func _make_mode_button(mode: int) -> Button:
button.button_group = _mode_group
button.custom_minimum_size.y = float(_theme.mode_button_min_height())
button.add_theme_font_size_override("font_size", _theme.font_mode())
# No keyboard focus: the focus stylebox otherwise draws over the pressed
# stylebox, hiding the selected (accent-bordered) state until the cursor
# moves away (Round 4 fix).
button.focus_mode = Control.FOCUS_NONE
button.focus_mode = Control.FOCUS_ALL
_mode_bar.add_child(button)
return button
@@ -399,6 +419,8 @@ func _on_canvas_mode_changed(mode: int) -> void:
return
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
if mode != PropCanvasScript.EditMode.POINTS and _canvas.is_create_point_armed():
_canvas.cancel_create_point()
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
@@ -430,6 +452,11 @@ func _sync_mode_controls(mode: int) -> void:
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _create_point_button != null:
_create_point_button.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
if _create_point_hint != null:
_create_point_hint.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
_update_create_point_feedback()
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
@@ -461,73 +488,15 @@ func _build_attach_tools() -> void:
# ---------------------------------------------------------------------------
func _build_control_theme() -> void:
_ui_theme = Theme.new()
var bg: Color = _theme.canvas_background_color()
var panel := StyleBoxFlat.new()
panel.bg_color = Color(0.12, 0.12, 0.12, 1.0)
panel.set_corner_radius_all(6)
panel.content_margin_left = 8
panel.content_margin_right = 8
panel.content_margin_top = 6
panel.content_margin_bottom = 6
panel.border_color = Color(1.0, 1.0, 1.0, 0.12)
panel.set_border_width_all(1)
var panel_dark := StyleBoxFlat.new()
panel_dark.bg_color = bg.darkened(0.25)
panel_dark.set_corner_radius_all(6)
panel_dark.content_margin_left = 8
panel_dark.content_margin_right = 8
panel_dark.content_margin_top = 6
panel_dark.content_margin_bottom = 6
panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12)
panel_dark.set_border_width_all(1)
var panel_pressed := StyleBoxFlat.new()
panel_pressed.bg_color = bg.darkened(0.25)
panel_pressed.set_corner_radius_all(6)
panel_pressed.content_margin_left = 8
panel_pressed.content_margin_right = 8
panel_pressed.content_margin_top = 6
panel_pressed.content_margin_bottom = 6
panel_pressed.border_color = _theme.accent_color()
panel_pressed.set_border_width_all(2)
_ui_theme.set_stylebox("normal", "Button", panel)
_ui_theme.set_stylebox("hover", "Button", panel_dark)
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
_ui_theme.set_stylebox("focus", "Button", panel)
var inspector_surface := StyleBoxFlat.new()
inspector_surface.bg_color = _theme.inspector_background_color()
inspector_surface.border_color = _theme.border_color()
inspector_surface.set_border_width_all(1)
inspector_surface.content_margin_left = 8
inspector_surface.content_margin_right = 8
_ui_theme = _editor_visual_theme.control_theme(&"prop")
theme = _ui_theme
var inspector_surface: StyleBoxFlat = _ui_theme.get_stylebox("panel", "PanelContainer").duplicate() as StyleBoxFlat
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
var splitter := StyleBoxFlat.new()
splitter.bg_color = _theme.border_color().lightened(0.35)
splitter.bg_color = _editor_visual_theme.chrome_color(&"border", &"prop").lightened(0.35)
splitter.content_margin_left = 3
splitter.content_margin_right = 3
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
_ui_theme.set_stylebox("normal", "OptionButton", panel)
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
_ui_theme.set_stylebox("normal", "SpinBox", panel_dark)
_ui_theme.set_stylebox("normal", "LineEdit", panel_dark)
_ui_theme.set_stylebox("normal", "ColorPickerButton", panel)
_ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark)
_ui_theme.set_stylebox("normal", "PopupMenu", panel_dark)
_ui_theme.set_stylebox("panel", "MenuBar", panel_dark)
_ui_theme.set_stylebox("normal", "CheckBox", panel)
_ui_theme.set_stylebox("panel", "PanelContainer", panel_dark)
_ui_theme.set_color("font_color", "Label", _theme.text_color())
_ui_theme.set_color("font_color", "Button", _theme.text_color())
_ui_theme.set_color("font_hover_color", "Button", Color.WHITE)
_ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color())
_ui_theme.set_font_size("font_size", "Label", _theme.font_body())
_ui_theme.set_font_size("font_size", "Button", _theme.font_body())
_ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body())
_ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body())
_ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body())
_ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu())
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
_menu_bar.theme = _ui_theme
_top_right.theme = _ui_theme
_prop_name_edit.theme = _ui_theme
@@ -544,26 +513,26 @@ func _setup_menu_bar() -> void:
var file_menu := PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("🆕 New", MENU_NEW)
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
file_menu.add_item("📂 Load...", MENU_LOAD)
file_menu.add_separator()
file_menu.add_item("📂 Load…", MENU_LOAD)
var recent_popup := PopupMenu.new()
recent_popup.name = "RecentProps"
recent_popup.id_pressed.connect(_on_recent_prop_selected)
file_menu.add_child(recent_popup)
file_menu.add_submenu_item("🕑 Recent", "RecentProps")
file_menu.add_submenu_item("🕑 Open Recent", "RecentProps")
file_menu.add_separator()
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As…", MENU_SAVE_AS)
file_menu.id_pressed.connect(_on_menu_id_pressed)
file_menu.about_to_popup.connect(_update_recent_menu)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "📁 File")
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
var view_menu := PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)
view_menu.add_item("🔍 Reset Views", MENU_RESET_VIEWS)
view_menu.id_pressed.connect(_on_menu_id_pressed)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "👁️ View")
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
# ---------------------------------------------------------------------------
@@ -654,22 +623,35 @@ func _setup_dialogs() -> void:
_load_dialog.current_dir = content_dir
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
_error_dialog.get_ok_button().text = "Got it"
StageDialogStylerScript.apply(
_error_dialog, _dialog_theme, _dialog_theme.font(),
_error_dialog.get_ok_button()
)
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_rename_anchor_dialog.set_meta(StageDialogStylerScript.ICON_META, "📝")
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
add_child(_rename_anchor_dialog)
StageDialogStylerScript.apply(
_rename_anchor_dialog, _dialog_theme, _dialog_theme.font(),
_rename_anchor_dialog.get_ok_button()
)
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
func _on_rename_anchor_text_submitted(_text: String) -> void:
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
if _rename_anchor_dialog == null or not StageDialogStylerScript.is_open(_rename_anchor_dialog):
return
var ok_button := _rename_anchor_dialog.get_ok_button()
if not ok_button.disabled:
@@ -708,8 +690,8 @@ func _build_top_right_controls() -> void:
_top_right.add_child(_snap_check)
func _build_size_tools() -> void:
_whole_prop_resize_button = Button.new()
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
_whole_prop_resize_button.text = "Move, Turn, or Resize Prop"
_whole_prop_resize_button.tooltip_text = "Move, turn, or resize the whole prop, including grip markers."
_whole_prop_resize_button.toggle_mode = true
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
@@ -920,24 +902,31 @@ func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
func _build_exit_dialog() -> void:
_exit_dialog = ConfirmationDialog.new()
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.title = "Unsaved Changes"
_exit_dialog.set_meta(StageDialogStylerScript.ICON_META, "💾")
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
_exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel"
_exit_dialog.theme = _ui_theme
_exit_dialog.ok_button_text = "Save"
var discard_button: Button = _exit_dialog.add_button("Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "Cancel"
add_child(_exit_dialog)
StageDialogStylerScript.apply(
_exit_dialog, _dialog_theme, _dialog_theme.font(),
_exit_dialog.get_ok_button(), [discard_button]
)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
_off_origin_save_dialog = ConfirmationDialog.new()
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
_off_origin_save_dialog.ok_button_text = "Center & Save"
var save_anyway_button: Button = _off_origin_save_dialog.add_button("Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "Cancel"
add_child(_off_origin_save_dialog)
StageDialogStylerScript.apply(
_off_origin_save_dialog, _dialog_theme, _dialog_theme.font(),
_off_origin_save_dialog.get_ok_button(), [], [save_anyway_button]
)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"save_anyway": await _execute_pending_save(false))
@@ -959,7 +948,7 @@ func _request_close() -> void:
_pending_quit = true
_pending_new = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.popup_centered()
StageDialogStylerScript.popup(_exit_dialog)
func _request_new() -> void:
@@ -970,7 +959,7 @@ func _request_new() -> void:
_pending_new = true
_pending_quit = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?"
_exit_dialog.popup_centered()
StageDialogStylerScript.popup(_exit_dialog)
func _request_stage_return() -> void:
@@ -986,11 +975,11 @@ func _request_stage_return() -> void:
_pending_new = false
_pending_quit = false
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage."
_exit_dialog.popup_centered()
StageDialogStylerScript.popup(_exit_dialog)
func _on_exit_save() -> void:
_exit_dialog.hide()
StageDialogStylerScript.hide(_exit_dialog)
if _current_path.is_empty():
_save_dialog.popup_centered()
return
@@ -999,7 +988,7 @@ func _on_exit_save() -> void:
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
StageDialogStylerScript.hide(_exit_dialog)
if not _stage_authoring:
_clear_autosave()
if _stage_return_pending:
@@ -1074,47 +1063,60 @@ func _on_menu_id_pressed(id: int) -> void:
# ---------------------------------------------------------------------------
func _build_inspector() -> void:
var header_size: int = _theme.font_header()
var row_height: int = _theme.row_min_height()
_inspector_list.add_theme_constant_override("separation", 10)
_inspector_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# Material preset section
_material_section = VBoxContainer.new()
_material_section.add_theme_constant_override("separation", 6)
_material_section = _make_inspector_card()
_inspector_list.add_child(_material_section)
var material_title := Label.new()
material_title.text = "Material"
material_title.add_theme_font_size_override("font_size", header_size)
_material_section.add_child(material_title)
var material_content: VBoxContainer = _inspector_card_content(_material_section)
material_content.add_child(_make_inspector_header("Material", PropInspectorIconsScript.section("material")))
_preset_option = OptionButton.new()
for label: String in PRESET_LABELS:
_preset_option.add_item(label)
_preset_option = PropInspectorDropdown.new()
for index: int in PRESET_LABELS.size():
_preset_option.add_icon_item(
PropInspectorIconsScript.material(index, _theme.material_tint(index, "fill")),
PRESET_LABELS[index]
)
_preset_option.selected = _material_preset
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
_preset_option.configure(
_ui_theme,
_theme.inspector_background_color(),
_editor_visual_theme.chrome_color(&"selected_surface", &"prop"),
_theme.border_color(),
_theme.accent_color(),
_editor_visual_theme.chrome_color(&"focus", &"prop"),
_theme.text_color(),
_theme.font_body()
)
_preset_option.item_selected.connect(_on_preset_selected)
_material_section.add_child(_preset_option)
material_content.add_child(_preset_option)
_mass_label = Label.new()
_friction_label = Label.new()
_bounce_label = Label.new()
_material_section.add_child(_mass_label)
_material_section.add_child(_friction_label)
_material_section.add_child(_bounce_label)
var stats := HBoxContainer.new()
stats.add_theme_constant_override("separation", 6)
material_content.add_child(stats)
var mass_tile: Array[Label] = _make_stat_tile(stats, "Mass")
_mass_label = mass_tile[0]
_mass_value = mass_tile[1]
var grip_tile: Array[Label] = _make_stat_tile(stats, "Surface grip")
_friction_label = grip_tile[0]
_friction_value = grip_tile[1]
var bounce_tile: Array[Label] = _make_stat_tile(stats, "Bounce")
_bounce_label = bounce_tile[0]
_bounce_value = bounce_tile[1]
_update_physics_labels()
# Selected-shape section
_shape_section = VBoxContainer.new()
_shape_section.add_theme_constant_override("separation", 6)
_shape_section = _make_inspector_card()
_inspector_list.add_child(_shape_section)
var shape_title := Label.new()
shape_title.text = "Selected Shape"
shape_title.add_theme_font_size_override("font_size", header_size)
_shape_section.add_child(shape_title)
var shape_content: VBoxContainer = _inspector_card_content(_shape_section)
shape_content.add_child(_make_inspector_header("Selected Shape", PropInspectorIconsScript.section("selected_shape")))
var fill_row := HBoxContainer.new()
fill_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(fill_row)
shape_content.add_child(fill_row)
var fill_label := Label.new()
fill_label.text = "Fill:"
fill_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1127,7 +1129,7 @@ func _build_inspector() -> void:
var outline_row := HBoxContainer.new()
outline_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(outline_row)
shape_content.add_child(outline_row)
var outline_label := Label.new()
outline_label.text = "Outline:"
outline_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1140,7 +1142,7 @@ func _build_inspector() -> void:
var width_row := HBoxContainer.new()
width_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(width_row)
shape_content.add_child(width_row)
var width_label := Label.new()
width_label.text = "Width:"
width_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1156,7 +1158,7 @@ func _build_inspector() -> void:
_radius_row = HBoxContainer.new()
_radius_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(_radius_row)
shape_content.add_child(_radius_row)
var radius_label := Label.new()
radius_label.text = "Radius:"
radius_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1173,43 +1175,66 @@ func _build_inspector() -> void:
_reshaped_note = Label.new()
_reshaped_note.text = "✏️ This shape was reshaped"
_reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_reshaped_note)
shape_content.add_child(_reshaped_note)
_vertex_label = Label.new()
_shape_section.add_child(_vertex_label)
shape_content.add_child(_vertex_label)
_create_point_button = Button.new()
_create_point_button.text = "📍 Create Point"
_create_point_button.tooltip_text = "Add a point to the selected shape's edge."
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
_create_point_button.pressed.connect(_on_create_point_pressed)
shape_content.add_child(_create_point_button)
_create_point_hint = Label.new()
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
shape_content.add_child(_create_point_hint)
_delete_button = Button.new()
_delete_button.text = "Erase Shape"
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
_delete_button.pressed.connect(_on_delete_shape_pressed)
_shape_section.add_child(_delete_button)
shape_content.add_child(_delete_button)
# Add-shape section (hidden in Reshape mode, Round 3)
_add_section = VBoxContainer.new()
_add_section.add_theme_constant_override("separation", 8)
_add_section = _make_inspector_card()
_inspector_list.add_child(_add_section)
var add_content: VBoxContainer = _inspector_card_content(_add_section)
add_content.add_child(_make_inspector_header("Add Shape", PropInspectorIconsScript.section("add_shape")))
var add_title := Label.new()
add_title.text = "Add Shape"
add_title.add_theme_font_size_override("font_size", header_size)
_add_section.add_child(add_title)
_primitive_option = OptionButton.new()
for label: String in PRIMITIVE_LABELS:
_primitive_option.add_item(label)
_primitive_option = PropInspectorDropdown.new()
for index: int in PRIMITIVE_LABELS.size():
_primitive_option.add_icon_item(
PropInspectorIconsScript.shape(PRIMITIVE_IDS[index], _primitive_icon_color(PRIMITIVE_IDS[index])),
PRIMITIVE_LABELS[index]
)
_primitive_option.custom_minimum_size.y = float(_theme.button_min_height())
_primitive_option.configure(
_ui_theme,
_theme.inspector_background_color(),
_editor_visual_theme.chrome_color(&"selected_surface", &"prop"),
_theme.border_color(),
_theme.accent_color(),
_editor_visual_theme.chrome_color(&"focus", &"prop"),
_theme.text_color(),
_theme.font_body()
)
_primitive_option.item_selected.connect(_on_primitive_selected)
_add_section.add_child(_primitive_option)
add_content.add_child(_primitive_option)
_params_box = VBoxContainer.new()
_params_box.add_theme_constant_override("separation", 4)
_add_section.add_child(_params_box)
add_content.add_child(_params_box)
_add_button = Button.new()
_add_button.text = "Add a Shape"
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
_add_button.pressed.connect(_on_add_pressed)
_add_section.add_child(_add_button)
add_content.add_child(_add_button)
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
@@ -1218,8 +1243,119 @@ func _build_inspector() -> void:
_refresh_tag_controls()
func _make_inspector_card() -> PanelContainer:
var card := PanelContainer.new()
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var style := StyleBoxFlat.new()
style.bg_color = _theme.inspector_background_color().darkened(0.07)
style.border_color = _theme.border_color()
style.set_border_width_all(1)
style.set_corner_radius_all(10)
style.content_margin_left = 10.0
style.content_margin_top = 10.0
style.content_margin_right = 10.0
style.content_margin_bottom = 10.0
card.add_theme_stylebox_override("panel", style)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 8)
card.add_child(content)
return card
func _inspector_card_content(card: PanelContainer) -> VBoxContainer:
return card.get_child(0) as VBoxContainer
func _make_inspector_header(title: String, icon: Texture2D) -> HBoxContainer:
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 8)
var icon_view := TextureRect.new()
icon_view.texture = icon
icon_view.custom_minimum_size = Vector2(30.0, 30.0)
icon_view.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_view.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(icon_view)
var label := Label.new()
label.text = title
label.add_theme_font_size_override("font_size", _theme.font_header())
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(label)
return header
func _make_stat_tile(parent: Control, caption_text: String) -> Array[Label]:
var tile := PanelContainer.new()
tile.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var style := StyleBoxFlat.new()
style.bg_color = _editor_visual_theme.chrome_color(&"background", &"prop").darkened(0.02)
style.border_color = _theme.border_color()
style.set_border_width_all(1)
style.set_corner_radius_all(8)
style.content_margin_left = 7.0
style.content_margin_top = 7.0
style.content_margin_right = 7.0
style.content_margin_bottom = 7.0
tile.add_theme_stylebox_override("panel", style)
parent.add_child(tile)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 1)
tile.add_child(content)
var caption := Label.new()
caption.text = caption_text
caption.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
caption.add_theme_font_size_override("font_size", 14)
caption.add_theme_color_override("font_color", _theme.text_muted_color())
content.add_child(caption)
var value := Label.new()
value.add_theme_font_size_override("font_size", 22)
content.add_child(value)
return [caption, value]
func _style_tag_button(button: Button) -> void:
var text_color: Color = _theme.text_color()
var surface: Color = _editor_visual_theme.chrome_color(&"background", &"prop")
var border: Color = _theme.border_color()
var accent: Color = _theme.accent_color()
button.add_theme_stylebox_override("normal", _make_tag_style(surface, border, 1))
button.add_theme_stylebox_override("hover", _make_tag_style(surface.lightened(0.12), accent, 1))
button.add_theme_stylebox_override("pressed", _make_tag_style(_editor_visual_theme.chrome_color(&"selected_surface", &"prop"), accent, 2))
button.add_theme_stylebox_override("focus", _make_tag_style(Color(0.0, 0.0, 0.0, 0.0), _editor_visual_theme.chrome_color(&"focus", &"prop"), 2))
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", text_color)
button.add_theme_color_override("font_pressed_color", text_color)
button.add_theme_color_override("icon_normal_color", Color.WHITE)
button.add_theme_color_override("icon_hover_color", Color.WHITE)
button.add_theme_color_override("icon_pressed_color", Color.WHITE)
func _make_tag_style(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = fill
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(8)
style.content_margin_left = 7.0
style.content_margin_top = 5.0
style.content_margin_right = 7.0
style.content_margin_bottom = 5.0
return style
func _primitive_icon_color(primitive_id: String) -> Color:
match primitive_id:
"box": return Color("#70C8F0")
"plank": return Color("#F3B65D")
"triangle": return Color("#66C681")
"line": return Color("#DDE7F5")
"circle": return Color("#C9A3FF")
return _theme.accent_color()
func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("TopBar")) as HBoxContainer
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
if top_bar == null:
return
var name_label: Label = top_bar.get_node_or_null(NodePath("NameLabel")) as Label
@@ -1232,6 +1368,7 @@ func _build_stage_authoring_controls() -> void:
_stage_place_button.custom_minimum_size.y = float(_theme.button_min_height())
_stage_place_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_stage_place_button.theme = _ui_theme
_editor_visual_theme.style_button_role(_stage_place_button, &"primary", &"prop")
_stage_place_button.pressed.connect(_request_stage_return)
top_bar.add_child(_stage_place_button)
@@ -1257,29 +1394,30 @@ func _apply_authoring_context() -> void:
func _build_tag_inspector() -> void:
_tag_section = VBoxContainer.new()
_tag_section.add_theme_constant_override("separation", 4)
_tag_section = _make_inspector_card()
_inspector_list.add_child(_tag_section)
var title := Label.new()
title.text = "Artwork Tags"
title.add_theme_font_size_override("font_size", _theme.font_header())
_tag_section.add_child(title)
var tag_content: VBoxContainer = _inspector_card_content(_tag_section)
tag_content.add_child(_make_inspector_header("Artwork Tags", PropInspectorIconsScript.section("tags")))
var hint := Label.new()
hint.text = "Choose a few so this art is easy to find."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tag_section.add_child(hint)
hint.add_theme_color_override("font_color", _theme.text_muted_color())
tag_content.add_child(hint)
var rows := FlowContainer.new()
rows.add_theme_constant_override("h_separation", 6)
rows.add_theme_constant_override("v_separation", 4)
_tag_section.add_child(rows)
rows.add_theme_constant_override("v_separation", 6)
tag_content.add_child(rows)
for index: int in ART_TAG_IDS.size():
var tag_id: String = ART_TAG_IDS[index]
var check := CheckBox.new()
check.text = ART_TAG_LABELS[index]
check.custom_minimum_size.y = float(_theme.button_min_height())
check.toggled.connect(_on_art_tag_toggled.bind(tag_id))
rows.add_child(check)
_tag_checks[tag_id] = check
var tag_button := Button.new()
tag_button.text = ART_TAG_LABELS[index]
tag_button.toggle_mode = true
tag_button.custom_minimum_size = Vector2(122.0, 50.0)
tag_button.icon = PropInspectorIconsScript.tag(tag_id)
tag_button.toggled.connect(_on_art_tag_toggled.bind(tag_id))
_style_tag_button(tag_button)
rows.add_child(tag_button)
_tag_checks[tag_id] = tag_button
func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
@@ -1298,9 +1436,9 @@ func _refresh_tag_controls() -> void:
return
_updating_inspector = true
for tag_id: String in ART_TAG_IDS:
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox
if check != null:
check.set_pressed_no_signal(_tags.has(tag_id))
var tag_button: Button = _tag_checks.get(tag_id) as Button
if tag_button != null:
tag_button.set_pressed_no_signal(_tags.has(tag_id))
_updating_inspector = false
func _build_anchor_inspector() -> void:
@@ -1312,6 +1450,8 @@ func _build_anchor_inspector() -> void:
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
for label: String in ["Add Anchor", "Rename", "Delete"]:
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
var role: StringName = &"primary" if label == "Add Anchor" else (&"destructive" if label == "Delete" else &"secondary")
_editor_visual_theme.style_button_role(button, role, &"prop")
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
elif label == "Rename":
_rename_anchor_button = button
@@ -1324,11 +1464,17 @@ func _build_anchor_inspector() -> void:
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z = _anchor_spin("Held prop layer", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z.step = 1.0
var anchor_layer_hint := Label.new()
anchor_layer_hint.text = "Negative: behind · 0: normal order · Positive: farther in front."
anchor_layer_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
anchor_layer_hint.add_theme_font_size_override("font_size", _theme.font_status())
anchor_layer_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_anchor_section.add_child(anchor_layer_hint)
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
var button := Button.new(); button.text = str(entry["text"]); button.custom_minimum_size.y = float(_theme.button_min_height()); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
_refresh_anchor_inspector()
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
@@ -1341,6 +1487,7 @@ func _anchor_option(label_text: String, entries: PackedStringArray, callback: Ca
row.add_child(label)
var option := OptionButton.new()
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
option.custom_minimum_size.y = float(_theme.button_min_height())
for entry: String in entries:
option.add_item(entry)
option.item_selected.connect(callback)
@@ -1360,6 +1507,7 @@ func _anchor_spin(label_text: String, minimum: float, maximum: float, callback:
spin.max_value = maximum
spin.step = 1.0
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.custom_minimum_size.y = float(_theme.button_min_height())
spin.value_changed.connect(callback)
row.add_child(spin)
return spin
@@ -1400,13 +1548,17 @@ func _on_rename_anchor_pressed() -> void:
_rename_anchor_source_id = _selected_anchor_id
_rename_anchor_edit.text = _rename_anchor_source_id
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
StageDialogStylerScript.popup(_rename_anchor_dialog)
_rename_anchor_edit.call_deferred("grab_focus")
_rename_anchor_edit.call_deferred("select_all")
func _update_rename_anchor_confirmation(value: String) -> void:
if _rename_anchor_dialog != null:
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
var native_button: Button = _rename_anchor_dialog.get_ok_button()
native_button.disabled = value.strip_edges().is_empty()
var visible_button: Button = StageDialogStylerScript.visual_button(native_button)
if visible_button != native_button:
visible_button.disabled = native_button.disabled
func _commit_anchor_rename() -> void:
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
@@ -1466,15 +1618,16 @@ func _apply_control_theme(node: Node) -> void:
func _update_physics_labels() -> void:
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
_friction_label.text = "Friction: %.2f" % mat.friction
_bounce_label.text = "Bounce: %.2f" % mat.bounce
_mass_value.text = "%.1f" % PropBlockScript.mass_for(_material_preset)
_friction_value.text = "%.2f" % mat.friction
_friction_label.tooltip_text = "Higher values make sliding harder."
_bounce_value.text = "%.2f" % mat.bounce
func _on_preset_selected(index: int) -> void:
_material_preset = clampi(index, 0, PRESET_LABELS.size() - 1)
_update_physics_labels()
_update_save_status()
_mark_dirty()
func _rebuild_primitive_params() -> void:
@@ -1604,6 +1757,7 @@ func _refresh_inspector_from_selection() -> void:
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
_mark_dirty()
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
@@ -1700,6 +1854,25 @@ func _center_prop_on_origin(focus_view: bool = true) -> bool:
func _on_canvas_shape_selected(_idx: int) -> void:
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
func _on_create_point_pressed() -> void:
if _canvas.get_selected_index() < 0:
return
_canvas.arm_create_point()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_update_create_point_feedback()
func _update_create_point_feedback() -> void:
if _create_point_button == null or _create_point_hint == null or _canvas == null:
return
var armed: bool = _canvas.is_create_point_armed()
_create_point_button.text = "Click an Edge to Place Point" if armed else "📍 Create Point"
_create_point_button.tooltip_text = "Press Esc to cancel." if armed else "Add a point to the selected shape's edge."
_create_point_hint.text = "Click near an edge to place the point. Press Esc to cancel." if armed else "Adds a point where you click on the selected edge."
func _on_canvas_copy_requested(shape: Dictionary) -> void:
@@ -1768,11 +1941,16 @@ func _check_autosave_recovery() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
dialog.set_meta(StageDialogStylerScript.ICON_META, "🔄")
dialog.dialog_text = "An unsaved prop was recovered. Restore this work?"
dialog.ok_button_text = "Restore"
var discard_button: Button = dialog.add_button("Discard", false, "discard")
dialog.get_cancel_button().text = "Cancel"
add_child(dialog)
StageDialogStylerScript.apply(
dialog, _dialog_theme, _dialog_theme.font(),
dialog.get_ok_button(), [discard_button]
)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
_current_path = source_path
@@ -1782,7 +1960,8 @@ func _check_autosave_recovery() -> void:
if action == &"discard":
_clear_autosave()
dialog.queue_free())
dialog.popup_centered()
dialog.canceled.connect(func() -> void: dialog.queue_free())
StageDialogStylerScript.popup(dialog)
# ---------------------------------------------------------------------------
@@ -1858,7 +2037,7 @@ func _begin_save_request(path: String, with_thumbnail: bool) -> void:
_pending_save_path = path
_pending_save_with_thumbnail = with_thumbnail
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
_off_origin_save_dialog.popup_centered()
StageDialogStylerScript.popup(_off_origin_save_dialog)
return
await _execute_pending_save(false)
@@ -1923,4 +2102,4 @@ func _on_load_file_selected(path: String) -> void:
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()
StageDialogStylerScript.popup(_error_dialog)
@@ -0,0 +1,9 @@
class_name PropInspectorDropdown
extends EditorSelectionDropdown
## Prop-specific icon adapter for the shared editor selection dropdown.
const PropInspectorIconsScript := preload("res://scripts/editors/prop/prop_inspector_icons.gd")
func add_icon_item(texture: Texture2D, label: String) -> void:
add_styled_item(label, texture, PropInspectorIconsScript.popup_item(texture))
@@ -0,0 +1 @@
uid://bnnujoeli7cwk
@@ -0,0 +1,95 @@
class_name PropInspectorIcons
extends RefCounted
## Small flat 2D glyphs for the Prop Editor inspector.
## SVG is rasterized at runtime so colors can follow the editor and material tokens.
const ICON_SIZE := 24
static func material(preset: int, tint: Color) -> Texture2D:
if preset == 0:
return _texture(_svg('<circle cx="24" cy="24" r="15" fill="#252B38" stroke="#C4D1E5" stroke-width="4"/>'))
return _texture(_svg('<circle cx="24" cy="24" r="15" fill="%s" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="3"/>' % _hex(tint)))
static func shape(shape_id: String, tint: Color = Color("#22C6FF")) -> Texture2D:
var color := _hex(tint)
var content := ""
match shape_id:
"box":
content = '<rect x="8" y="8" width="32" height="32" rx="2" fill="%s"/>' % color
"plank":
content = '<rect x="5" y="16" width="38" height="16" rx="2" fill="%s"/>' % color
"triangle":
content = '<polygon points="24,6 44,41 4,41" fill="%s"/>' % color
"line":
content = '<path d="M8 38 L40 10" fill="none" stroke="%s" stroke-width="6" stroke-linecap="round"/>' % color
"circle":
content = '<circle cx="24" cy="24" r="16" fill="%s"/>' % color
_:
content = '<rect x="8" y="8" width="32" height="32" rx="2" fill="%s"/>' % color
return _texture(_svg(content))
static func section(section_id: String) -> Texture2D:
var content := ""
match section_id:
"material":
content = '<rect x="7" y="9" width="14" height="14" rx="3" fill="#F3B65D"/><rect x="27" y="9" width="14" height="14" rx="3" fill="#E76D66"/><rect x="7" y="27" width="14" height="14" rx="3" fill="#66C681"/><rect x="27" y="27" width="14" height="14" rx="3" fill="#70BFE8"/>'
"selected_shape":
content = '<path d="M9 32 L16 11 L38 15 L35 37 Z" fill="#DDE7F5" stroke="#22C6FF" stroke-width="3" stroke-linejoin="round"/><circle cx="9" cy="32" r="3" fill="#FFFFFF"/><circle cx="16" cy="11" r="3" fill="#FFFFFF"/><circle cx="38" cy="15" r="3" fill="#FFFFFF"/><circle cx="35" cy="37" r="3" fill="#FFFFFF"/>'
"add_shape":
content = '<rect x="7" y="7" width="34" height="34" fill="none" stroke="#22C6FF" stroke-width="5"/><path d="M24 15 V33 M15 24 H33" stroke="#22C6FF" stroke-width="4" stroke-linecap="round"/>'
"tags":
content = '<path d="M7 17 L17 7 H34 L42 15 V32 L25 42 L7 24 Z" fill="#FFB84D"/><circle cx="31" cy="16" r="3.5" fill="#252B38"/>'
return _texture(_svg(content))
static func tag(tag_id: String) -> Texture2D:
var color := "#66C681"
var body := ""
match tag_id:
"nature":
color = "#66C681"
body = '<path d="M9 35 C9 18 21 8 39 8 C39 26 29 39 13 39 Z" fill="%s"/><path d="M12 38 L33 15" stroke="#E8FFF0" stroke-width="3" stroke-linecap="round"/>' % color
"buildings":
color = "#F3A653"
body = '<path d="M10 40 V10 H29 V18 H38 V40 Z" fill="%s"/><path d="M16 16 H21 M16 23 H21 M16 30 H21 M31 24 H35 M31 31 H35" stroke="#3C2A22" stroke-width="3"/>' % color
"walls":
color = "#F36B5B"
body = '<path d="M5 10 H21 V19 H5 Z M24 10 H42 V19 H24 Z M14 22 H31 V31 H14 Z M4 34 H20 V42 H4 Z M23 34 H42 V42 H23 Z" fill="%s"/><path d="M22 10 V19 M13 22 V31 M31 34 V42" stroke="#733A37" stroke-width="2"/>' % color
"roads":
color = "#9BAEC9"
body = '<path d="M16 7 H32 L42 41 H6 Z" fill="%s"/><path d="M24 12 V19 M24 25 V31 M24 36 V40" stroke="#283247" stroke-width="3" stroke-linecap="round"/>' % color
"objects":
color = "#E8A96D"
body = '<rect x="7" y="11" width="34" height="26" fill="none" stroke="%s" stroke-width="5"/>' % color
return _texture(_svg(body))
static func popup_item(texture: Texture2D, size: int = 44) -> Texture2D:
if texture == null:
return null
var source: Image = texture.get_image()
var canvas := Image.create(size, size, false, Image.FORMAT_RGBA8)
var draw_size: Vector2i = Vector2i(mini(source.get_width(), size), mini(source.get_height(), size))
var source_rect := Rect2i(Vector2i.ZERO, draw_size)
var destination := Vector2i((size - draw_size.x) / 2, (size - draw_size.y) / 2)
canvas.blit_rect(source, source_rect, destination)
return ImageTexture.create_from_image(canvas)
static func _svg(content: String) -> String:
return '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 48 48">%s</svg>' % [ICON_SIZE, ICON_SIZE, content]
static func _texture(svg: String) -> Texture2D:
var image := Image.new()
var error: Error = image.load_svg_from_string(svg)
if error != OK:
push_warning("Prop Inspector icon could not be rasterized (%s)." % error_string(error))
return null
return ImageTexture.create_from_image(image)
static func _hex(color: Color) -> String:
return "#" + color.to_html(false)
@@ -0,0 +1 @@
uid://5ac0640i4bj7
+20 -22
View File
@@ -10,6 +10,7 @@ extends RefCounted
## truth.
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://prop_theme.json"
@@ -39,19 +40,16 @@ const DEFAULT_CONFIG: Dictionary = {
"background_color": "#1F1F1F",
"grid_color": "#FFFFFF26",
},
"ui": {
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
},
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
var _editor_visual_theme: EditorVisualTheme = EditorVisualThemeScript.new()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
@@ -155,71 +153,71 @@ func canvas_grid_color() -> Color:
# ---------------------------------------------------------------------------
# Kid-friendly UI tokens (Round 2)
# Compatibility accessors: editor chrome is owned by EditorVisualTheme.
# ---------------------------------------------------------------------------
func font_body() -> int:
return integer("ui.fonts.body", 18, 8, 72)
return _editor_visual_theme.chrome_px(&"body", &"prop")
func font_header() -> int:
return integer("ui.fonts.header", 24, 8, 96)
return _editor_visual_theme.chrome_px(&"section", &"prop")
func font_menu() -> int:
return integer("ui.fonts.menu", 22, 8, 72)
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_status() -> int:
return integer("ui.fonts.status", 18, 8, 72)
return _editor_visual_theme.chrome_px(&"status", &"prop")
func font_mode() -> int:
return integer("ui.fonts.mode", 26, 8, 96)
return _editor_visual_theme.chrome_px(&"mode", &"prop")
func font_menu_bar() -> int:
return integer("ui.fonts.menu_bar", 22, 8, 72)
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_prop_name() -> int:
return integer("ui.fonts.prop_name", 20, 8, 96)
return _editor_visual_theme.chrome_px(&"prop_name", &"prop")
func font_context_menu() -> int:
return integer("ui.fonts.context_menu", 22, 8, 96)
return _editor_visual_theme.chrome_px(&"context_menu", &"prop")
func button_min_height() -> int:
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
return _editor_visual_theme.chrome_px(&"button_min_height", &"prop")
func row_min_height() -> int:
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
return _editor_visual_theme.chrome_px(&"row_min_height", &"prop")
func mode_button_min_height() -> int:
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
return _editor_visual_theme.chrome_px(&"mode_button_min_height", &"prop")
func text_color() -> Color:
return color("ui.colors.text", Color("#F2F2F2"))
return _editor_visual_theme.chrome_color(&"text", &"prop")
func text_muted_color() -> Color:
return color("ui.colors.text_muted", Color("#9A9A9A"))
return _editor_visual_theme.chrome_color(&"text_muted", &"prop")
func border_color() -> Color:
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
return _editor_visual_theme.chrome_color(&"border", &"prop")
func accent_color() -> Color:
return color("ui.colors.accent", Color("#4FA3FF"))
return _editor_visual_theme.chrome_color(&"edit", &"prop")
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
return _editor_visual_theme.chrome_color(&"surface", &"prop")
func invalidate_caches() -> void:
+51 -39
View File
@@ -63,6 +63,7 @@ const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/
const STAGE_SURFACE_COLOR_CONTROLS_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const STAGE_SURFACE_COLOR_DIALOG_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_SURFACE_PREVIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const STAGE_GROUND_FINISH_CHOOSER_SCRIPT := preload("res://scripts/shared/stage_ui/stage_ground_finish_chooser.gd")
const STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT := preload("res://scripts/shared/stage_ui/stage_depth_choice_preview.gd")
const PROP_FILE_SCRIPT := preload("res://scripts/editors/prop/prop_file.gd")
const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
@@ -277,7 +278,7 @@ var _plain_color_draft: Color = Color("#F5EFE0")
var _new_ground_finish: StringName = &"finish_ground_grass"
var _hosted_prop_editor: PropEditor
var _hosted_artwork_path: String = ""
var _ground_finish_popup: PopupMenu
var _ground_finish_chooser: StageGroundFinishChooser
var _scenery_edit_mode: bool = false
var _scenery_multi_select: bool = false
var _scenery_single_dragging: bool = false
@@ -1729,9 +1730,13 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
return
if _scenery_controller != null and _scenery_controller.is_active():
var scenery_world_position: Vector2 = _viewport_to_world(mb.position)
var return_to_scenery_selector: bool = mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed and _scenery_controller.is_placement_tool()
if _scenery_controller.handle_mouse_button(mb, scenery_world_position, _is_mouse_over_ui()):
get_viewport().set_input_as_handled()
_refresh_scenery_capture_bar()
if return_to_scenery_selector:
_return_to_scenery_selector()
else:
_refresh_scenery_capture_bar()
return
if _scenery_edit_mode:
var edit_world_position: Vector2 = _viewport_to_world(mb.position)
@@ -3281,7 +3286,7 @@ func _build_ui() -> void:
_asset_browser.surface_color_confirmed.connect(_record_recent_surface_color)
_asset_browser.set_surface_color_history(_recent_surface_colors)
_asset_browser.set_background_state(_background_kind, _sky_id, _plain_color)
_build_ground_finish_popup(ui)
_build_ground_finish_chooser(ui)
_modal_views.build_file_dialogs(ui)
_scene_open_dialog = _modal_views.scene_open_dialog
@@ -3895,43 +3900,41 @@ func _update_scenery_depth_cursor_badge() -> void:
_scenery_depth_cursor_badge.visible = true
func _build_ground_finish_popup(ui: CanvasLayer) -> void:
_ground_finish_popup = PopupMenu.new()
_ground_finish_popup.name = "NewGroundLook"
_ground_finish_popup.theme = _sandbox_theme.control_theme() if _sandbox_theme != null else null
for index: int in GROUND_FINISH_LABELS.size():
_ground_finish_popup.add_item(GROUND_FINISH_LABELS[index], index)
_ground_finish_popup.set_item_as_checkable(index, true)
_ground_finish_popup.id_pressed.connect(_on_ground_finish_selected)
ui.add_child(_ground_finish_popup)
func _build_ground_finish_chooser(ui: CanvasLayer) -> void:
_ground_finish_chooser = STAGE_GROUND_FINISH_CHOOSER_SCRIPT.new() as StageGroundFinishChooser
_ground_finish_chooser.name = "NewGroundLook"
_ground_finish_chooser.configure(_sandbox_theme as SandboxTheme, _ui_font, GROUND_FINISH_IDS, GROUND_FINISH_LABELS)
_ground_finish_chooser.confirmed.connect(_on_ground_finish_draw_requested)
_ground_finish_chooser.canceled.connect(_on_ground_finish_chooser_closed)
ui.add_child(_ground_finish_chooser)
func _open_ground_finish_popup() -> void:
if _ground_finish_popup == null:
func _open_ground_finish_chooser() -> void:
if _ground_finish_chooser == null:
return
for index: int in GROUND_FINISH_IDS.size():
_ground_finish_popup.set_item_checked(index, GROUND_FINISH_IDS[index] == _new_ground_finish)
var origin: Vector2 = get_viewport().get_mouse_position()
var ground_button: Button = _palette_buttons.get("ground") as Button
if ground_button != null:
var button_rect: Rect2 = ground_button.get_global_rect()
origin = Vector2(button_rect.position.x, button_rect.end.y)
var viewport_size: Vector2 = get_viewport_rect().size
origin.x = clampf(origin.x, 8.0, maxf(8.0, viewport_size.x - 288.0))
origin.y = clampf(origin.y, 8.0, maxf(8.0, viewport_size.y - 320.0))
_ground_finish_popup.popup(Rect2i(Vector2i(origin), Vector2i(280, 312)))
_ground_finish_chooser.open_for_finish(_new_ground_finish)
func _on_ground_finish_selected(index: int) -> void:
if index < 0 or index >= GROUND_FINISH_IDS.size():
func _on_ground_finish_draw_requested() -> void:
if _ground_finish_chooser == null:
return
_new_ground_finish = GROUND_FINISH_IDS[index]
var finish_id: StringName = _ground_finish_chooser.selected_finish()
var index: int = GROUND_FINISH_IDS.find(finish_id)
if index < 0 or index >= GROUND_FINISH_LABELS.size():
return
_new_ground_finish = finish_id
if _terrain_painter != null:
_terrain_painter.set_ground_finish(_new_ground_finish)
set_placement_mode("ground")
_show_toast("New Ground look: %s" % GROUND_FINISH_LABELS[index])
func _on_ground_finish_chooser_closed() -> void:
for placement_id: String in _palette_buttons:
var palette_button: Button = _palette_buttons[placement_id] as Button
palette_button.set_pressed_no_signal(placement_id == _placement_id)
func _build_use_spot_overlay(ui: CanvasLayer) -> void:
_use_spot_overlay = STAGE_USE_SPOT_OVERLAY_SCRIPT.new() as StageUseSpotOverlay
_use_spot_overlay.name = "UseSpotOverlay"
@@ -4938,7 +4941,7 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
_open_selector(id)
return
if id == "ground":
_open_ground_finish_popup()
_open_ground_finish_chooser()
return
set_placement_mode(id)
else:
@@ -4954,7 +4957,7 @@ func _on_palette_card_activated(payload: Dictionary) -> void:
if bool(payload.get("opens_selector", false)):
_open_selector(id)
elif id == "ground":
_open_ground_finish_popup()
_open_ground_finish_chooser()
else:
set_placement_mode(id)
@@ -5001,12 +5004,13 @@ func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
func _open_selector(kind: String) -> void:
func _open_selector(kind: String, preserve_scenery_browse_state: bool = false) -> void:
if kind == "scenery":
if _scenery_edit_mode:
_exit_scenery_edit_mode()
if _scenery_controller != null and _scenery_controller.is_active():
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
_scenery_placed_count = 0
elif _scenery_edit_mode:
_exit_scenery_edit_mode()
@@ -5017,12 +5021,14 @@ func _open_selector(kind: String) -> void:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
if kind == "scenery":
_asset_browser.set_surface_color_history(_recent_surface_colors)
_asset_browser.set_background_state(_background_kind, _sky_id, _plain_color)
var selector_state: Dictionary = _asset_browser.open_selector(
kind,
_spawner.get_selected_stickman_path(),
_sky_id,
_background_kind,
_plain_color
_plain_color,
preserve_scenery_browse_state
)
if kind == "stickman":
var selected_stickman_path: String = String(selector_state.get("selected_stickman_path", _spawner.get_selected_stickman_path()))
@@ -5099,6 +5105,11 @@ func _on_asset_selected(entry: Dictionary) -> void:
set_placement_mode(kind)
func _return_to_scenery_selector() -> void:
_hide_scenery_capture_bar()
_open_selector("scenery", true)
func _load_starter_scene(entry: Dictionary) -> void:
var path: String = CONTENT_PATHS.canonicalize(String(entry.get("path", "")))
var read_result: Dictionary = _document.read(path) if _document != null else {}
@@ -5658,7 +5669,7 @@ func _refresh_scenery_capture_bar() -> void:
actions.append(_make_scenery_depth_capture_action(&"depth_floor", &"floor", "Front", selected_depth))
var artwork_name: String = String(_scenery_controller.get_active_artwork_name())
var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another · Cancel undoes"
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "done_tool", "label": "Back", "primary": true})
actions.append({"id": "cancel_tool", "label": "Cancel"})
_use_target_capture_bar.set_scenery_capture_actions(prompt, actions)
elif _scenery_controller.get_active_tool() in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]:
@@ -5672,8 +5683,7 @@ func _refresh_scenery_capture_bar() -> void:
actions.append({"id": "cancel_tool", "label": "Cancel"})
_use_target_capture_bar.set_scenery_capture_actions(erase_prompt, actions)
else:
actions.append({"id": "choose_scenery", "label": "Choose scenery"})
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "done_tool", "label": "Back", "primary": true})
actions.append({"id": "cancel_tool", "label": "Cancel"})
if _scenery_controller.get_active_tool() == &"background_ground":
var ground_prompt: String = "Draw background ground · No walking"
@@ -5737,8 +5747,6 @@ func _hide_scenery_capture_bar() -> void:
func _on_capture_action_requested(action_id: StringName) -> void:
match action_id:
&"choose_scenery":
_open_selector("scenery")
&"depth_far":
_scenery_controller.set_depth(&"far")
_refresh_scenery_capture_bar()
@@ -5749,8 +5757,12 @@ func _on_capture_action_requested(action_id: StringName) -> void:
_scenery_controller.set_depth(&"floor")
_refresh_scenery_capture_bar()
&"done_tool":
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
if _scenery_controller != null and _scenery_controller.is_placement_tool():
_return_to_scenery_selector()
else:
if _scenery_controller != null:
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
&"cancel_tool":
_scenery_controller.cancel_active_tool()
_hide_scenery_capture_bar()
+13 -4
View File
@@ -160,8 +160,12 @@ func open_selector(
current_stickman_path: String,
current_sky_id: StringName = &"",
background_kind: StringName = &"sky",
plain_color: Color = Color("#F5EFE0")
plain_color: Color = Color("#F5EFE0"),
preserve_scenery_browse_state: bool = false
) -> Dictionary:
var scenery_browse_state: Dictionary = {}
if preserve_scenery_browse_state and kind == "scenery" and selector != null:
scenery_browse_state = selector.capture_browse_state()
_current_background_kind = &"plain" if background_kind == &"plain" else &"sky"
_current_sky_id = current_sky_id if not current_sky_id.is_empty() else &"sky_neutral"
_current_plain_color = Color(plain_color.r, plain_color.g, plain_color.b, 1.0)
@@ -177,7 +181,7 @@ func open_selector(
selected_stickman_path = String(entries[0].get("path", ""))
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
if auto_selection.is_empty():
open(kind, entries, _current_sky_id)
open(kind, entries, _current_sky_id, scenery_browse_state)
return {
"entries": entries,
"selected_stickman_path": selected_stickman_path,
@@ -201,14 +205,19 @@ func set_selector_kind(kind: String) -> void:
selector_kind = kind
func open(kind: String, entries: Array[Dictionary], current_sky_id: StringName = &"") -> void:
func open(
kind: String,
entries: Array[Dictionary],
current_sky_id: StringName = &"",
scenery_browse_state: Dictionary = {}
) -> void:
selector_kind = kind
selector_open = true
if selector_dim != null:
selector_dim.visible = true
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
if selector != null:
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color)
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color, scenery_browse_state)
load_or_enqueue_thumbnails(kind, entries)
+8 -6
View File
@@ -426,17 +426,19 @@ func _build_file_menu(parent: HBoxContainer) -> void:
var file_popup: PopupMenu = _file_menu.get_popup()
var popup_font_size: int = _theme_integer("fonts.sizes.file_menu_popup", 26, 12, 64)
file_popup.add_theme_font_size_override("font_size", popup_font_size)
file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1)
file_popup.add_item("🆕 New", 0)
file_popup.add_item("📂 Open…", 1)
var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_requested)
file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent")
file_popup.add_item("Save", 2)
file_popup.add_item("Save As…", 3)
file_popup.add_item("Rename Scene…", 4)
file_popup.add_submenu_item("🕑 Open Recent", "OpenRecent")
file_popup.add_separator()
file_popup.add_item("💾 Save", 2)
file_popup.add_item("📄 Save As…", 3)
file_popup.add_separator()
file_popup.add_item("✏️ Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_requested)
file_popup.about_to_popup.connect(_on_recent_menu_requested)
parent.add_child(_file_menu)
@@ -78,6 +78,10 @@ func is_active() -> bool:
return not _active_tool.is_empty()
func is_placement_tool() -> bool:
return _is_placement_tool()
func is_group_preview_pending() -> bool:
return _group_pending
@@ -893,7 +893,7 @@ func _apply_compact_action_labels(compact: bool) -> void:
"resize_group": "Size",
"keep_group": "Keep",
"cancel_group": "Cancel",
"done_tool": "Done",
"done_tool": "Back",
"cancel_tool": "Cancel",
"depth_far": "Back",
"depth_near": "Middle",
+153 -22
View File
@@ -20,6 +20,7 @@ signal shape_selected()
signal shape_copy_requested(shape_dict: Dictionary)
signal shape_paste_requested(world_pos: Vector2)
signal color_selected(color: Color)
signal presentation_safe
# ---------------------------------------------------------------------------
# Exported / public properties
@@ -63,9 +64,10 @@ const ZOOM_STEP: float = 1.10
@onready var title_label: Label = %TitleLabel
@onready var drawing_area: Control = %DrawingArea
@onready var context_menu: PopupMenu = %ContextMenu
@onready var context_menu: EditorContextMenu = %ContextMenu
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
@onready var color_picker: ColorPicker = %ColorPicker
@onready var color_picker_button_row: HBoxContainer = %ButtonRow
@onready var cancel_button: Button = %CancelButton
@onready var ok_button: Button = %OKButton
@@ -89,6 +91,12 @@ var _snap_enabled: bool = false
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
var _restore_color: Color = Color.BLACK
var _grid_color: Color = GRID_COLOR
var _selection_core_color: Color = Color.WHITE
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
var _hint_color: Color = Color(0.67, 0.70, 0.75, 0.8)
var _button_target_height: float = 44.0
var _editor_visual_theme: EditorVisualTheme
# Phase 4: tracks which shape was right-clicked for context menu ops.
var _context_shape_idx: int = -1
@@ -117,6 +125,20 @@ func _ready() -> void:
ok_button.pressed.connect(_on_color_picker_ok)
if not cancel_button.pressed.is_connected(_on_color_picker_cancel):
cancel_button.pressed.connect(_on_color_picker_cancel)
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
context_menu.popup_hide.connect(_on_presentation_popup_hidden)
if _editor_visual_theme != null:
context_menu.configure(_editor_visual_theme, &"stickman")
if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
cancel_button.text = "Cancel"
cancel_button.custom_minimum_size.y = _button_target_height
ok_button.text = "Use Color"
ok_button.custom_minimum_size.y = _button_target_height
color_picker_button_row.add_theme_constant_override("separation", 12)
if _editor_visual_theme != null:
_editor_visual_theme.style_button_role(ok_button, &"primary", &"stickman")
_editor_visual_theme.style_button_role(cancel_button, &"secondary", &"stickman")
_drawing_area_initialized = true
@@ -262,6 +284,77 @@ func reset_view() -> void:
_pan_offset = Vector2.ZERO
drawing_area.queue_redraw()
func can_switch_presentation() -> bool:
return not _is_panning \
and _dragging_vertex < 0 \
and not _is_dragging_shape \
and not context_menu.visible \
and not color_picker_popup.visible
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, hint_color: Color = Color(0.67, 0.70, 0.75, 0.8), button_target_height: float = 44.0, editor_visual_theme: EditorVisualTheme = null) -> void:
_grid_color = grid_color
_selection_core_color = selection_core
_selection_halo_color = selection_halo
_hint_color = hint_color
_button_target_height = button_target_height
_editor_visual_theme = editor_visual_theme
if context_menu != null and _editor_visual_theme != null:
context_menu.configure(_editor_visual_theme, &"stickman")
if drawing_area != null:
drawing_area.queue_redraw()
func set_active_presentation(active: bool, surface: Color, border: Color, accent: Color, section_font_size: int = 22) -> void:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = surface
style.border_color = accent if active else border
style.set_border_width_all(2 if active else 1)
style.set_corner_radius_all(8)
style.content_margin_left = 6
style.content_margin_right = 6
style.content_margin_top = 4
style.content_margin_bottom = 4
add_theme_stylebox_override("panel", style)
title_label.add_theme_color_override("font_color", accent if active else _selection_core_color)
title_label.add_theme_font_size_override("font_size", section_font_size)
func add_shape(kind: StringName, at_world: Vector2) -> bool:
match kind:
&"line": _create_line(at_world)
&"rectangle": _create_rectangle(at_world)
&"circle": _create_circle(at_world)
_:
return false
drawing_area.queue_redraw()
_emit_changed()
return true
func selected_shape_index() -> int:
return _selected_shape_idx
func has_selected_shape() -> bool:
return _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size()
func open_selected_color_picker() -> void:
if not has_selected_shape():
return
_context_shape_idx = _selected_shape_idx
_show_color_picker()
func get_view_context() -> Dictionary:
return {"zoom": _zoom, "pan_offset": _pan_offset}
func drawing_center_world() -> Vector2:
return _screen_to_world(drawing_area.size * 0.5)
# ---------------------------------------------------------------------------
# Phase 5: Clipboard
# ---------------------------------------------------------------------------
@@ -332,6 +425,24 @@ func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
var screen_pos := drawing_area.global_position + _pan_offset + world_pos * _zoom
return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0))
func _global_shape_target_rect(sd: Dictionary) -> Rect2:
var points: PackedVector2Array = sd.get("points", PackedVector2Array())
if points.is_empty():
return Rect2()
var min_point := points[0]
var max_point := points[0]
for point: Vector2 in points:
min_point = min_point.min(point)
max_point = max_point.max(point)
var screen_position := drawing_area.global_position + _pan_offset + min_point * _zoom
return Rect2(screen_position, (max_point - min_point) * _zoom).grow(4.0)
func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size)
if gs <= 0.0:
@@ -358,11 +469,11 @@ func _on_drawing_area_draw() -> void:
drawing_area.draw_string(
font,
hint_pos,
"Right-click to add shape",
"Use Add Shape above",
HORIZONTAL_ALIGNMENT_CENTER,
-1,
14,
Color(0.5, 0.5, 0.5, 0.6)
_hint_color
)
return
@@ -386,7 +497,8 @@ func _on_drawing_area_draw() -> void:
# 3. Selection highlight (only on selected shape)
if i == _selected_shape_idx:
_draw_polyline(pts, Color.WHITE, closed, 2.5)
_draw_polyline(pts, _selection_halo_color, closed, 5.0)
_draw_polyline(pts, _selection_core_color, closed, 2.5)
# 4. Vertex handles (only on selected shape)
for j: int in range(count):
@@ -396,6 +508,7 @@ func _on_drawing_area_draw() -> void:
if sd_vf.size() > j:
flag = sd_vf[j]
drawing_area.draw_circle(pt, (HANDLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
var handle_color := color
if j == _dragging_vertex:
handle_color = Color.YELLOW
@@ -423,12 +536,12 @@ func _draw_grid() -> void:
var x: float = start_x
while x <= world_end.x:
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), _grid_color, line_width)
x += gs
var y: float = start_y
while y <= world_end.y:
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
y += gs
@@ -470,6 +583,7 @@ func _on_drawing_area_gui_input(event: InputEvent) -> void:
_dragging_vertex = -1
else:
_is_panning = false
_emit_presentation_safe()
return
# 2. Mouse wheel -> zoom
@@ -546,9 +660,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
if hit_idx >= 0:
_context_shape_idx = _selected_shape_idx
context_menu.clear()
context_menu.add_item("Remove Point", 6)
context_menu.add_destructive_item("Remove Point", 6)
context_menu.set_meta("vertex_index", hit_idx)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
return
for i: int in range(shapes.size() - 1, -1, -1):
@@ -559,9 +673,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
if hit_idx >= 0:
_context_shape_idx = i
context_menu.clear()
context_menu.add_item("Remove Point", 6)
context_menu.add_destructive_item("Remove Point", 6)
context_menu.set_meta("vertex_index", hit_idx)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
return
# Step 2: Check if right-click is over any shape (reverse order = front first).
@@ -585,22 +699,22 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
context_menu.add_item("Create Point", 3)
context_menu.add_separator()
context_menu.add_item("Color...", 4)
context_menu.add_item("Change Color…", 4)
context_menu.add_separator()
context_menu.add_item("Send Back", 7)
context_menu.add_item("Bring Forward", 8)
context_menu.add_separator()
context_menu.add_item("Copy", 9)
context_menu.add_item("Copy Shape", 9)
if _has_clipboard:
context_menu.add_item("Paste", 10)
context_menu.add_item("Paste Shape", 10)
context_menu.add_separator()
context_menu.add_item("Mirror X", 11)
context_menu.add_item("Mirror Y", 12)
context_menu.add_item("Flip Left-Right", 11)
context_menu.add_item("Flip Up-Down", 12)
context_menu.add_separator()
context_menu.add_item("Delete", 5)
context_menu.add_item("Bring Forward", 8)
context_menu.add_item("Send Back", 7)
context_menu.add_separator()
context_menu.add_destructive_item("Erase Shape", 5)
context_menu.set_meta("click_position", world_pos)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position, _global_shape_target_rect(found_sd))
return
# Step 3: No shape under mouse -> shape creation menu.
@@ -610,9 +724,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
context_menu.add_item("Circle", 2)
if _has_clipboard:
context_menu.add_separator()
context_menu.add_item("Paste", 10)
context_menu.add_item("Paste Shape", 10)
context_menu.set_meta("click_position", world_pos)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position)
func _handle_left_press(world_pos: Vector2) -> void:
@@ -660,6 +774,7 @@ func _handle_left_release() -> void:
_shape_drag_original_points = PackedVector2Array()
drawing_area.queue_redraw()
_emit_changed()
_emit_presentation_safe()
return
if _dragging_vertex >= 0:
@@ -667,6 +782,22 @@ func _handle_left_release() -> void:
_drag_offset = Vector2.ZERO
drawing_area.queue_redraw()
_emit_changed()
_emit_presentation_safe()
return
_emit_presentation_safe()
func _on_presentation_popup_hidden() -> void:
_emit_presentation_safe()
func _emit_presentation_safe() -> void:
call_deferred("_emit_presentation_safe_if_idle")
func _emit_presentation_safe_if_idle() -> void:
if can_switch_presentation():
presentation_safe.emit()
func _on_context_menu_id_pressed(id: int) -> void:
+572 -27
View File
@@ -1,3 +1,4 @@
class_name StickmanEditor
extends Control
## StickmanEditor - Main controller for the Stickman Studio editor.
##
@@ -15,6 +16,12 @@ extends Control
# ---------------------------------------------------------------------------
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const SandboxThemeScript := preload("res://scripts/stage/sandbox_theme.gd")
const StageDialogStylerScript := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
enum WorkspaceView { OVERVIEW, FOCUS }
enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT }
const BODY_PART_NAMES: PackedStringArray = [
"head", "torso",
@@ -89,6 +96,10 @@ const SETTINGS_VERSION := "1.0"
const DEFAULT_GRID_SIZE := 15
const MIN_GRID_SIZE := 1
const MAX_GRID_SIZE := 100
const OVERVIEW_PANEL_MIN_WIDTH: float = 230.0
const OVERVIEW_PREVIEW_MIN_WIDTH: float = 320.0
const FOCUS_PANEL_MIN_WIDTH: float = 350.0
const WORKSPACE_GAP: float = 8.0
# ---------------------------------------------------------------------------
# On-ready node references
@@ -97,6 +108,18 @@ const MAX_GRID_SIZE := 100
@onready var _stickman_name_edit: LineEdit = %StickmanNameEdit
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _body_part_panels: Dictionary = {} ## { String : BodyPartPanel }
@onready var _body_part_workspace: Control = %BodyPartWorkspace
@onready var _overview_scroll: ScrollContainer = %OverviewScroll
@onready var _focus_host: VBoxContainer = %FocusHost
@onready var _focus_panel_host: Control = %FocusPanelHost
@onready var _workspace_row: HSplitContainer = %WorkspaceRow
@onready var _overview_button: Button = %OverviewButton
@onready var _focus_button: Button = %FocusButton
@onready var _part_picker: EditorSelectionDropdown = %PartPicker
@onready var _add_shape_button: Button = %AddShapeButton
@onready var _color_button: Button = %ColorButton
@onready var _whole_stickman_button: Button = %WholeStickmanButton
@onready var _back_to_part_button: Button = %BackToPartButton
@onready var _whole_preview: WholeStickmanPreview = %WholeStickmanPreview
@onready var _save_dialog: FileDialog = %SaveDialog
@onready var _load_dialog: FileDialog = %LoadDialog
@@ -106,6 +129,25 @@ const MAX_GRID_SIZE := 100
@onready var _grid_size_spin_box: SpinBox = %GridSizeSpinBox
@onready var _status_cursor_coords: Label = %CursorCoords
@onready var _status_snap_status: Label = %SnapStatus
@onready var _status_save_status: Label = %SaveStatus
@onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog
var _editor_visual_theme: EditorVisualTheme
var _dialog_theme: SandboxTheme
var _body_part_origins: Dictionary = {}
var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW
var _view_choice_was_manual: bool = false
var _active_part_name: String = "head"
var _pending_view: int = -1
var _pending_part_name: String = ""
var _workspace_controls_initialized: bool = false
var _view_button_group: ButtonGroup
var _add_shape_menu: PopupMenu
var _preview_takes_workspace: bool = false
var _current_path: String = ""
var _dirty: bool = false
var _applying_document: bool = false
var _pending_document_action: PendingDocumentAction = PendingDocumentAction.NONE
# ---------------------------------------------------------------------------
# Grid / snap state
@@ -131,19 +173,45 @@ var _view_menu: PopupMenu
# ---------------------------------------------------------------------------
func _ready() -> void:
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_dialog_theme = SandboxThemeScript.new()
_dialog_theme.load_from(SandboxThemeScript.DEFAULT_PATH)
theme = _editor_visual_theme.control_theme(&"stickman")
_setup_menu_bar()
_setup_dialogs()
_populate_body_part_panels()
_setup_workspace_controls()
_whole_preview.part_moved.connect(_on_preview_part_moved)
_whole_preview.part_transform_committed.connect(_on_preview_part_transform_committed)
_whole_preview.part_order_changed.connect(_on_preview_part_order_changed)
_whole_preview.set_body_parts(_collect_all_shape_data())
_whole_preview.set_canvas_tokens(
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"preview", &"stickman")
)
_whole_preview.set_context_menu_visual_theme(_editor_visual_theme, &"stickman")
_stickman_name_edit.text_changed.connect(_on_stickman_name_changed)
_load_settings()
_update_snap_status_label()
_broadcast_settings()
_broadcast_recent_colors()
_update_active_part_presentation()
_update_document_status()
if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed):
_grid_config_dialog.confirmed.connect(_on_grid_config_confirmed)
call_deferred("_initialize_workspace_presentation")
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_document_action(PendingDocumentAction.QUIT)
elif what == NOTIFICATION_RESIZED:
call_deferred("_refresh_responsive_workspace")
func _process(_delta: float) -> void:
@@ -151,7 +219,7 @@ func _process(_delta: float) -> void:
for part_name: String in BODY_PART_NAMES:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel and panel.is_cursor_over_drawing(mouse_pos):
if panel and panel.is_visible_in_tree() and panel.is_cursor_over_drawing(mouse_pos):
var world_pos := panel.global_to_world(mouse_pos)
_status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
return
@@ -170,17 +238,18 @@ func _process(_delta: float) -> void:
func _setup_menu_bar() -> void:
var file_menu: PopupMenu = PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("Save", 0)
file_menu.add_item("Load", 1)
file_menu.add_item("📂 Load…", 1)
file_menu.add_separator()
file_menu.add_item("Clear", 2)
file_menu.add_item("💾 Save", 0)
file_menu.add_separator()
file_menu.add_item("🆕 Start Over", 2)
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
_edit_menu = PopupMenu.new()
_edit_menu.name = "EditMenu"
_edit_menu.add_item("Configure Grid...", 0)
_edit_menu.add_item("⚙️ Configure Grid…", 0)
_edit_menu.add_separator()
_edit_menu.add_item(_snap_menu_label(), 1)
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
@@ -190,7 +259,7 @@ func _setup_menu_bar() -> void:
var view_menu: PopupMenu = PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("Reset Views", 0)
view_menu.add_item("🔍 Reset Views", 0)
view_menu.add_separator()
view_menu.add_item(_guide_menu_label(), 1)
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
@@ -201,12 +270,340 @@ func _setup_menu_bar() -> void:
func _setup_dialogs() -> void:
_save_dialog.theme = theme
_load_dialog.theme = theme
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
_save_dialog.current_dir = content_dir
_load_dialog.current_dir = content_dir
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"stickman")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"stickman")
_clear_confirm.confirmed.connect(_on_clear_confirmed)
_clear_confirm.title = "Start Over?"
_clear_confirm.dialog_text = "This will clear your current stickman. Start over?"
_clear_confirm.ok_button_text = "Start Over"
_clear_confirm.set_meta(StageDialogStylerScript.ICON_META, "🧹")
_clear_confirm.get_cancel_button().text = "Cancel"
StageDialogStylerScript.apply(
_clear_confirm, _dialog_theme, _dialog_theme.font(),
null, [_clear_confirm.get_ok_button()], [], false
)
_unsaved_dialog.title = "Unsaved Changes"
_unsaved_dialog.set_meta(StageDialogStylerScript.ICON_META, "💾")
var discard_button: Button = _unsaved_dialog.add_button("Discard", false, "discard")
_unsaved_dialog.get_cancel_button().text = "Cancel"
_unsaved_dialog.ok_button_text = "Save"
StageDialogStylerScript.apply(
_unsaved_dialog, _dialog_theme, _dialog_theme.font(),
_unsaved_dialog.get_ok_button(), [discard_button]
)
_error_dialog.get_ok_button().text = "Got it"
StageDialogStylerScript.apply(
_error_dialog, _dialog_theme, _dialog_theme.font(),
_error_dialog.get_ok_button()
)
_grid_config_dialog.set_meta(StageDialogStylerScript.ICON_META, "📏")
StageDialogStylerScript.apply(
_grid_config_dialog, _dialog_theme, _dialog_theme.font(),
_grid_config_dialog.get_ok_button()
)
_unsaved_dialog.confirmed.connect(_on_unsaved_save_pressed)
_unsaved_dialog.custom_action.connect(_on_unsaved_custom_action)
_unsaved_dialog.canceled.connect(_cancel_pending_document_action)
get_tree().auto_accept_quit = false
func _setup_workspace_controls() -> void:
_view_button_group = ButtonGroup.new()
_overview_button.button_group = _view_button_group
_focus_button.button_group = _view_button_group
_overview_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_focus_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"mode_button_min_height", &"stickman"))
_part_picker.custom_minimum_size = Vector2(220.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
_part_picker.configure(
theme,
_editor_visual_theme.chrome_color(&"surface", &"stickman"),
_editor_visual_theme.chrome_color(&"selected_surface", &"stickman"),
_editor_visual_theme.chrome_color(&"border", &"stickman"),
_editor_visual_theme.chrome_color(&"edit", &"stickman"),
_editor_visual_theme.chrome_color(&"focus", &"stickman"),
_editor_visual_theme.chrome_color(&"text", &"stickman"),
_editor_visual_theme.chrome_px(&"menu_popup", &"stickman"),
12
)
_add_shape_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_editor_visual_theme.style_button_role(_add_shape_button, &"primary", &"stickman")
_color_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_editor_visual_theme.style_button_role(_color_button, &"secondary", &"stickman")
_whole_stickman_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_back_to_part_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_status_cursor_coords.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
_status_save_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
_status_snap_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
for part_name: String in BODY_PART_NAMES:
_part_picker.add_item(BODY_PART_DISPLAY[part_name])
_overview_button.pressed.connect(func() -> void: request_workspace_view(WorkspaceView.OVERVIEW))
_focus_button.pressed.connect(func() -> void: request_workspace_view(WorkspaceView.FOCUS))
_part_picker.item_selected.connect(_on_part_picker_selected)
_add_shape_button.pressed.connect(_on_add_shape_button_pressed)
_color_button.pressed.connect(_on_color_button_pressed)
_whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed)
_back_to_part_button.pressed.connect(_on_back_to_part_button_pressed)
_add_shape_menu = PopupMenu.new()
_add_shape_menu.add_item("〰️ Line", 0)
_add_shape_menu.add_item("▭ Rectangle", 1)
_add_shape_menu.add_item("⚪ Circle", 2)
_add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed)
_add_shape_menu.theme = theme
add_child(_add_shape_menu)
_workspace_controls_initialized = true
_refresh_workspace_controls()
func _initialize_workspace_presentation() -> void:
if _workspace_row.size.x <= 0.0:
await get_tree().process_frame
if not _view_choice_was_manual:
var initial_view: WorkspaceView = WorkspaceView.OVERVIEW if _overview_width_fits() else WorkspaceView.FOCUS
request_workspace_view(initial_view, false)
_refresh_responsive_workspace()
func request_workspace_view(view: WorkspaceView, user_chosen: bool = true) -> void:
if user_chosen:
_view_choice_was_manual = true
var panel: BodyPartPanel = _active_panel()
if panel != null and not panel.can_switch_presentation():
_pending_view = int(view)
return
_pending_view = -1
_apply_workspace_view(view)
func request_active_part(part_name: String) -> void:
if not BODY_PART_NAMES.has(part_name):
return
var panel: BodyPartPanel = _active_panel()
if panel != null and not panel.can_switch_presentation():
_pending_part_name = part_name
return
_pending_part_name = ""
_set_active_part(part_name)
func get_workspace_view() -> WorkspaceView:
return _workspace_view
func get_active_part_name() -> String:
return _active_part_name
func has_unsaved_changes() -> bool:
return _dirty
func current_path() -> String:
return _current_path
func _apply_pending_presentation() -> void:
var panel: BodyPartPanel = _active_panel()
if panel != null and not panel.can_switch_presentation():
return
if not _pending_part_name.is_empty():
var requested_part: String = _pending_part_name
_pending_part_name = ""
_set_active_part(requested_part)
if _pending_view >= 0:
var requested_view: WorkspaceView = _pending_view
_pending_view = -1
_apply_workspace_view(requested_view)
func _apply_workspace_view(view: WorkspaceView) -> void:
if view == _workspace_view:
_refresh_workspace_controls()
_refresh_responsive_workspace()
return
if _workspace_view == WorkspaceView.FOCUS:
_restore_panel_to_overview(_active_part_name)
_workspace_view = view
_preview_takes_workspace = false
if _workspace_view == WorkspaceView.FOCUS:
_move_panel_to_focus(_active_part_name)
_overview_scroll.visible = _workspace_view == WorkspaceView.OVERVIEW
_focus_host.visible = _workspace_view == WorkspaceView.FOCUS
_refresh_workspace_controls()
_update_active_part_presentation()
_refresh_responsive_workspace()
func _set_active_part(part_name: String) -> void:
if not BODY_PART_NAMES.has(part_name) or part_name == _active_part_name:
_refresh_workspace_controls()
return
if _workspace_view == WorkspaceView.FOCUS:
_restore_panel_to_overview(_active_part_name)
_active_part_name = part_name
if _workspace_view == WorkspaceView.FOCUS:
_move_panel_to_focus(_active_part_name)
_refresh_workspace_controls()
_update_active_part_presentation()
func _active_panel() -> BodyPartPanel:
return _body_part_panels.get(_active_part_name) as BodyPartPanel
func _move_panel_to_focus(part_name: String) -> void:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel == null or panel.get_parent() == _focus_panel_host:
return
var current_focus_panel: Control = _focus_panel_host.get_child(0) as Control if _focus_panel_host.get_child_count() > 0 else null
if current_focus_panel != null:
var current_part: String = String(current_focus_panel.get_meta("body_part_name", ""))
if not current_part.is_empty():
_restore_panel_to_overview(current_part)
var old_parent: Node = panel.get_parent()
if old_parent == null:
return
old_parent.remove_child(panel)
_focus_panel_host.add_child(panel)
panel.set_meta("body_part_name", part_name)
panel.layout_mode = 1
panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
panel.custom_minimum_size = Vector2(FOCUS_PANEL_MIN_WIDTH, 300.0)
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
func _restore_panel_to_overview(part_name: String) -> void:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
var origin: Dictionary = _body_part_origins.get(part_name, {})
if panel == null or origin.is_empty():
return
var origin_parent: Node = origin.get("parent") as Node
if origin_parent == null or panel.get_parent() == origin_parent:
return
var old_parent: Node = panel.get_parent()
if old_parent != null:
old_parent.remove_child(panel)
panel.layout_mode = 2
panel.custom_minimum_size = origin.get("minimum_size", Vector2(230.0, 160.0))
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
origin_parent.add_child(panel)
origin_parent.move_child(panel, int(origin.get("index", origin_parent.get_child_count() - 1)))
panel.remove_meta("body_part_name")
func _overview_width_fits() -> bool:
var required_width: float = OVERVIEW_PANEL_MIN_WIDTH * 2.0 + OVERVIEW_PREVIEW_MIN_WIDTH + WORKSPACE_GAP * 2.0
return _available_workspace_width() >= required_width
func _available_workspace_width() -> float:
var viewport_width: float = get_viewport_rect().size.x
return minf(size.x, viewport_width) if viewport_width > 0.0 else _workspace_row.size.x
func _refresh_responsive_workspace() -> void:
if not is_node_ready():
return
if not _view_choice_was_manual and _workspace_controls_initialized:
var responsive_view: WorkspaceView = WorkspaceView.OVERVIEW if _overview_width_fits() else WorkspaceView.FOCUS
if responsive_view != _workspace_view:
request_workspace_view(responsive_view, false)
var narrow_preview: bool = _available_workspace_width() < FOCUS_PANEL_MIN_WIDTH + OVERVIEW_PREVIEW_MIN_WIDTH + WORKSPACE_GAP
if _workspace_view == WorkspaceView.OVERVIEW:
_preview_takes_workspace = false
_body_part_workspace.visible = true
_whole_preview.visible = not narrow_preview
_whole_stickman_button.visible = false
_back_to_part_button.visible = false
return
if narrow_preview and _preview_takes_workspace:
_body_part_workspace.visible = false
_whole_preview.visible = true
_whole_stickman_button.visible = false
_back_to_part_button.visible = true
elif narrow_preview:
_body_part_workspace.visible = true
_whole_preview.visible = false
_whole_stickman_button.visible = true
_back_to_part_button.visible = false
else:
_preview_takes_workspace = false
_body_part_workspace.visible = true
_whole_preview.visible = true
_whole_stickman_button.visible = false
_back_to_part_button.visible = false
func _update_active_part_presentation() -> void:
if _editor_visual_theme == null:
return
var surface: Color = _editor_visual_theme.chrome_color(&"surface", &"stickman")
var border: Color = _editor_visual_theme.chrome_color(&"border", &"stickman")
var accent: Color = _editor_visual_theme.chrome_color(&"edit", &"stickman")
var section_size: int = _editor_visual_theme.chrome_px(&"section", &"stickman")
for part_name: String in BODY_PART_NAMES:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel != null:
panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size)
func _refresh_workspace_controls() -> void:
if not _workspace_controls_initialized:
return
_overview_button.set_pressed_no_signal(_workspace_view == WorkspaceView.OVERVIEW)
_focus_button.set_pressed_no_signal(_workspace_view == WorkspaceView.FOCUS)
var part_index: int = BODY_PART_NAMES.find(_active_part_name)
if part_index >= 0:
_part_picker.select(part_index)
var active: BodyPartPanel = _active_panel()
var selected: bool = active != null and active.has_selected_shape()
_color_button.disabled = not selected
_color_button.tooltip_text = "Select a shape to choose its color." if not selected else "Choose this shape's color."
_update_active_part_presentation()
func _on_part_picker_selected(index: int) -> void:
if index >= 0 and index < BODY_PART_NAMES.size():
request_active_part(BODY_PART_NAMES[index])
func _on_add_shape_button_pressed() -> void:
var rect: Rect2 = Rect2(_add_shape_button.global_position + Vector2(0, _add_shape_button.size.y), Vector2(_add_shape_button.size.x, 1.0))
_add_shape_menu.popup(rect)
func _on_add_shape_menu_id_pressed(id: int) -> void:
var kind: StringName = [&"line", &"rectangle", &"circle"][clampi(id, 0, 2)]
var panel: BodyPartPanel = _active_panel()
if panel != null:
panel.add_shape(kind, panel.drawing_center_world())
_refresh_workspace_controls()
func _on_color_button_pressed() -> void:
var panel: BodyPartPanel = _active_panel()
if panel != null:
panel.open_selected_color_picker()
func _on_whole_stickman_button_pressed() -> void:
_preview_takes_workspace = true
_refresh_responsive_workspace()
func _on_back_to_part_button_pressed() -> void:
_preview_takes_workspace = false
_refresh_responsive_workspace()
# ---------------------------------------------------------------------------
# Body-part panel population
@@ -230,7 +627,17 @@ func _populate_body_part_panels() -> void:
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
panel.color_selected.connect(_on_color_selected.bind(part_name))
panel.presentation_safe.connect(_apply_pending_presentation)
panel.set_canvas_tokens(
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"text_muted", &"stickman"),
float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")),
_editor_visual_theme
)
_body_part_panels[part_name] = panel
_body_part_origins[part_name] = {"parent": left_col, "index": left_col.get_child_count(), "minimum_size": panel.custom_minimum_size}
left_col.add_child(panel)
for part_name: String in center_names:
@@ -242,7 +649,17 @@ func _populate_body_part_panels() -> void:
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
panel.color_selected.connect(_on_color_selected.bind(part_name))
panel.presentation_safe.connect(_apply_pending_presentation)
panel.set_canvas_tokens(
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"text_muted", &"stickman"),
float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")),
_editor_visual_theme
)
_body_part_panels[part_name] = panel
_body_part_origins[part_name] = {"parent": center_col, "index": center_col.get_child_count(), "minimum_size": panel.custom_minimum_size}
center_col.add_child(panel)
# ---------------------------------------------------------------------------
@@ -252,18 +669,128 @@ func _populate_body_part_panels() -> void:
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0: # Save
_save_dialog.popup_centered_ratio(0.6)
_save_current_document()
1: # Load
_request_document_action(PendingDocumentAction.LOAD)
2: # Start Over
_request_document_action(PendingDocumentAction.CLEAR)
func _save_current_document() -> void:
if _current_path.is_empty():
_save_dialog.popup_centered_ratio(0.6)
return
save_to_path(_current_path)
func save_to_path(path: String) -> bool:
var save_path: String = ContentPathsScript.canonicalize(path)
if save_path.get_extension().to_lower() != "stk":
save_path = save_path.get_basename() + ".stk"
var file: FileAccess = FileAccess.open(save_path, FileAccess.WRITE)
if file == null:
_show_error("Could not save the stickman. Check that the file location is available.")
return false
file.store_string(JSON.stringify(_build_json_data(), "\t", false))
var write_error: Error = file.get_error()
file.close()
if write_error != OK:
_show_error("Could not finish saving the stickman. Check the file location and try again.")
return false
_current_path = save_path
_dirty = false
_update_document_status()
return true
func _request_document_action(action: PendingDocumentAction) -> void:
if _dirty:
_pending_document_action = action
match action:
PendingDocumentAction.LOAD:
_unsaved_dialog.dialog_text = "Save changes before loading another stickman?"
PendingDocumentAction.CLEAR:
_unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?"
PendingDocumentAction.QUIT:
_unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?"
StageDialogStylerScript.popup(_unsaved_dialog)
return
_continue_document_action(action)
func _continue_document_action(action: PendingDocumentAction) -> void:
match action:
PendingDocumentAction.LOAD:
_load_dialog.popup_centered_ratio(0.6)
2: # Clear
_clear_confirm.popup_centered()
PendingDocumentAction.CLEAR:
StageDialogStylerScript.popup(_clear_confirm)
PendingDocumentAction.QUIT:
get_tree().quit()
_pending_document_action = PendingDocumentAction.NONE
func _on_unsaved_save_pressed() -> void:
if _pending_document_action == PendingDocumentAction.NONE:
return
if _current_path.is_empty():
_save_dialog.popup_centered_ratio(0.6)
return
if save_to_path(_current_path):
_resume_pending_document_action()
else:
_cancel_pending_document_action()
func _on_unsaved_custom_action(action: StringName) -> void:
if action != &"discard":
return
_resume_pending_document_action()
func _resume_pending_document_action() -> void:
var action: PendingDocumentAction = _pending_document_action
_pending_document_action = PendingDocumentAction.NONE
if action == PendingDocumentAction.CLEAR:
_clear_all()
return
_continue_document_action(action)
func _cancel_pending_document_action() -> void:
_pending_document_action = PendingDocumentAction.NONE
func _on_stickman_name_changed(_value: String) -> void:
if not _applying_document:
_mark_dirty()
func _mark_dirty() -> void:
if _applying_document:
return
_dirty = true
_update_document_status()
func _update_document_status() -> void:
if _status_save_status == null:
return
if _dirty:
_status_save_status.text = "Unsaved changes"
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"dirty", &"stickman"))
elif _current_path.is_empty():
_status_save_status.text = "Not saved yet"
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"text_muted", &"stickman"))
else:
_status_save_status.text = "Saved"
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"saved", &"stickman"))
func _on_edit_menu_id_pressed(id: int) -> void:
match id:
0: # Configure Grid...
_grid_size_spin_box.value = float(_grid_size)
_grid_config_dialog.popup_centered()
StageDialogStylerScript.popup(_grid_config_dialog)
1: # Snap to Grid (checkable)
_snap_enabled = not _snap_enabled
if _edit_menu:
@@ -308,7 +835,7 @@ func _update_guide_menu_item() -> void:
func _guide_menu_label() -> String:
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
return "👁️ Hide Pose Guide" if _show_guide else "👁️ Show Pose Guide"
func _on_grid_config_confirmed() -> void:
@@ -320,19 +847,12 @@ func _on_grid_config_confirmed() -> void:
func _on_save_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if not path.ends_with(".stk"):
path += ".stk"
var data := _build_json_data()
var json_text := JSON.stringify(data, "\t", false)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
_show_error("Failed to save file: " + str(FileAccess.get_open_error()))
if path.get_extension().to_lower() != "stk":
path = path.get_basename() + ".stk"
if not save_to_path(path):
_cancel_pending_document_action()
return
file.store_string(json_text)
file.close()
_resume_pending_document_action()
func _on_load_file_selected(path: String) -> void:
@@ -358,6 +878,9 @@ func _on_load_file_selected(path: String) -> void:
if not err.is_empty():
_show_error(err)
return
_current_path = path
_dirty = false
_update_document_status()
func _on_clear_confirmed() -> void:
@@ -366,7 +889,7 @@ func _on_clear_confirmed() -> void:
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()
StageDialogStylerScript.popup(_error_dialog)
# ---------------------------------------------------------------------------
# Signal handlers - body-part panels
@@ -379,19 +902,31 @@ func _on_body_part_shape_changed(_new_data: Array, part_name: String) -> void:
_whole_preview.set_part_rotation(part_name, 0.0)
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
_broadcast_clipboard_state()
_mark_dirty()
_refresh_workspace_controls()
func _on_body_part_shape_selected(part_name: String) -> void:
request_active_part(part_name)
for other_name: String in BODY_PART_NAMES:
if other_name != part_name:
var panel: BodyPartPanel = _body_part_panels.get(other_name) as BodyPartPanel
if panel:
panel.deselect()
_refresh_workspace_controls()
func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
pass
func _on_preview_part_transform_committed(_part_name: String, _position: Vector2, _rotation_deg: float, _scale: Vector2) -> void:
_mark_dirty()
func _on_preview_part_order_changed(_part_name: String, _new_order: Array[String]) -> void:
_mark_dirty()
# ---------------------------------------------------------------------------
# Data collection / JSON helpers
# ---------------------------------------------------------------------------
@@ -478,13 +1013,13 @@ func _apply_json_data(json: Variant) -> String:
if version not in SUPPORTED_VERSIONS:
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', '1.4', or '1.5')" % version
_stickman_name_edit.text = dict.get("stickman_name", "")
var body_parts: Variant = dict.get("body_parts")
if not body_parts is Dictionary:
return "Invalid file format: 'body_parts' must be a dictionary."
var body_dict: Dictionary = body_parts as Dictionary
_applying_document = true
_stickman_name_edit.text = str(dict.get("stickman_name", ""))
_clear_all_panels()
@@ -546,6 +1081,9 @@ func _apply_json_data(json: Variant) -> String:
_whole_preview.reset_view()
_whole_preview.set_all_part_positions(positions)
_whole_preview.set_body_parts(_collect_all_shape_data())
_applying_document = false
_dirty = false
_update_document_status()
return ""
@@ -561,12 +1099,18 @@ func _clear_all_panels() -> void:
func _clear_all() -> void:
_applying_document = true
_clear_all_panels()
_whole_preview.clear_all()
_whole_preview.set_all_part_positions(DEFAULT_POSITIONS.duplicate())
_stickman_name_edit.text = ""
_shape_clipboard.clear()
_broadcast_clipboard_state()
_applying_document = false
_current_path = ""
_dirty = false
_update_document_status()
_refresh_workspace_controls()
# ---------------------------------------------------------------------------
# Phase 5: Shape clipboard
@@ -611,6 +1155,7 @@ func _on_color_selected(color: Color, _part_name: String) -> void:
_recent_colors.pop_back()
_save_settings()
_broadcast_recent_colors()
_mark_dirty()
# ---------------------------------------------------------------------------
# Phase 3: Settings persistence
@@ -680,7 +1225,7 @@ func _broadcast_settings() -> void:
func _snap_menu_label() -> String:
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"
return "🧲 [√] Snap to Grid" if _snap_enabled else "🧲 Snap to Grid"
func _update_snap_status_label() -> void:
@@ -18,12 +18,15 @@ class_name WholeStickmanPreview
# ---------------------------------------------------------------------------
signal part_moved(part_name: String, new_position: Vector2)
signal part_transform_committed(part_name: String, position: Vector2, rotation_deg: float, scale: Vector2)
signal part_order_changed(part_name: String, new_order: Array[String])
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
@@ -140,13 +143,21 @@ var _pan_start: Vector2 = Vector2.ZERO
# Phase 5: z-ordering
var _part_order: Array[String] = []
var _context_menu: PopupMenu
var _context_menu: EditorContextMenu
# Phase 6: selection gizmos rendered on top
var _selected_gizmo_bounds: Rect2 = Rect2()
# Phase 7: pose silhouette guide (default ON per user decision)
var _show_guide: bool = true
var _grid_color: Color = GRID_COLOR
var _selection_core_color: Color = Color.WHITE
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
var _preview_color: Color = Color(0.13, 0.78, 1.0, 0.4)
var _interaction_start_part: String = ""
var _interaction_start_position: Vector2 = Vector2.ZERO
var _interaction_start_rotation: float = 0.0
var _interaction_start_scale: Vector2 = Vector2.ONE
# ---------------------------------------------------------------------------
# Public API
@@ -271,6 +282,19 @@ func set_show_guide(enabled: bool) -> void:
preview_area.queue_redraw()
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, preview_color: Color = Color(0.13, 0.78, 1.0, 0.4)) -> void:
_grid_color = grid_color
_selection_core_color = selection_core
_selection_halo_color = selection_halo
_preview_color = preview_color
preview_area.queue_redraw()
func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void:
if _context_menu != null:
_context_menu.configure(visual_theme, editor)
## Returns the preview-space position of a guide joint (a master-space
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
## unknown joint names.
@@ -316,7 +340,7 @@ func _ready() -> void:
if not preview_area.gui_input.is_connected(_on_preview_gui_input):
preview_area.gui_input.connect(_on_preview_gui_input)
_context_menu = PopupMenu.new()
_context_menu = EditorContextMenuScript.new() as EditorContextMenu
_context_menu.name = "PreviewContextMenu"
add_child(_context_menu)
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
@@ -399,25 +423,35 @@ func _on_preview_draw() -> void:
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
var bounds: Variant = _part_bounds.get(_dragging_part)
if bounds is Rect2:
preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5)
preview_area.draw_rect(bounds as Rect2, _preview_color, false, 1.5 / _zoom)
# Draw selection gizmos on top of all parts
if _selected_gizmo_bounds.has_area():
var b: Rect2 = _selected_gizmo_bounds
preview_area.draw_rect(b, Color.WHITE, false, 1.5 / _zoom)
preview_area.draw_rect(b, _selection_halo_color, false, 4.0 / _zoom)
preview_area.draw_rect(b, _selection_core_color, false, 1.5 / _zoom)
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE)
preview_area.draw_circle(rot_center, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, _selection_core_color)
var cross_half := SCALE_CROSS_SIZE / _zoom
for i: int in range(4):
var corner := _get_corner_position(b, i)
preview_area.draw_line(
corner + Vector2(-cross_half, 0),
corner + Vector2(cross_half, 0),
Color.WHITE, 1.5 / _zoom)
_selection_halo_color, 4.0 / _zoom)
preview_area.draw_line(
corner + Vector2(0, -cross_half),
corner + Vector2(0, cross_half),
Color.WHITE, 1.5 / _zoom)
_selection_halo_color, 4.0 / _zoom)
preview_area.draw_line(
corner + Vector2(-cross_half, 0),
corner + Vector2(cross_half, 0),
_selection_core_color, 1.5 / _zoom)
preview_area.draw_line(
corner + Vector2(0, -cross_half),
corner + Vector2(0, cross_half),
_selection_core_color, 1.5 / _zoom)
func _draw_grid() -> void:
@@ -436,12 +470,12 @@ func _draw_grid() -> void:
var x: float = start_x
while x <= world_end.x:
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), _grid_color, line_width)
x += gs
var y: float = start_y
while y <= world_end.y:
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
y += gs
@@ -688,6 +722,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom:
_interaction = Interaction.ROTATE
_capture_transform_start(_selected_part)
_rotate_start_rotation = _part_rotations[_selected_part]
_rotate_total_delta = 0.0
var bounds_center := bounds.position + bounds.size * 0.5
@@ -700,6 +735,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
var corner := _get_corner_position(bounds, i)
if world_pos.distance_to(corner) <= SCALE_CROSS_HIT_RADIUS / _zoom:
_interaction = Interaction.SCALE
_capture_transform_start(_selected_part)
_scale_corner = i
_scale_anchor = _get_opposite_corner(bounds, i)
_scale_original_size = _compute_original_bounds_size(_selected_part)
@@ -717,6 +753,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
if rect.grow(8.0).has_point(world_pos):
_selected_part = part_name
_interaction = Interaction.TRANSLATE
_capture_transform_start(_selected_part)
_dragging_part = part_name
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
_drag_offset = world_pos - pos
@@ -731,13 +768,40 @@ func _try_start_interaction(world_pos: Vector2) -> void:
func _end_interaction() -> void:
if _interaction != Interaction.NONE:
var changed_part: String = _interaction_start_part
var changed: bool = changed_part != "" and (
_part_positions.get(changed_part, Vector2.ZERO) != _interaction_start_position
or not is_equal_approx(float(_part_rotations.get(changed_part, 0.0)), _interaction_start_rotation)
or Vector2(_part_scales.get(changed_part, Vector2.ONE)) != _interaction_start_scale
)
if _interaction == Interaction.TRANSLATE and not _dragging_part.is_empty():
_dragging_part = ""
_drag_offset = Vector2.ZERO
_interaction = Interaction.NONE
if changed:
_emit_transform_committed(changed_part)
_interaction_start_part = ""
preview_area.queue_redraw()
func _capture_transform_start(part_name: String) -> void:
_interaction_start_part = part_name
_interaction_start_position = _part_positions.get(part_name, Vector2.ZERO)
_interaction_start_rotation = float(_part_rotations.get(part_name, 0.0))
_interaction_start_scale = _part_scales.get(part_name, Vector2.ONE)
func _emit_transform_committed(part_name: String) -> void:
if part_name.is_empty():
return
part_transform_committed.emit(
part_name,
_part_positions.get(part_name, Vector2.ZERO),
float(_part_rotations.get(part_name, 0.0)),
_part_scales.get(part_name, Vector2.ONE)
)
func _handle_translate_drag(mm: InputEventMouseMotion) -> void:
if _dragging_part.is_empty():
return
@@ -966,12 +1030,14 @@ func _handle_right_click(mb: InputEventMouseButton) -> void:
if rect.has_point(world_pos):
_selected_part = part_name
_context_menu.clear()
_context_menu.add_item("Send Back", 0)
_context_menu.add_item("Bring Forward", 1)
_context_menu.add_item("Send Back", 0)
_context_menu.add_separator()
_context_menu.add_item("Mirror X", 2)
_context_menu.add_item("Mirror Y", 3)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_item("Flip Left-Right", 2)
_context_menu.add_item("Flip Up-Down", 3)
var screen_position := preview_area.global_position + _pan_offset + rect.position * _zoom
var target_rect := Rect2(screen_position, rect.size * _zoom).grow(4.0)
_context_menu.popup_at(mb.global_position, target_rect)
preview_area.queue_redraw()
return
@@ -996,6 +1062,7 @@ func _send_back_part() -> void:
_part_order.remove_at(idx)
_part_order.insert(idx - 1, _selected_part)
preview_area.queue_redraw()
part_order_changed.emit(_selected_part, _part_order.duplicate())
func _bring_forward_part() -> void:
@@ -1007,6 +1074,7 @@ func _bring_forward_part() -> void:
_part_order.remove_at(idx)
_part_order.insert(idx + 1, _selected_part)
preview_area.queue_redraw()
part_order_changed.emit(_selected_part, _part_order.duplicate())
func _mirror_part_x() -> void:
@@ -1016,6 +1084,7 @@ func _mirror_part_x() -> void:
_part_scales[_selected_part] = Vector2(-scl.x, scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
_emit_transform_committed(_selected_part)
func _mirror_part_y() -> void:
@@ -1025,3 +1094,4 @@ func _mirror_part_y() -> void:
_part_scales[_selected_part] = Vector2(scl.x, -scl.y)
_rebuild_bounds()
preview_area.queue_redraw()
_emit_transform_committed(_selected_part)
@@ -0,0 +1,385 @@
class_name EditorContextMenu
extends PopupPanel
## Shared Studio List popup used by right-click menus in both standalone editors.
signal id_pressed(id: int)
enum ItemRole { NORMAL, DESTRUCTIVE }
const DEFAULT_EDITOR := &"prop"
const DEFAULT_MIN_WIDTH := 208
const DEFAULT_ROW_HEIGHT := 48
const DEFAULT_GROUP_GAP := 8
const SHELL_INSET := 8
const ROW_RADIUS := 6
const TARGET_GAP := 12.0
var _visual_theme: EditorVisualTheme
var _editor: StringName = DEFAULT_EDITOR
var _font_size_override: int = -1
var _shell: MarginContainer
var _scroll: ScrollContainer
var _items: VBoxContainer
var _row_buttons: Array[Button] = []
var _row_ids: Dictionary = {}
var _keyboard_navigation_active: bool = false
var _focused_row_index: int = -1
func _ready() -> void:
_build_contents()
if not about_to_popup.is_connected(_reset_keyboard_navigation):
about_to_popup.connect(_reset_keyboard_navigation)
if not popup_hide.is_connected(_reset_keyboard_navigation):
popup_hide.connect(_reset_keyboard_navigation)
if _visual_theme != null:
_apply_visual_style()
func configure(visual_theme: EditorVisualTheme, editor: StringName) -> void:
_visual_theme = visual_theme
_editor = editor
if is_node_ready():
_apply_visual_style()
func set_font_size(size: int) -> void:
_font_size_override = maxi(1, size)
if is_node_ready():
_apply_visual_style()
func clear() -> void:
_reset_keyboard_navigation()
_row_buttons.clear()
_row_ids.clear()
if _items == null:
return
for child: Node in _items.get_children():
_items.remove_child(child)
child.free()
func add_item(label: String, id: int, role: ItemRole = ItemRole.NORMAL) -> void:
if _items == null:
_build_contents()
var button := Button.new()
button.text = label
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height())
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_font_size_override("font_size", _font_size())
button.add_theme_color_override("font_color", _text_color(role))
button.add_theme_color_override("font_hover_color", _text_color(role))
button.add_theme_color_override("font_pressed_color", _text_color(role))
button.add_theme_color_override("font_focus_color", _text_color(role))
button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
button.add_theme_stylebox_override("hover", _make_hover_box())
button.add_theme_stylebox_override("pressed", _make_hover_box(true))
button.add_theme_stylebox_override("focus", _make_focus_box())
button.add_theme_stylebox_override("disabled", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
button.set_meta("context_menu_id", id)
button.set_meta("context_menu_role", role)
button.pressed.connect(_on_row_pressed.bind(button))
button.mouse_entered.connect(_on_row_mouse_entered.bind(button))
_items.add_child(button)
_row_buttons.append(button)
_row_ids[id] = button
func add_destructive_item(label: String, id: int) -> void:
add_item(label, id, ItemRole.DESTRUCTIVE)
func add_separator() -> void:
if _items == null:
_build_contents()
var spacer := MarginContainer.new()
spacer.custom_minimum_size.y = float(DEFAULT_GROUP_GAP + 1)
spacer.add_theme_constant_override("margin_top", int(DEFAULT_GROUP_GAP / 2))
spacer.add_theme_constant_override("margin_bottom", int(DEFAULT_GROUP_GAP / 2))
var line := ColorRect.new()
line.color = _theme_color(&"border", Color(1, 1, 1, 0.2))
line.custom_minimum_size.y = 1.0
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spacer.add_child(line)
_items.add_child(spacer)
func popup_at(global_position: Vector2, avoid_rect: Rect2 = Rect2()) -> void:
if _items == null:
_build_contents()
var parent_viewport: Viewport = get_parent().get_viewport() if get_parent() != null else get_viewport()
var safe_rect: Rect2 = parent_viewport.get_visible_rect()
var margin := 8.0
var popup_size := _items.get_combined_minimum_size() + Vector2(SHELL_INSET * 2, SHELL_INSET * 2)
var border_width := float(_theme_px(&"normal_border", 1))
popup_size += Vector2(border_width * 2.0, border_width * 2.0)
popup_size.x = maxf(popup_size.x, float(_menu_min_width() + SHELL_INSET * 2))
var max_popup_size := Vector2(maxf(1.0, safe_rect.size.x - margin * 2.0), maxf(1.0, safe_rect.size.y - margin * 2.0))
popup_size = popup_size.min(max_popup_size)
if _scroll != null:
var content_height: float = _items.get_combined_minimum_size().y
var available_content_height: float = max_popup_size.y - float(SHELL_INSET * 2) - border_width * 2.0
_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO if content_height > available_content_height else ScrollContainer.SCROLL_MODE_DISABLED
size = Vector2i(popup_size.ceil())
if _scroll != null:
_scroll.scroll_vertical = 0
var max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - popup_size.x - margin)
var max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - popup_size.y - margin)
var popup_origin := _choose_popup_origin(
global_position,
avoid_rect,
popup_size,
safe_rect,
margin,
max_x,
max_y
)
var popup_position := Vector2i(popup_origin.round())
popup_on_parent(Rect2i(popup_position, Vector2i.ONE))
# PopupPanel sizes itself from the child layout on popup. Reapply the measured
# target size after opening so the scroll region can honor the 48 px rows.
size = Vector2i(popup_size.ceil())
var actual_size := Vector2(size)
if popup_position.x + actual_size.x > safe_rect.end.x - margin:
popup_origin.x = global_position.x - actual_size.x - margin
if popup_position.y + actual_size.y > safe_rect.end.y - margin:
popup_origin.y = global_position.y - actual_size.y - margin
var actual_max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - actual_size.x - margin)
var actual_max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - actual_size.y - margin)
popup_origin.x = clampf(popup_origin.x, safe_rect.position.x + margin, actual_max_x)
popup_origin.y = clampf(popup_origin.y, safe_rect.position.y + margin, actual_max_y)
position = Vector2i(popup_origin.round())
func _choose_popup_origin(
global_position: Vector2,
avoid_rect: Rect2,
popup_size: Vector2,
safe_rect: Rect2,
margin: float,
max_x: float,
max_y: float
) -> Vector2:
var low_x: float = safe_rect.position.x + margin
var low_y: float = safe_rect.position.y + margin
var cursor_fallback := Vector2(
clampf(global_position.x + margin, low_x, max_x),
clampf(global_position.y + margin, low_y, max_y)
)
if not avoid_rect.has_area():
return cursor_fallback
var aligned_y := clampf(global_position.y - 20.0, low_y, max_y)
var aligned_x := clampf(global_position.x + margin, low_x, max_x)
var candidates: Array[Vector2] = [
Vector2(avoid_rect.end.x + TARGET_GAP, aligned_y),
Vector2(avoid_rect.position.x - popup_size.x - TARGET_GAP, aligned_y),
Vector2(aligned_x, avoid_rect.end.y + TARGET_GAP),
Vector2(aligned_x, avoid_rect.position.y - popup_size.y - TARGET_GAP),
cursor_fallback,
]
for candidate: Vector2 in candidates:
if candidate.x < low_x or candidate.y < low_y or candidate.x > max_x or candidate.y > max_y:
continue
var candidate_rect := Rect2(candidate, popup_size)
if not candidate_rect.intersects(avoid_rect):
return candidate
return cursor_fallback
func get_row_button(id: int) -> Button:
return _row_ids.get(id) as Button
func _build_contents() -> void:
if _items != null:
return
_shell = MarginContainer.new()
_shell.name = "MenuShell"
_shell.add_theme_constant_override("margin_left", SHELL_INSET)
_shell.add_theme_constant_override("margin_top", SHELL_INSET)
_shell.add_theme_constant_override("margin_right", SHELL_INSET)
_shell.add_theme_constant_override("margin_bottom", SHELL_INSET)
_shell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_shell.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(_shell)
_scroll = ScrollContainer.new()
_scroll.name = "MenuScroll"
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_shell.add_child(_scroll)
_items = VBoxContainer.new()
_items.name = "MenuItems"
_items.add_theme_constant_override("separation", 0)
_items.custom_minimum_size.x = float(_menu_min_width())
_scroll.add_child(_items)
_apply_visual_style()
func _apply_visual_style() -> void:
if _visual_theme == null:
return
var surface := _theme_color(&"surface", Color("#252B38"))
var border := _theme_color(&"border", Color("#FFFFFF33"))
var radius := _theme_px(&"panel", 10)
var panel := StyleBoxFlat.new()
panel.bg_color = surface
panel.border_color = border
panel.set_border_width_all(_theme_px(&"normal_border", 1))
panel.set_corner_radius_all(radius)
panel.shadow_color = Color(0.0, 0.0, 0.0, 0.28)
panel.shadow_size = 8
panel.shadow_offset = Vector2(0.0, 3.0)
add_theme_stylebox_override("panel", panel)
if _shell != null:
_shell.add_theme_constant_override("margin_left", SHELL_INSET)
_shell.add_theme_constant_override("margin_top", SHELL_INSET)
_shell.add_theme_constant_override("margin_right", SHELL_INSET)
_shell.add_theme_constant_override("margin_bottom", SHELL_INSET)
if _items != null:
_items.custom_minimum_size.x = float(_menu_min_width())
for button: Button in _row_buttons:
if not is_instance_valid(button):
continue
button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height())
button.add_theme_font_size_override("font_size", _font_size())
var role := int(button.get_meta("context_menu_role", ItemRole.NORMAL))
var item_role: ItemRole = ItemRole.DESTRUCTIVE if role == ItemRole.DESTRUCTIVE else ItemRole.NORMAL
button.add_theme_color_override("font_color", _text_color(item_role))
button.add_theme_color_override("font_hover_color", _text_color(item_role))
button.add_theme_color_override("font_pressed_color", _text_color(item_role))
button.add_theme_color_override("font_focus_color", _text_color(item_role))
button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
button.add_theme_stylebox_override("hover", _make_hover_box())
button.add_theme_stylebox_override("pressed", _make_hover_box(true))
button.add_theme_stylebox_override("focus", _make_focus_box())
func _make_row_box(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = fill
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(ROW_RADIUS)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 4
style.content_margin_bottom = 4
return style
func _make_hover_box(pressed: bool = false) -> StyleBoxFlat:
var hover_color := _theme_color(&"surface_hover", Color("#30394A"))
if pressed:
hover_color = hover_color.darkened(0.1)
var style := _make_row_box(hover_color, _theme_color(&"edit", Color("#22C6FF")), 0)
style.border_width_left = 3
style.content_margin_left = 9
return style
func _make_focus_box() -> StyleBoxFlat:
var style := _make_row_box(Color(0.0, 0.0, 0.0, 0.0), _theme_color(&"focus", Color("#8BD9FF")), 2)
return style
func _on_row_pressed(button: Button) -> void:
if not is_instance_valid(button):
return
var id: int = int(button.get_meta("context_menu_id", -1))
id_pressed.emit(id)
hide()
func _input(event: InputEvent) -> void:
if not visible or not event is InputEventKey:
return
var key_event := event as InputEventKey
if not key_event.pressed:
return
if key_event.keycode == KEY_DOWN:
_move_keyboard_focus(1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_UP:
_move_keyboard_focus(-1)
get_viewport().set_input_as_handled()
func _move_keyboard_focus(direction: int) -> void:
if _row_buttons.is_empty():
return
var candidate_index: int
if not _keyboard_navigation_active:
candidate_index = 0 if direction > 0 else _row_buttons.size() - 1
else:
candidate_index = _focused_row_index + direction
for _attempt: int in range(_row_buttons.size()):
candidate_index = posmod(candidate_index, _row_buttons.size())
var button: Button = _row_buttons[candidate_index]
if is_instance_valid(button) and button.visible and not button.disabled:
_enable_keyboard_navigation()
_focused_row_index = candidate_index
button.grab_focus()
return
candidate_index += direction
func _enable_keyboard_navigation() -> void:
_keyboard_navigation_active = true
for button: Button in _row_buttons:
if is_instance_valid(button):
button.focus_mode = Control.FOCUS_ALL
func _reset_keyboard_navigation() -> void:
_keyboard_navigation_active = false
_focused_row_index = -1
if not is_node_ready():
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and _row_buttons.has(focus_owner):
focus_owner.release_focus()
for button: Button in _row_buttons:
if is_instance_valid(button):
button.focus_mode = Control.FOCUS_NONE
func _on_row_mouse_entered(_button: Button) -> void:
_reset_keyboard_navigation()
func _theme_color(role: StringName, fallback: Color) -> Color:
if _visual_theme == null:
return fallback
return _visual_theme.chrome_color(role, _editor)
func _theme_px(role: StringName, fallback: int) -> int:
if _visual_theme == null:
return fallback
return _visual_theme.chrome_px(role, _editor)
func _font_size() -> int:
if _font_size_override > 0:
return _font_size_override
return _theme_px(&"context_menu", 20)
func _menu_row_height() -> int:
return maxi(_theme_px(&"context_menu_row_height", DEFAULT_ROW_HEIGHT), DEFAULT_ROW_HEIGHT)
func _menu_min_width() -> int:
return maxi(_theme_px(&"context_menu_min_width", DEFAULT_MIN_WIDTH), DEFAULT_MIN_WIDTH)
func _text_color(role: ItemRole) -> Color:
if role == ItemRole.DESTRUCTIVE:
return _theme_color(&"destructive", Color("#E66B75"))
return _theme_color(&"text", Color("#F5F7FA"))
@@ -0,0 +1 @@
uid://bjidsdsxomilt
@@ -0,0 +1,231 @@
class_name EditorSelectionDropdown
extends MenuButton
## Single-choice editor picker with a Studio-style selected row and no radio lane.
signal item_selected(index: int)
var selected: int = -1:
set(value):
_set_selected(value)
get:
return _selected_index
var _selected_index: int = -1
var _popup_menu: PopupMenu
var _field_icons: Array[Texture2D] = []
var _control_theme: Theme
var _surface_color: Color = Color("#252B38")
var _selected_surface_color: Color = Color("#103B4D")
var _border_color: Color = Color("#FFFFFF33")
var _accent_color: Color = Color("#22C6FF")
var _focus_color: Color = Color("#8BD9FF")
var _text_color: Color = Color("#F5F7FA")
var _font_size: int = 18
var _row_separation: int = 6
func _ready() -> void:
flat = false
focus_mode = Control.FOCUS_ALL
alignment = HORIZONTAL_ALIGNMENT_LEFT
_popup_menu = get_popup()
_popup_menu.shrink_width = false
_popup_menu.transparent = false
_popup_menu.transparent_bg = false
_popup_menu.id_pressed.connect(_on_popup_item_pressed)
about_to_popup.connect(_on_about_to_popup)
_apply_popup_theme()
_apply_field_theme()
_sync_selected_item()
queue_redraw()
func configure(
control_theme: Theme,
surface_color: Color,
selected_surface_color: Color,
border_color: Color,
accent_color: Color,
focus_color: Color,
text_color: Color,
font_size: int,
row_separation: int = 6
) -> void:
_control_theme = control_theme
_surface_color = surface_color
_selected_surface_color = selected_surface_color
_border_color = border_color
_accent_color = accent_color
_focus_color = focus_color
_text_color = text_color
_font_size = font_size
_row_separation = maxi(0, row_separation)
if is_node_ready():
_apply_popup_theme()
func add_item(label: String, id: int = -1) -> void:
var index: int = get_popup().item_count
var item_id: int = index if id < 0 else id
add_styled_item(label, null, null, item_id)
func add_styled_item(
label: String,
field_icon: Texture2D = null,
popup_icon: Texture2D = null,
id: int = -1
) -> void:
var popup: PopupMenu = get_popup()
var index: int = popup.item_count
var item_id: int = index if id < 0 else id
_field_icons.append(field_icon)
if popup_icon != null:
popup.add_icon_item(popup_icon, label, item_id)
popup.set_item_icon_max_width(index, 44)
else:
popup.add_item(label, item_id)
popup.set_item_as_radio_checkable(index, false)
if _selected_index < 0:
_set_selected(index)
func get_item_text(index: int) -> String:
return get_popup().get_item_text(index)
func get_item_icon(index: int) -> Texture2D:
return _field_icons[index] if index >= 0 and index < _field_icons.size() else null
func select(index: int) -> void:
_set_selected(index)
func clear() -> void:
get_popup().clear()
_field_icons.clear()
_selected_index = -1
_sync_selected_item()
func hide_popup() -> void:
get_popup().hide()
func _set_selected(index: int) -> void:
var count: int = get_popup().item_count
if index < 0 or count == 0:
_selected_index = -1
else:
_selected_index = clampi(index, 0, count - 1)
_sync_selected_item()
func _sync_selected_item() -> void:
if not is_instance_valid(get_popup()):
return
if _selected_index < 0 or _selected_index >= get_popup().item_count:
text = ""
icon = null
return
text = get_popup().get_item_text(_selected_index)
icon = _field_icons[_selected_index] if _selected_index < _field_icons.size() else null
func _apply_popup_theme() -> void:
if _popup_menu == null:
return
if _control_theme != null:
_popup_menu.theme = _control_theme
var panel := StyleBoxFlat.new()
var opaque_surface: Color = _surface_color
opaque_surface.a = 1.0
panel.bg_color = opaque_surface
panel.border_color = _border_color
panel.set_border_width_all(1)
panel.set_corner_radius_all(8)
panel.content_margin_left = 6.0
panel.content_margin_top = 6.0
panel.content_margin_right = 6.0
panel.content_margin_bottom = 6.0
_popup_menu.add_theme_stylebox_override("panel", panel)
var selected_row := StyleBoxFlat.new()
selected_row.bg_color = _selected_surface_color
selected_row.border_color = _accent_color
selected_row.set_border_width_all(2)
selected_row.set_corner_radius_all(7)
selected_row.content_margin_left = 5.0
selected_row.content_margin_top = 12.0
selected_row.content_margin_right = 5.0
selected_row.content_margin_bottom = 12.0
_popup_menu.add_theme_stylebox_override("hover", selected_row)
_popup_menu.add_theme_color_override("font_hover_color", _text_color)
_popup_menu.add_theme_color_override("font_color", _text_color)
_popup_menu.add_theme_font_size_override("font_size", _font_size)
_popup_menu.add_theme_constant_override("icon_max_width", 44)
_popup_menu.add_theme_constant_override("h_separation", 10)
_popup_menu.add_theme_constant_override("v_separation", _row_separation)
_popup_menu.add_theme_constant_override("item_start_padding", 4)
_popup_menu.add_theme_constant_override("item_end_padding", 4)
add_theme_stylebox_override("focus", _make_focus_style())
queue_redraw()
func _apply_field_theme() -> void:
for state: String in ["normal", "hover", "pressed", "disabled"]:
var original: StyleBox = get_theme_stylebox(state, "Button")
if original == null:
continue
var field_style: StyleBox = original.duplicate()
field_style.content_margin_right = maxf(field_style.content_margin_right, 28.0)
add_theme_stylebox_override(state, field_style)
func _make_focus_style() -> StyleBoxFlat:
var focus_box := StyleBoxFlat.new()
focus_box.bg_color = Color(0.0, 0.0, 0.0, 0.0)
focus_box.border_color = _focus_color
focus_box.set_border_width_all(2)
focus_box.set_corner_radius_all(8)
focus_box.content_margin_right = 28.0
return focus_box
func _draw() -> void:
var center := Vector2(size.x - 16.0, size.y * 0.5)
var points := PackedVector2Array([
center + Vector2(-4.0, -2.0),
center + Vector2(0.0, 2.0),
center + Vector2(4.0, -2.0),
])
draw_polyline(points, _text_color.darkened(0.28), 2.0, true)
func _on_about_to_popup() -> void:
var target_width: int = maxi(roundi(size.x), _popup_menu.min_size.x)
_popup_menu.min_size = Vector2i(target_width, _popup_menu.min_size.y)
_popup_menu.size = Vector2i(maxi(_popup_menu.size.x, target_width), _popup_menu.size.y)
call_deferred("_sync_popup_size")
if _selected_index >= 0:
_popup_menu.set_focused_item(_selected_index)
else:
_popup_menu.set_focused_item(-1)
func _sync_popup_size() -> void:
if _popup_menu == null:
return
var target_width: int = maxi(roundi(size.x), _popup_menu.min_size.x)
_popup_menu.min_size = Vector2i(target_width, _popup_menu.min_size.y)
_popup_menu.size = Vector2i(maxi(_popup_menu.size.x, target_width), _popup_menu.size.y)
if _selected_index >= 0:
_popup_menu.set_focused_item(_selected_index)
func _on_popup_item_pressed(id: int) -> void:
var index: int = get_popup().get_item_index(id)
if index < 0 or index == _selected_index:
return
_set_selected(index)
item_selected.emit(index)
@@ -0,0 +1 @@
uid://qlg14pj6jfj7
@@ -0,0 +1,297 @@
class_name EditorVisualTheme
extends RefCounted
## Validated semantic chrome tokens shared by the standalone editors.
## Canvas artwork and editor-specific drawing values stay with their owners.
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://editor_visual_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"chrome": {
"colors": {
"background": "#171B24F2",
"surface": "#252B38FA",
"surface_hover": "#30394A",
"selected_surface": "#103B4D",
"text": "#F5F7FA",
"text_muted": "#AAB2C0",
"edit": "#22C6FF",
"preview": "#22C6FF66",
"focus": "#8BD9FF",
"action_primary": "#087DBA",
"destructive": "#E66B75",
"border": "#FFFFFF33",
"disabled": "#77808F",
"saved": "#8FD6A4",
"dirty": "#FFD166",
"error": "#FF8A8A",
},
"fonts": {
"body": 18,
"helper": 16,
"section": 22,
"title": 26,
"menu": 20,
"menu_bar": 22,
"menu_popup": 26,
"status": 16,
"mode": 24,
"prop_name": 20,
"context_menu": 20,
},
"spacing": {"small": 8, "medium": 12, "large": 16},
"radii": {"control": 8, "panel": 10},
"borders": {"normal": 1, "focus": 2},
"targets": {
"button_min_height": 44,
"row_min_height": 44,
"mode_button_min_height": 56,
"context_menu_row_height": 48,
"context_menu_min_width": 208,
},
},
"editors": {"prop": {}, "stickman": {}},
"stickman_canvas": {
"grid_color": "#FFFFFF26",
"selection_core": "#FFFFFF",
"selection_halo": "#000000D9",
},
}
const COLOR_ROLE_SECTIONS: Dictionary = {
"background": "colors",
"surface": "colors",
"surface_hover": "colors",
"selected_surface": "colors",
"text": "colors",
"text_muted": "colors",
"edit": "colors",
"preview": "colors",
"focus": "colors",
"action_primary": "colors",
"destructive": "colors",
"border": "colors",
"disabled": "colors",
"saved": "colors",
"dirty": "colors",
"error": "colors",
}
const SIZE_ROLE_SECTIONS: Dictionary = {
"body": "fonts",
"helper": "fonts",
"section": "fonts",
"title": "fonts",
"menu": "fonts",
"menu_bar": "fonts",
"menu_popup": "fonts",
"status": "fonts",
"mode": "fonts",
"prop_name": "fonts",
"context_menu": "fonts",
"small": "spacing",
"medium": "spacing",
"large": "spacing",
"control": "radii",
"panel": "radii",
"normal_border": "borders",
"focus_border": "borders",
"button_min_height": "targets",
"row_min_height": "targets",
"mode_button_min_height": "targets",
"context_menu_row_height": "targets",
"context_menu_min_width": "targets",
}
const CONFIG_SIZE_KEYS: Dictionary = {
"normal_border": "normal",
"focus_border": "focus",
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
_warned.clear()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "EditorVisualTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "EditorVisualTheme: '%s' could not be read; using defaults." % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
_warn_once(path, "EditorVisualTheme: '%s' is malformed; using defaults." % path)
return false
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func chrome_color(role: StringName, editor: StringName = &"") -> Color:
var role_key: String = String(role)
if not COLOR_ROLE_SECTIONS.has(role_key):
_warn_once("color:" + role_key, "EditorVisualTheme: unknown color role '%s'." % role_key)
return Color.MAGENTA
var section: String = COLOR_ROLE_SECTIONS[role_key]
var raw: Variant = _resolved_value(section, role_key, editor)
var fallback_config: Dictionary = DEFAULT_CONFIG["chrome"][section]
var fallback: Color = Color.from_string(String(fallback_config[role_key]), Color.WHITE)
if not raw is String:
_warn_once("color:" + role_key, "EditorVisualTheme: invalid value for color role '%s'." % role_key)
return fallback
var hex: String = String(raw)
if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9):
_warn_once("color:" + role_key, "EditorVisualTheme: invalid color at '%s'." % role_key)
return fallback
return Color.from_string(hex, fallback)
func chrome_px(role: StringName, editor: StringName = &"") -> int:
var role_key: String = String(role)
if not SIZE_ROLE_SECTIONS.has(role_key):
_warn_once("size:" + role_key, "EditorVisualTheme: unknown size role '%s'." % role_key)
return 0
var section: String = SIZE_ROLE_SECTIONS[role_key]
var config_key: String = String(CONFIG_SIZE_KEYS.get(role_key, role_key))
var raw: Variant = _resolved_value(section, config_key, editor)
var fallback: int = int(DEFAULT_CONFIG["chrome"][section][config_key])
if not raw is int and not raw is float:
_warn_once("size:" + role_key, "EditorVisualTheme: invalid value for size role '%s'." % role_key)
return fallback
return clampi(int(round(float(raw))), 0, 256)
func stickman_canvas_color(role: StringName) -> Color:
var role_key: String = String(role)
var fallback_text: String = String(DEFAULT_CONFIG["stickman_canvas"].get(role_key, "#FFFFFF"))
var fallback: Color = Color.from_string(fallback_text, Color.WHITE)
var raw: Variant = _get_nested(_config, ["stickman_canvas", role_key], fallback_text)
if not raw is String:
_warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key)
return fallback
var hex: String = String(raw)
if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9):
_warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key)
return fallback
return Color.from_string(hex, fallback)
func control_theme(editor: StringName) -> Theme:
var theme: Theme = Theme.new()
var surface: Color = chrome_color(&"surface", editor)
var background: Color = chrome_color(&"background", editor)
var hover: Color = chrome_color(&"surface_hover", editor)
var text: Color = chrome_color(&"text", editor)
var muted: Color = chrome_color(&"text_muted", editor)
var edit: Color = chrome_color(&"edit", editor)
var focus: Color = chrome_color(&"focus", editor)
var border: Color = chrome_color(&"border", editor)
var radius: int = chrome_px(&"control", editor)
var normal_width: int = chrome_px(&"normal_border", editor)
var focus_width: int = maxi(2, chrome_px(&"focus_border", editor))
var normal_box: StyleBoxFlat = _make_box(surface, border, radius, normal_width)
var hover_box: StyleBoxFlat = _make_box(hover, edit, radius, normal_width)
var pressed_box: StyleBoxFlat = _make_box(surface.darkened(0.12), edit, radius, focus_width)
var focus_box: StyleBoxFlat = _make_box(Color(0, 0, 0, 0), focus, radius, focus_width)
var disabled_box: StyleBoxFlat = _make_box(background.darkened(0.1), border, radius, normal_width)
for type_name: String in ["Button", "OptionButton", "ColorPickerButton", "CheckBox"]:
theme.set_stylebox("normal", type_name, normal_box.duplicate())
theme.set_stylebox("hover", type_name, hover_box.duplicate())
theme.set_stylebox("pressed", type_name, pressed_box.duplicate())
theme.set_stylebox("focus", type_name, focus_box.duplicate())
theme.set_stylebox("disabled", type_name, disabled_box.duplicate())
theme.set_color("font_color", type_name, text)
theme.set_color("font_hover_color", type_name, text)
theme.set_color("font_pressed_color", type_name, edit)
theme.set_color("font_disabled_color", type_name, muted)
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
var input_box: StyleBoxFlat = _make_box(background, border, radius, normal_width)
var input_focus: StyleBoxFlat = _make_box(background, focus, radius, focus_width)
for type_name: String in ["LineEdit", "SpinBox", "TextEdit"]:
theme.set_stylebox("normal", type_name, input_box.duplicate())
theme.set_stylebox("focus", type_name, input_focus.duplicate())
theme.set_color("font_color", type_name, text)
theme.set_color("font_placeholder_color", type_name, muted)
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
var panel_box: StyleBoxFlat = _make_box(surface, border, chrome_px(&"panel", editor), normal_width)
theme.set_stylebox("panel", "PanelContainer", panel_box)
theme.set_stylebox("panel", "MenuBar", panel_box.duplicate())
theme.set_font_size("font_size", "Label", chrome_px(&"body", editor))
theme.set_color("font_color", "Label", text)
theme.set_color("font_hover_color", "Label", text)
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu_popup", editor))
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu_bar", editor))
theme.set_color("font_color", "MenuBar", text)
return theme
func style_button_role(button: Button, role: StringName, editor: StringName) -> void:
button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, float(chrome_px(&"button_min_height", editor)))
if role != &"primary" and role != &"destructive":
return
var fill_role: StringName = &"action_primary" if role == &"primary" else &"destructive"
var fill: Color = chrome_color(fill_role, editor)
var focus: Color = chrome_color(&"focus", editor)
var radius: int = chrome_px(&"control", editor)
var border_width: int = chrome_px(&"normal_border", editor)
var focus_width: int = maxi(2, chrome_px(&"focus_border", editor))
button.add_theme_stylebox_override("normal", _make_box(fill, fill.lightened(0.12), radius, border_width))
button.add_theme_stylebox_override("hover", _make_box(fill.lightened(0.12), focus, radius, border_width))
button.add_theme_stylebox_override("pressed", _make_box(fill.darkened(0.12), focus, radius, focus_width))
button.add_theme_stylebox_override("focus", _make_box(Color(0.0, 0.0, 0.0, 0.0), focus, radius, focus_width))
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
func _resolved_value(section: String, key: String, editor: StringName) -> Variant:
if not editor.is_empty():
var editor_value: Variant = _get_nested(_config, ["editors", String(editor), section, key], null)
if editor_value != null:
return editor_value
return _get_nested(_config, ["chrome", section, key], null)
func _get_nested(source: Dictionary, keys: Array[String], fallback: Variant) -> Variant:
var cursor: Variant = source
for key: String in keys:
if not cursor is Dictionary or not (cursor as Dictionary).has(key):
return fallback
cursor = (cursor as Dictionary)[key]
return cursor
func _make_box(fill: Color, outline: Color, radius: int, width: int) -> StyleBoxFlat:
var box: StyleBoxFlat = StyleBoxFlat.new()
box.bg_color = fill
box.border_color = outline
box.set_border_width_all(width)
box.set_corner_radius_all(radius)
box.content_margin_left = 8
box.content_margin_right = 8
box.content_margin_top = 6
box.content_margin_bottom = 6
return box
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
for key: Variant in overrides:
if merged.get(key) is Dictionary and overrides[key] is Dictionary:
merged[key] = _deep_merge(merged[key] as Dictionary, overrides[key] as Dictionary)
else:
merged[key] = overrides[key]
return merged
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key):
_warned[key] = true
push_warning(message)
@@ -0,0 +1 @@
uid://c2mnpqu565na
+68 -24
View File
@@ -1,7 +1,7 @@
class_name StageDialogStyler
extends RefCounted
## Renders child-facing dialogs as a shared in-stage card while preserving the
## native AcceptDialog/ConfirmationDialog signals used by the stage workflows.
## Renders child-facing stage and editor dialogs as a shared card while preserving
## the native AcceptDialog/ConfirmationDialog signals used by their workflows.
const THEME_DEFAULTS := preload("res://scripts/stage/sandbox_theme.gd")
const OVERLAY_META := &"stage_dialog_overlay"
@@ -12,12 +12,17 @@ const DETAIL_META := &"stage_dialog_detail"
const ACTIONS_META := &"stage_dialog_actions"
const EXTRAS_META := &"stage_dialog_extra_controls"
const CARD_META := &"stage_dialog_card"
const CARD_WIDTH_META := &"stage_dialog_card_width"
const MAX_HEIGHT_RATIO_META := &"stage_dialog_max_height_ratio"
const SCREEN_MARGIN_META := &"stage_dialog_screen_margin"
const TITLE_META := &"stage_dialog_title"
const ICON_META := &"stage_dialog_icon"
const ICON_LABEL_META := &"stage_dialog_icon_label"
const ICON_BADGE_META := &"stage_dialog_icon_badge"
const PRIMARY_META := &"stage_dialog_primary_button"
const SECONDARY_META := &"stage_dialog_secondary_buttons"
const DESTRUCTIVE_META := &"stage_dialog_destructive_buttons"
const DEFAULT_PRIMARY_META := &"stage_dialog_default_primary"
const OPEN_META := &"stage_dialog_open"
@@ -26,7 +31,9 @@ static func apply(
stage_theme: SandboxTheme,
ui_font: Font,
primary_button: Button = null,
destructive_buttons: Array = []
destructive_buttons: Array = [],
secondary_buttons: Array = [],
default_ok_is_primary: bool = true
) -> void:
if dialog == null:
return
@@ -34,10 +41,14 @@ static func apply(
if not dialog.has_meta(OVERLAY_META):
_install_dialog_surface(dialog, active_theme, ui_font)
var ok_button: Button = (dialog as AcceptDialog).get_ok_button() if dialog is AcceptDialog else null
var primary: Button = primary_button if primary_button != null else ok_button
var primary: Button = primary_button
if primary == null and default_ok_is_primary and not _contains_button(destructive_buttons, ok_button):
primary = ok_button
dialog.set_meta(PRIMARY_META, primary)
dialog.set_meta(SECONDARY_META, secondary_buttons.duplicate())
dialog.set_meta(DESTRUCTIVE_META, destructive_buttons.duplicate())
_refresh_dialog_surface(dialog, active_theme, ui_font, primary, destructive_buttons)
dialog.set_meta(DEFAULT_PRIMARY_META, default_ok_is_primary)
_refresh_dialog_surface(dialog, active_theme, ui_font, primary, destructive_buttons, secondary_buttons, default_ok_is_primary)
static func popup(dialog: Window) -> void:
@@ -45,30 +56,24 @@ static func popup(dialog: Window) -> void:
return
var active_theme: SandboxTheme = _theme_for(dialog)
var font: Font = dialog.get_meta(&"stage_dialog_font") as Font if dialog.has_meta(&"stage_dialog_font") else null
var primary: Button = dialog.get_meta(PRIMARY_META, null) as Button
var primary: Button = dialog.get_meta(PRIMARY_META) as Button if dialog.has_meta(PRIMARY_META) else null
var secondary_buttons: Array = dialog.get_meta(SECONDARY_META, []) as Array
var destructive_buttons: Array = dialog.get_meta(DESTRUCTIVE_META, []) as Array
_refresh_dialog_surface(dialog, active_theme, font, primary, destructive_buttons)
var default_ok_is_primary: bool = bool(dialog.get_meta(DEFAULT_PRIMARY_META, true))
_refresh_dialog_surface(dialog, active_theme, font, primary, destructive_buttons, secondary_buttons, default_ok_is_primary)
var overlay: Control = dialog.get_meta(OVERLAY_META) as Control
if overlay == null:
return
dialog.hide()
dialog.set_meta(OPEN_META, true)
overlay.show()
var host_viewport: Viewport = overlay.get_viewport()
if host_viewport != null:
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size)
var card: PanelContainer = dialog.get_meta(CARD_META) as PanelContainer
if card != null:
var card_width: int = active_theme.integer("popups.confirmation.card_width", 560, 320, 720)
card.custom_minimum_size.x = minf(float(card_width), maxf(280.0, float(viewport_size.x - 36)))
_fit_body_scroll(dialog, viewport_size, active_theme)
refit(dialog)
var tree: SceneTree = dialog.get_tree()
if tree != null:
var fit_after_final_layout := func() -> void:
if not is_instance_valid(dialog) or not dialog.is_inside_tree() or not is_open(dialog):
return
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size) if host_viewport != null else Vector2i.ZERO
_fit_body_scroll(dialog, viewport_size, active_theme)
refit(dialog)
var fit_after_layout := func() -> void:
if is_instance_valid(tree):
tree.process_frame.connect(fit_after_final_layout, Object.CONNECT_ONE_SHOT)
@@ -76,8 +81,10 @@ static func popup(dialog: Window) -> void:
if is_instance_valid(tree):
tree.process_frame.connect(fit_after_layout, Object.CONNECT_ONE_SHOT)
tree.process_frame.connect(defer_fit_one_frame, Object.CONNECT_ONE_SHOT)
var focus_primary: Button = dialog.get_meta(PRIMARY_META, null) as Button
var focus_primary: Button = dialog.get_meta(PRIMARY_META) as Button if dialog.has_meta(PRIMARY_META) else null
var focus_target: Button = visual_button(focus_primary)
if focus_target == null and dialog is ConfirmationDialog:
focus_target = visual_button((dialog as ConfirmationDialog).get_cancel_button())
if focus_target != null and focus_target.visible:
focus_target.call_deferred("grab_focus")
@@ -91,6 +98,27 @@ static func is_open(dialog: Window) -> bool:
return dialog.visible
static func refit(dialog: Window) -> void:
if dialog == null or not is_instance_valid(dialog) or not dialog.has_meta(OVERLAY_META):
return
var overlay: Control = dialog.get_meta(OVERLAY_META) as Control
if overlay == null or not is_instance_valid(overlay):
return
var host_viewport: Viewport = overlay.get_viewport()
if host_viewport == null:
return
var active_theme: SandboxTheme = _theme_for(dialog)
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size)
var card: PanelContainer = dialog.get_meta(CARD_META) as PanelContainer
if card != null:
var screen_margin: int = int(dialog.get_meta(SCREEN_MARGIN_META, 18))
var default_width: int = active_theme.integer("popups.confirmation.card_width", 560, 320, 720)
var card_width: int = int(dialog.get_meta(CARD_WIDTH_META, default_width))
var available_width: float = maxf(0.0, float(viewport_size.x - screen_margin * 2))
card.custom_minimum_size.x = minf(float(card_width), available_width)
_fit_body_scroll(dialog, viewport_size, active_theme)
static func is_styled(dialog: Window) -> bool:
return dialog != null and is_instance_valid(dialog) and dialog.has_meta(OVERLAY_META)
@@ -216,6 +244,7 @@ static func _install_dialog_surface(dialog: Window, stage_theme: SandboxTheme, u
var overlay := Control.new()
overlay.name = "StageDialogOverlay"
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
overlay.theme = stage_theme.control_theme()
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
overlay.z_index = 100
overlay.z_as_relative = false
@@ -369,7 +398,9 @@ static func _refresh_dialog_surface(
stage_theme: SandboxTheme,
ui_font: Font,
primary_button: Button = null,
destructive_buttons: Array = []
destructive_buttons: Array = [],
secondary_buttons: Array = [],
default_ok_is_primary: bool = true
) -> void:
if not dialog.has_meta(OVERLAY_META):
return
@@ -442,16 +473,24 @@ static func _refresh_dialog_surface(
for old_button: Node in action_row.get_children():
action_row.remove_child(old_button)
old_button.queue_free()
var primary: Button = primary_button if primary_button != null else ok_button
var primary: Button = primary_button
if primary == null and default_ok_is_primary and not _contains_button(destructive_buttons, ok_button):
primary = ok_button
if ok_button != null:
ok_button.visible = false
if cancel_button != null:
cancel_button.visible = false
for raw_secondary: Variant in secondary_buttons:
if raw_secondary is Button:
(raw_secondary as Button).visible = false
for raw_button: Variant in destructive_buttons:
if raw_button is Button:
(raw_button as Button).visible = false
var ordered: Array[Button] = []
_append_unique_button(ordered, primary)
for raw_secondary: Variant in secondary_buttons:
if raw_secondary is Button:
_append_unique_button(ordered, raw_secondary as Button)
for raw_destructive: Variant in destructive_buttons:
if raw_destructive is Button:
_append_unique_button(ordered, raw_destructive as Button)
@@ -460,6 +499,8 @@ static func _refresh_dialog_surface(
for original: Button in ordered:
var visual: Button = Button.new()
visual.text = original.text
visual.disabled = original.disabled
visual.tooltip_text = original.tooltip_text
visual.name = "Action_%s" % String(original.name)
visual.size_flags_horizontal = Control.SIZE_SHRINK_END
var role: StringName = &"secondary"
@@ -498,7 +539,10 @@ static func _fit_body_scroll(dialog: Window, viewport_size: Vector2i, stage_them
var body_width: float = maxf(0.0, card_width - card_padding * 2.0)
var preferred_body_height: float = body.get_combined_minimum_size().y
var fixed_height: float = header.get_combined_minimum_size().y + actions.get_combined_minimum_size().y + card_padding * 2.0 + content_gap * 2.0
var max_body_height: float = maxf(48.0, float(viewport_size.y) - 36.0 - fixed_height)
var screen_margin: int = int(dialog.get_meta(SCREEN_MARGIN_META, 18))
var max_height_ratio: float = clampf(float(dialog.get_meta(MAX_HEIGHT_RATIO_META, 1.0)), 0.35, 1.0)
var available_height: float = maxf(0.0, minf(float(viewport_size.y - screen_margin * 2), float(viewport_size.y) * max_height_ratio))
var max_body_height: float = maxf(48.0, available_height - fixed_height)
if preferred_body_height > max_body_height:
var vertical_bar: VScrollBar = body_scroll.get_v_scroll_bar()
if vertical_bar != null:
@@ -516,18 +560,18 @@ static func _find_overlay_host(dialog: Window) -> Node:
return ancestor
ancestor = ancestor.get_parent()
var tree: SceneTree = dialog.get_tree()
var scene: Node = tree.current_scene if tree != null else null
var scene: Node = tree.current_scene if tree != null and tree.current_scene != null else (tree.root if tree != null else null)
if scene != null:
var ui_layer: Node = scene.get_node_or_null("UI")
if ui_layer is CanvasLayer:
return ui_layer
var existing: Node = scene.get_node_or_null("StageDialogLayer")
var existing: Node = scene.find_child("StageDialogLayer", true, false)
if existing is CanvasLayer:
return existing
var layer := CanvasLayer.new()
layer.name = "StageDialogLayer"
layer.layer = 100
scene.add_child(layer)
scene.add_child.call_deferred(layer)
return layer
return null
@@ -0,0 +1,396 @@
class_name StageGroundFinishChooser
extends ConfirmationDialog
## Modal single-choice gallery for choosing the look of newly painted Ground.
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const GROUND_PREVIEW := preload("res://scripts/shared/stage_ui/stage_ground_finish_preview.gd")
const DEFAULT_THEME := preload("res://scripts/stage/sandbox_theme.gd")
const DEFAULT_PAINT_TINT: Color = Color(0.25, 0.55, 0.25, 1.0)
var _stage_theme: SandboxTheme
var _ui_font: Font
var _finish_ids: Array[StringName] = []
var _finish_labels: Array[String] = []
var _selected_finish: StringName = &""
var _button_group: ButtonGroup
var _choice_buttons: Dictionary = {}
var _choice_previews: Dictionary = {}
var _choice_labels: Dictionary = {}
var _content_root: VBoxContainer
var _layout_row: HBoxContainer
var _choice_grid: GridContainer
var _preview_panel: PanelContainer
var _selected_preview: StageGroundFinishPreview
var _choice_height: int = 96
var _choice_preview_height: int = 48
var _choice_font_size: int = 18
var _preview_height: int = 128
var _compact_preview_height: int = 88
var _preview_min_width: int = 224
var _choice_min_width: int = 104
var _grid_gap: int = 8
var _compact_choice_height: int = 54
var _compact_choice_preview_height: int = 22
var _compact_choice_font_size: int = 16
var _compact_grid_gap: int = 6
var _compact_layout_max_width: int = 600
var _compact_layout_max_height: int = 460
var _wide_layout_min_width: int = 760
var _screen_margin: int = 24
var _is_wide_layout: bool = true
var _is_compact_layout: bool = false
func configure(
stage_theme: SandboxTheme,
ui_font: Font,
finish_ids: Array[StringName],
finish_labels: Array[String]
) -> void:
_stage_theme = stage_theme if stage_theme != null else DEFAULT_THEME.new() as SandboxTheme
_ui_font = ui_font
_finish_ids.assign(finish_ids)
_finish_labels.assign(finish_labels)
if not _finish_ids.is_empty():
_selected_finish = _finish_ids[0]
_configure_theme_values()
func open_for_finish(finish_id: StringName) -> void:
_select_finish(finish_id)
_apply_responsive_layout()
DIALOG_STYLER.popup(self)
call_deferred("_refit_dialog")
call_deferred("_focus_selected_choice")
func selected_finish() -> StringName:
return _selected_finish
func choice_buttons() -> Dictionary:
return _choice_buttons.duplicate()
func selected_preview_finish() -> StringName:
return &"" if _selected_preview == null else _selected_preview.finish_id
func _ready() -> void:
if _stage_theme == null:
_stage_theme = DEFAULT_THEME.new() as SandboxTheme
_configure_theme_values()
title = "Choose Ground"
dialog_text = "Stickmen walk here"
cancel_button_text = "Close"
ok_button_text = "Draw Grass Ground"
theme = _stage_theme.control_theme()
set_meta(DIALOG_STYLER.CARD_WIDTH_META, _theme_int("card_width", 880, 420, 1200))
set_meta(DIALOG_STYLER.MAX_HEIGHT_RATIO_META, _theme_number("max_height_ratio", 0.86, 0.55, 0.96))
set_meta(DIALOG_STYLER.SCREEN_MARGIN_META, _screen_margin)
_build_content()
DIALOG_STYLER.apply(self, _stage_theme, _ui_font, get_ok_button())
var icon_badge: Control = get_meta(DIALOG_STYLER.ICON_BADGE_META, null) as Control
if icon_badge != null:
icon_badge.hide()
_apply_responsive_layout()
var viewport: Viewport = _stage_viewport()
if viewport != null:
viewport.size_changed.connect(_on_viewport_size_changed)
func _configure_theme_values() -> void:
_choice_font_size = _theme_int("choice_font_size", 18, 14, 26)
_choice_height = _theme_int("choice_height", 96, 72, 132)
_choice_preview_height = _theme_int("choice_preview_height", 48, 32, 72)
_compact_choice_height = _theme_int("compact_choice_height", 50, 48, 72)
_compact_choice_preview_height = _theme_int("compact_choice_preview_height", 22, 16, 36)
_compact_choice_font_size = _theme_int("compact_choice_font_size", 16, 14, 20)
_compact_grid_gap = _theme_int("compact_grid_gap", 6, 4, 12)
_compact_layout_max_width = _theme_int("compact_layout_max_width", 600, 440, 760)
_compact_layout_max_height = _theme_int("compact_layout_max_height", 460, 360, 640)
_preview_height = _theme_int("preview_height", 128, 88, 220)
_compact_preview_height = _theme_int("compact_preview_height", 88, 64, 140)
_preview_min_width = _theme_int("preview_min_width", 224, 160, 360)
_choice_min_width = _theme_int("choice_min_width", 104, 88, 160)
_grid_gap = _theme_int("grid_gap", 8, 4, 24)
_wide_layout_min_width = _theme_int("wide_layout_min_width", 760, 600, 1100)
_screen_margin = _theme_int("screen_margin", 24, 12, 64)
func _build_content() -> void:
_button_group = ButtonGroup.new()
_button_group.allow_unpress = false
_content_root = VBoxContainer.new()
_content_root.name = "GroundFinishGallery"
_content_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_content_root.add_theme_constant_override("separation", _grid_gap)
_layout_row = HBoxContainer.new()
_layout_row.name = "GalleryAndPreview"
_layout_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_layout_row.add_theme_constant_override("separation", 16)
_choice_grid = GridContainer.new()
_choice_grid.name = "GroundFinishChoices"
_choice_grid.columns = 3
_choice_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_choice_grid.add_theme_constant_override("h_separation", _grid_gap)
_choice_grid.add_theme_constant_override("v_separation", _grid_gap)
_build_choice_buttons()
_preview_panel = _build_selected_preview_panel()
_layout_row.add_child(_choice_grid)
_layout_row.add_child(_preview_panel)
_content_root.add_child(_layout_row)
add_child(_content_root)
func _build_choice_buttons() -> void:
for index: int in mini(_finish_ids.size(), _finish_labels.size()):
var finish_id: StringName = _finish_ids[index]
var finish_label: String = _finish_labels[index]
var button := Button.new()
button.name = "GroundFinish_%s" % String(finish_id).trim_prefix("finish_ground_")
button.toggle_mode = true
button.button_group = _button_group
button.text = ""
button.custom_minimum_size = Vector2(float(_choice_min_width), float(_choice_height))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_ALL
_apply_choice_style(button)
var contents := VBoxContainer.new()
contents.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
contents.add_theme_constant_override("separation", 4)
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sample := GROUND_PREVIEW.new() as StageGroundFinishPreview
sample.name = "FinishSample"
sample.custom_minimum_size.y = float(_choice_preview_height)
sample.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sample.configure(finish_id, DEFAULT_PAINT_TINT)
contents.add_child(sample)
var label := Label.new()
label.name = "FinishLabel"
label.text = finish_label
label.custom_minimum_size.y = 20.0
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", _choice_font_size)
label.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA")))
if _ui_font != null:
label.add_theme_font_override("font", _ui_font)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
contents.add_child(label)
button.add_child(contents)
button.pressed.connect(_on_choice_pressed.bind(finish_id))
_choice_grid.add_child(button)
_choice_buttons[finish_id] = button
_choice_previews[finish_id] = sample
_choice_labels[finish_id] = label
func _build_selected_preview_panel() -> PanelContainer:
var panel := PanelContainer.new()
panel.name = "SelectedGroundPreview"
panel.custom_minimum_size = Vector2(float(_preview_min_width), 0.0)
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_theme_stylebox_override("panel", _preview_panel_style())
var stack := VBoxContainer.new()
stack.alignment = BoxContainer.ALIGNMENT_CENTER
stack.add_theme_constant_override("separation", 10)
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
var heading := Label.new()
heading.name = "GroundPreviewHeading"
heading.text = "New Ground will look like this"
heading.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
heading.add_theme_font_size_override("font_size", 18)
heading.add_theme_color_override("font_color", _theme_color("global.text_color", Color("#F5F7FA")))
if _ui_font != null:
heading.add_theme_font_override("font", _ui_font)
stack.add_child(heading)
_selected_preview = GROUND_PREVIEW.new() as StageGroundFinishPreview
_selected_preview.name = "SelectedGroundFinishSample"
_selected_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_selected_preview.custom_minimum_size.y = float(_preview_height)
_selected_preview.configure(_selected_finish if not _selected_finish.is_empty() else &"finish_ground_grass", DEFAULT_PAINT_TINT)
stack.add_child(_selected_preview)
panel.add_child(stack)
return panel
func _select_finish(finish_id: StringName) -> void:
if not _finish_ids.has(finish_id):
if _finish_ids.is_empty():
return
finish_id = _finish_ids[0]
_selected_finish = finish_id
for key: Variant in _choice_buttons:
var button: Button = _choice_buttons[key] as Button
button.set_pressed_no_signal(StringName(key) == finish_id)
if _selected_preview != null:
_selected_preview.configure(finish_id, DEFAULT_PAINT_TINT)
_update_primary_button_text()
func _update_primary_button_text() -> void:
var label: String = "Ground"
var index: int = _finish_ids.find(_selected_finish)
if index >= 0 and index < _finish_labels.size():
label = _finish_labels[index]
var button: Button = get_ok_button()
if button == null:
return
button.text = "Draw %s Ground" % label
var visual_button: Button = DIALOG_STYLER.visual_button(button)
if visual_button != null and visual_button != button:
visual_button.text = button.text
func _on_choice_pressed(finish_id: StringName) -> void:
_select_finish(finish_id)
func _on_viewport_size_changed() -> void:
call_deferred("_apply_responsive_layout")
call_deferred("_refit_dialog")
func _apply_responsive_layout() -> void:
if _content_root == null or _choice_grid == null or _preview_panel == null or _layout_row == null:
return
var viewport: Viewport = _stage_viewport()
if viewport == null:
return
var viewport_size: Vector2 = viewport.get_visible_rect().size
var viewport_width: float = viewport_size.x
var card_width: float = minf(
float(_theme_int("card_width", 880, 420, 1200)),
maxf(0.0, viewport_width - float(_screen_margin * 2))
)
var body_width: float = maxf(0.0, card_width - 64.0)
var wide: bool = viewport_width >= float(_wide_layout_min_width) and body_width >= float(_preview_min_width + _choice_min_width * 3 + _grid_gap * 4)
var compact: bool = viewport_width < float(_compact_layout_max_width) or viewport_size.y < float(_compact_layout_max_height)
var columns: int = 3 if body_width >= float(_choice_min_width * 3 + _grid_gap * 2) else 2
_choice_grid.columns = columns
_is_wide_layout = wide
_is_compact_layout = compact
var card: Control = get_meta(DIALOG_STYLER.CARD_META) as Control
if card != null:
set_meta(DIALOG_STYLER.MAX_HEIGHT_RATIO_META, _theme_number("compact_height_ratio", 0.89, 0.70, 0.89) if compact else _theme_number("max_height_ratio", 0.86, 0.55, 0.86))
_apply_choice_density(compact)
if wide:
if _choice_grid.get_parent() != _layout_row:
if _choice_grid.get_parent() == _content_root:
_content_root.remove_child(_choice_grid)
if _preview_panel.get_parent() == _content_root:
_content_root.remove_child(_preview_panel)
_layout_row.add_child(_choice_grid)
_layout_row.add_child(_preview_panel)
_content_root.add_child(_layout_row)
_preview_panel.visible = true
_preview_panel.custom_minimum_size.x = float(_preview_min_width)
_selected_preview.custom_minimum_size.y = float(_preview_height)
else:
if _choice_grid.get_parent() != _content_root:
if _choice_grid.get_parent() == _layout_row:
_layout_row.remove_child(_choice_grid)
if _preview_panel.get_parent() == _layout_row:
_layout_row.remove_child(_preview_panel)
if _layout_row.get_parent() == _content_root:
_content_root.remove_child(_layout_row)
_content_root.add_child(_preview_panel)
_content_root.add_child(_choice_grid)
_preview_panel.visible = not compact
_preview_panel.custom_minimum_size.x = 0.0
_selected_preview.custom_minimum_size.y = float(_compact_preview_height if compact else _preview_height)
_preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_choice_grid.add_theme_constant_override("v_separation", _compact_grid_gap if compact else _grid_gap)
_content_root.queue_sort()
func _refit_dialog() -> void:
if DIALOG_STYLER.is_open(self):
DIALOG_STYLER.refit(self)
func _stage_viewport() -> Viewport:
var overlay: Control = get_meta(DIALOG_STYLER.OVERLAY_META, null) as Control
if overlay != null and is_instance_valid(overlay):
return overlay.get_viewport()
return get_viewport()
func _apply_choice_density(compact: bool) -> void:
var target_height: float = float(_compact_choice_height if compact else _choice_height)
var target_preview_height: float = float(_compact_choice_preview_height if compact else _choice_preview_height)
var target_font_size: int = _compact_choice_font_size if compact else _choice_font_size
for finish_id: StringName in _choice_buttons:
var button: Button = _choice_buttons[finish_id] as Button
button.custom_minimum_size.y = target_height
_apply_choice_style(button)
var sample: StageGroundFinishPreview = _choice_previews[finish_id] as StageGroundFinishPreview
if sample != null:
sample.custom_minimum_size.y = target_preview_height
var label: Label = _choice_labels[finish_id] as Label
if label != null:
label.add_theme_font_size_override("font_size", target_font_size)
label.custom_minimum_size.y = 18.0 if compact else 20.0
func _focus_selected_choice() -> void:
if not is_inside_tree() or not DIALOG_STYLER.is_open(self):
return
var button: Button = _choice_buttons.get(_selected_finish) as Button
if button != null and button.visible:
button.grab_focus()
func _apply_choice_style(button: Button) -> void:
var neutral: Color = _theme_color("stage_panels.choice.neutral_surface", Color("#171B24"))
var hover: Color = _theme_color("stage_panels.choice.hover_surface", Color("#303A4B"))
var selected: Color = _theme_color("stage_panels.choice.selected_surface", Color("#12394A"))
var border: Color = _theme_color("stage_panels.choice.border_color", Color("#FFFFFF33"))
var accent: Color = _theme_color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _choice_box(neutral, border, 2))
button.add_theme_stylebox_override("hover", _choice_box(hover, accent, 2))
button.add_theme_stylebox_override("pressed", _choice_box(selected, accent, 3))
button.add_theme_stylebox_override("hover_pressed", _choice_box(selected.lightened(0.06), accent, 3))
button.add_theme_stylebox_override("focus", _choice_box(Color.TRANSPARENT, Color("#F5F7FA"), 3))
func _choice_box(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(_theme_int("choice_corner_radius", 12, 8, 24))
style.content_margin_left = 8.0
style.content_margin_right = 8.0
var vertical_padding: float = 2.0 if _is_compact_layout else 6.0
style.content_margin_top = vertical_padding
style.content_margin_bottom = vertical_padding
return style
func _preview_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = _theme_color("preview_surface", Color("#151C28"))
style.border_color = _theme_color("preview_border", Color("#39465B"))
style.set_border_width_all(1)
style.set_corner_radius_all(_theme_int("preview_corner_radius", 12, 8, 24))
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 12.0
style.content_margin_bottom = 12.0
return style
func _theme_int(key: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer("popups.ground_finish_chooser." + key, fallback, minimum, maximum)
func _theme_number(key: String, fallback: float, minimum: float, maximum: float) -> float:
return fallback if _stage_theme == null else _stage_theme.number("popups.ground_finish_chooser." + key, fallback, minimum, maximum)
func _theme_color(key: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color("popups.ground_finish_chooser." + key, fallback)
@@ -0,0 +1 @@
uid://dfo24vsra2cye
@@ -0,0 +1,30 @@
class_name StageGroundFinishPreview
extends Control
## Render-only Ground finish sample using the same motifs as Stage terrain.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DEFAULT_PAINT_TINT: Color = Color(0.25, 0.55, 0.25, 1.0)
var finish_id: StringName = &"finish_ground_grass"
var paint_tint: Color = DEFAULT_PAINT_TINT
func configure(p_finish_id: StringName, p_paint_tint: Color = DEFAULT_PAINT_TINT) -> void:
finish_id = p_finish_id
paint_tint = p_paint_tint
mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw()
func _draw() -> void:
var preview_top: float = 2.0
var motif_scale := Vector2.ONE
if finish_id == &"finish_ground_grass":
preview_top = minf(12.0, maxf(2.0, size.y * 0.48))
motif_scale.y = maxf(1.0, 12.0 / maxf(1.0, preview_top - 1.0))
var rect := Rect2(Vector2(2.0, preview_top), Vector2(size.x - 4.0, size.y - preview_top - 2.0))
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
return
SURFACE_RENDERER.draw_ground(self, rect, finish_id, paint_tint, Vector2.ZERO, motif_scale)
draw_rect(rect, Color("#111821"), false, 2.0)
@@ -0,0 +1 @@
uid://cgh4be2rdultd
+73 -29
View File
@@ -150,8 +150,13 @@ func open(
entries: Array[Dictionary],
current_sky_id: String = "",
current_background_kind: String = "sky",
plain_color: Color = Color("#F5EFE0")
plain_color: Color = Color("#F5EFE0"),
browse_state: Dictionary = {}
) -> void:
var restore_scenery_browse_state: bool = p_kind == "scenery" and not browse_state.is_empty()
var restored_gallery_tab: String = String(browse_state.get("gallery_tab", "backgrounds")) if restore_scenery_browse_state else "backgrounds"
var restored_filter_id: String = String(browse_state.get("tag_id", "all")) if restore_scenery_browse_state else "all"
var restored_scroll: int = int(browse_state.get("scroll_vertical", 0)) if restore_scenery_browse_state else 0
_cancel_plain_picker_preview(false)
kind = p_kind
_setup_kind = &""
@@ -164,28 +169,62 @@ func open(
_current_sky_id = current_sky_id if kind == "scenery" else ""
_current_background_kind = &"plain" if current_background_kind == "plain" else &"sky"
_current_plain_color = _opaque_color(plain_color)
_title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
_title_label.text = "Choose a Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
_backgrounds_back_button.visible = false
_apply_chooser_visual_style()
_tabs.visible = kind == "scenery"
_filter_chips.visible = false
_make_artwork_button.visible = false
_edit_scenery_button.visible = false
_browse_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
_refresh_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props found. Browse for a .prop file." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen found."))
_gallery_tab = "backgrounds"
_tag_id = "all"
_backgrounds_tab.set_pressed_no_signal(true)
_artwork_tab.set_pressed_no_signal(false)
_browse_button.visible = false
_refresh_button.visible = false
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props in the library yet." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen in the library yet."))
_gallery_tab = restored_gallery_tab
_tag_id = restored_filter_id
_backgrounds_tab.set_pressed_no_signal(_gallery_tab == "backgrounds")
_artwork_tab.set_pressed_no_signal(_gallery_tab == "artwork")
set_entries(entries)
if kind == "scenery":
_set_gallery_tab("backgrounds")
_set_gallery_tab(_gallery_tab)
popup_centered(size)
call_deferred("_settle_popup_after_open")
if restore_scenery_browse_state:
call_deferred("_restore_browse_scroll_after_open", restored_scroll, restored_gallery_tab)
func capture_browse_state() -> Dictionary:
if kind != "scenery":
return {}
return {
"gallery_tab": _gallery_tab,
"tag_id": _tag_id,
"scroll_vertical": _grid_scroll.scroll_vertical if _grid_scroll != null else 0,
}
func _restore_browse_scroll_after_open(scroll_vertical: int, gallery_tab: String) -> void:
await get_tree().process_frame
if not is_inside_tree() or not visible or kind != "scenery" or _gallery_tab != gallery_tab:
return
_grid_scroll.scroll_vertical = maxi(0, scroll_vertical)
func _settle_popup_after_open() -> void:
await get_tree().process_frame
await get_tree().process_frame
if is_inside_tree() and visible:
# PopupPanel can resize after the first native layout pass. Reapply the
# content-sized width once child minimum sizes and DPI scaling have settled.
var size_before_settle: Vector2i = size
_update_popup_layout(true)
if size != size_before_settle:
popup_centered(size)
func set_entries(entries: Array[Dictionary]) -> void:
_entries = entries
if _grid_scroll != null:
_grid_scroll.scroll_vertical = 0
if kind == "scenery":
_rebuild_artwork_filter_chips()
_thumbnails.clear()
@@ -881,8 +920,8 @@ func _set_gallery_tab(tab: String) -> void:
_filter_chips.visible = kind == "scenery" and tab == "artwork"
_make_artwork_button.visible = kind == "scenery" and tab == "artwork"
_edit_scenery_button.visible = kind == "scenery"
_browse_button.visible = kind == "stickman" or kind == "prop"
_refresh_button.visible = kind == "stickman" or kind == "prop"
_browse_button.visible = false
_refresh_button.visible = false
_empty_label.text = "No artwork here yet." if tab == "artwork" else "Choose a Sky look or wall finish."
_page = 0
_update_popup_layout(true)
@@ -953,6 +992,7 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
var in_subview: bool = in_setup or in_color_picker or in_plain_choices
var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_subview
var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_subview
var asset_scrolling_gallery: bool = _uses_scrolling_asset_gallery() and not in_subview
var filtered: Array[Dictionary] = _filtered_entries()
_tabs.visible = kind == "scenery" and not in_subview
_filter_chips.visible = scenery_artwork_gallery
@@ -972,17 +1012,17 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
_empty_label.visible = filtered.is_empty() and not in_subview
_grid.visible = not filtered.is_empty() and not in_subview
_grid_scroll.visible = not filtered.is_empty() or in_subview
_footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery or in_subview else BoxContainer.ALIGNMENT_CENTER
_footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery or asset_scrolling_gallery or in_subview else BoxContainer.ALIGNMENT_CENTER
_footer.add_theme_constant_override(
"separation",
_theme_integer("popups.asset_selector.footer_gap", 16, 8, 32) if in_subview or kind == "scenery" else 8
)
var available: Vector2 = _usable_screen_size()
var width_ratio: float = 0.98 if in_subview else (0.96 if kind == "starters" else 0.94)
var width_ratio: float = 0.98 if in_subview or asset_scrolling_gallery else (0.96 if kind == "starters" else 0.94)
var max_width: float = maxf(1.0, minf(available.x * width_ratio, available.x - POPUP_SAFE_SCREEN_MARGIN))
var height_ratio: float = _theme_number("popups.asset_selector.starter_viewport_height_ratio", 0.96, 0.7, 0.98) if kind == "starters" else 0.86
var max_height: float = maxf(1.0, minf(available.y * height_ratio, available.y - POPUP_SAFE_SCREEN_MARGIN * 2.0))
var preferred_width: float = CELL_MIN_SIZE.x * (2 if kind == "starters" else COLUMNS) + GRID_H_SEPARATION * ((2 if kind == "starters" else COLUMNS) - 1) + POPUP_SIDE_PADDING
var preferred_width: float = _cell_min_size.x * (2 if kind == "starters" else COLUMNS) + GRID_H_SEPARATION * ((2 if kind == "starters" else COLUMNS) - 1) + POPUP_SIDE_PADDING
var preferred_height: float = max_height
if kind == "starters":
preferred_width = _theme_number("popups.asset_selector.starter_dialog_width", 1160.0, 440.0, 2400.0)
@@ -993,14 +1033,14 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
var popup_width: float = minf(preferred_width, max_width)
_position_footer(popup_width)
var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING)
var minimum_card_width: float = CELL_MIN_SIZE.x
var minimum_card_width: float = _cell_min_size.x
if kind == "starters":
minimum_card_width = _theme_number("popups.asset_selector.starter_card_min_width", 340.0, 220.0, 800.0)
_active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (minimum_card_width + GRID_H_SEPARATION)), 1, 2 if kind == "starters" else COLUMNS)
_page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery or kind == "starters" else _active_columns * ROWS
_page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery or asset_scrolling_gallery or kind == "starters" else _active_columns * ROWS
_grid.columns = _active_columns
var available_card_width: float = (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns)
var card_width: float = available_card_width if kind == "starters" else minf(CELL_MIN_SIZE.x, available_card_width)
var card_width: float = available_card_width if kind == "starters" else minf(_cell_min_size.x, available_card_width)
var card_height_limit: float = 248.0 if kind != "starters" else INF
var card_height: float
if kind == "starters":
@@ -1128,6 +1168,7 @@ func _rebuild() -> void:
var in_subview: bool = in_setup or in_color_picker or in_plain_choices
var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_subview
var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_subview
var asset_scrolling_gallery: bool = _uses_scrolling_asset_gallery() and not in_subview
_position_footer(float(size.x))
_sync_stage_preview_dimming()
_close_button.visible = not in_setup and not in_color_picker
@@ -1144,8 +1185,8 @@ func _rebuild() -> void:
_color_use_button.visible = in_color_picker
_edit_scenery_button.visible = kind == "scenery" and not in_subview
_make_artwork_button.visible = scenery_artwork_gallery
_browse_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker
_refresh_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker
_browse_button.visible = false
_refresh_button.visible = false
_footer_spacer.visible = scenery_artwork_gallery
_empty_label.visible = filtered.is_empty() and not in_subview
_grid.visible = not filtered.is_empty() and not in_subview
@@ -1255,6 +1296,10 @@ func _is_scenery_artwork_gallery() -> bool:
return kind == "scenery" and _gallery_tab == "artwork"
func _uses_scrolling_asset_gallery() -> bool:
return kind == "prop" or kind == "stickman"
func _count_scene_background_entries(entries: Array[Dictionary]) -> int:
var count: int = 0
for entry: Dictionary in entries:
@@ -1324,7 +1369,7 @@ func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> C
card_payload["status"] = "Current background"
card_payload["status_color"] = _theme_color("mode_colors.edit", Color("#22C6FF"))
cell.configure(card_payload, _thumbnails.get(key, null), null)
if kind == "scenery" or kind == "starters":
if kind == "scenery" or kind == "starters" or _uses_scrolling_asset_gallery():
var card_button: Button = cell as Button
var card_border_width: int = _theme_integer("popups.asset_selector.button_border_width", 2, 1, 5)
var card_corner_radius: int = _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32)
@@ -1351,13 +1396,12 @@ func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> C
card_hover_border_width,
card_corner_radius
))
if kind == "starters":
card_button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(
card_selected_surface.lightened(0.05),
_theme_color("mode_colors.edit", Color("#22C6FF")),
card_hover_border_width,
card_corner_radius
))
card_button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(
card_selected_surface.lightened(0.05),
_theme_color("mode_colors.edit", Color("#22C6FF")),
card_hover_border_width,
card_corner_radius
))
var focus_color: Color = _theme_color("stage_panels.type.text_color", Color("#F5F7FA")) if kind == "starters" else _theme_color("mode_colors.edit", Color("#22C6FF"))
var focus_style: StyleBoxFlat = _make_rounded_style(
Color(0, 0, 0, 0),
@@ -1447,7 +1491,7 @@ func _apply_chooser_visual_style() -> void:
frame_style.set_corner_radius_all(_theme_integer("popups.asset_selector.surface_corner_radius", 14, 4, 32))
preview_frame.add_theme_stylebox_override("panel", frame_style)
if _title_label != null:
var title_size: int = _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48) if kind == "scenery" else (_theme_integer("popups.asset_selector.starter_title_size", 30, 22, 48) if kind == "starters" else 24)
var title_size: int = _theme_integer("popups.asset_selector.starter_title_size", 30, 22, 48) if kind == "starters" else _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48)
_title_label.add_theme_font_size_override("font_size", title_size)
_title_label.add_theme_color_override("font_color", text_color)
_apply_emphasis_font(_title_label)
+30
View File
@@ -125,6 +125,36 @@ func _apply_phase19_defaults() -> void:
if not asset_selector.has(key):
asset_selector[key] = starter_defaults[key]
popups["asset_selector"] = asset_selector
var ground_finish_defaults: Dictionary = {
"card_width": 880,
"max_height_ratio": 0.86,
"screen_margin": 24,
"choice_height": 96,
"choice_preview_height": 48,
"choice_font_size": 18,
"choice_min_width": 104,
"choice_corner_radius": 12,
"compact_choice_height": 50,
"compact_choice_preview_height": 22,
"compact_choice_font_size": 16,
"compact_grid_gap": 6,
"compact_layout_max_width": 600,
"compact_layout_max_height": 460,
"compact_height_ratio": 0.89,
"preview_height": 128,
"compact_preview_height": 88,
"preview_min_width": 224,
"preview_corner_radius": 12,
"preview_surface": "#151C28",
"preview_border": "#39465B",
"grid_gap": 8,
"wide_layout_min_width": 760,
}
var ground_finish: Dictionary = popups.get("ground_finish_chooser", {}).duplicate(true)
for key: String in ground_finish_defaults:
if not ground_finish.has(key):
ground_finish[key] = ground_finish_defaults[key]
popups["ground_finish_chooser"] = ground_finish
_config["popups"] = popups
var overlays: Dictionary = _config.get("overlays", {}).duplicate(true)
var gizmos: Dictionary = overlays.get("gizmos", {}).duplicate(true)
+209
View File
@@ -0,0 +1,209 @@
extends SceneTree
const EDITOR_VISUAL_THEME := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const EDITOR_CONTEXT_MENU := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
const STICKMAN_SCENE := preload("res://scenes/main.tscn")
var _checks: int = 0
var _failures: int = 0
var _last_pressed_id: int = -1
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 800)
root.gui_embed_subwindows = true
var visual_theme: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
check("shared editor theme loads", visual_theme.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH))
var menu: EditorContextMenu = EDITOR_CONTEXT_MENU.new()
root.add_child(menu)
menu.configure(visual_theme, &"prop")
menu.add_item("Copy Shape", 1)
menu.add_separator()
menu.add_destructive_item("Erase Shape", 2)
menu.id_pressed.connect(_on_menu_item_pressed)
var copy_button: Button = menu.get_row_button(1)
var erase_button: Button = menu.get_row_button(2)
check("normal and destructive rows are created", copy_button != null and erase_button != null)
check("rows meet the Studio List target height", copy_button != null and copy_button.custom_minimum_size.y == 48.0)
check("menu rows do not show a keyboard selector on popup", copy_button != null and copy_button.focus_mode == Control.FOCUS_NONE and not copy_button.has_focus())
check("destructive label uses semantic danger color", erase_button != null and erase_button.get_theme_color("font_color").is_equal_approx(visual_theme.chrome_color(&"destructive", &"prop")))
var panel_style: StyleBoxFlat = menu.get_theme_stylebox("panel") as StyleBoxFlat
var hover_style: StyleBoxFlat = copy_button.get_theme_stylebox("hover") as StyleBoxFlat
var focus_style: StyleBoxFlat = copy_button.get_theme_stylebox("focus") as StyleBoxFlat
check("popup shell uses shared raised surface", panel_style != null and panel_style.bg_color.is_equal_approx(visual_theme.chrome_color(&"surface", &"prop")))
check("hover row uses a cyan side cue", hover_style != null and hover_style.border_width_left == 3 and hover_style.border_color.is_equal_approx(visual_theme.chrome_color(&"edit", &"prop")))
check("keyboard focus has a distinct focus outline", focus_style != null and focus_style.border_width_left == 2 and focus_style.border_color.is_equal_approx(visual_theme.chrome_color(&"focus", &"prop")))
root.size = Vector2i(900, 600)
menu.popup_at(Vector2(root.size) - Vector2(4.0, 4.0))
await process_frame
check("popup opens at the requested edge", menu.visible)
check("popup stays inside the viewport", menu.position.x >= 0 and menu.position.y >= 0 and menu.position.x + menu.size.x <= root.size.x and menu.position.y + menu.size.y <= root.size.y)
var down_event := InputEventKey.new()
down_event.keycode = KEY_DOWN
down_event.pressed = true
menu.call("_input", down_event)
await process_frame
check("Down reveals focus on the first row", copy_button.has_focus())
var up_event := InputEventKey.new()
up_event.keycode = KEY_UP
up_event.pressed = true
menu.call("_input", up_event)
await process_frame
check("Up moves focus to the last row", erase_button.has_focus())
menu.call("_on_row_mouse_entered", copy_button)
await process_frame
check("pointer entry hides the keyboard selector", not copy_button.has_focus() and not erase_button.has_focus() and copy_button.focus_mode == Control.FOCUS_NONE)
menu.hide()
var avoid_rect := Rect2(340.0, 240.0, 220.0, 120.0)
menu.popup_at(avoid_rect.get_center(), avoid_rect)
await process_frame
check("popup keeps the clicked target unobstructed when room is available", not Rect2(Vector2(menu.position), Vector2(menu.size)).intersects(avoid_rect))
erase_button.emit_signal("pressed")
await process_frame
check("activating an item emits its id and closes the popup", _last_pressed_id == 2 and not menu.visible)
menu.queue_free()
await process_frame
root.size = Vector2i(1280, 800)
await _test_prop_context_menu()
await _test_stickman_context_menus()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_prop_context_menu() -> void:
var editor: Control = PROP_SCENE.instantiate() as Control
root.add_child(editor)
await process_frame
await process_frame
_dismiss_fixture_recovery_prompt(editor)
var canvas: PropCanvas = editor.get("_canvas") as PropCanvas
canvas.add_primitive("box", Vector2.ZERO)
var shape: Dictionary = (canvas.get("shapes") as Array)[0] as Dictionary
var bounds: Rect2 = canvas.call("_shape_bbox", shape) as Rect2
var center: Vector2 = bounds.get_center()
var zoom: float = float(canvas.get("_zoom"))
var pan: Vector2 = canvas.get("_pan_offset") as Vector2
var click := InputEventMouseButton.new()
click.global_position = canvas.global_position + center * zoom + pan
canvas.call("_handle_transform_right_click", click, center)
await process_frame
var menu: EditorContextMenu = canvas.get("_context_menu") as EditorContextMenu
check("Prop opens the shared Studio List menu", menu.visible)
check("Prop uses child-readable shape action labels", menu.get_row_button(3) != null and menu.get_row_button(3).text == "Copy Shape" and menu.get_row_button(5).text == "Erase Shape")
check("Prop destructive action uses danger color", menu.get_row_button(5) != null and menu.get_row_button(5).get_theme_color("font_color").is_equal_approx((editor.get("_editor_visual_theme") as EditorVisualTheme).chrome_color(&"destructive", &"prop")))
check("Prop menu remains inside the viewport", menu.position.x >= 0 and menu.position.y >= 0 and menu.position.x + menu.size.x <= root.size.x and menu.position.y + menu.size.y <= root.size.y)
var prop_target: Rect2 = canvas.call("_global_shape_target_rect", 0) as Rect2
check("Prop menu leaves the clicked shape visible", not Rect2(Vector2(menu.position), Vector2(menu.size)).intersects(prop_target))
check("Prop menu avoids an unnecessary scrollbar", not (menu.get_node("MenuShell/MenuScroll") as ScrollContainer).get_v_scroll_bar().visible)
await _save_capture("context_menu_prop.png")
menu.hide()
editor.queue_free()
await process_frame
func _test_stickman_context_menus() -> void:
var main_scene: Control = STICKMAN_SCENE.instantiate() as Control
root.add_child(main_scene)
await process_frame
await process_frame
var editor: Control = main_scene.find_child("StickmanEditor", true, false) as Control
var panels: Dictionary = editor.get("_body_part_panels") as Dictionary
var head: BodyPartPanel = panels.get("head") as BodyPartPanel
head.add_shape(&"rectangle", head.call("drawing_center_world"))
var shape: Dictionary = (head.get("shapes") as Array)[0] as Dictionary
var points: PackedVector2Array = shape.get("points", PackedVector2Array()) as PackedVector2Array
var center := Vector2.ZERO
for point: Vector2 in points:
center += point
if not points.is_empty():
center /= float(points.size())
var click := InputEventMouseButton.new()
var drawing_area: Control = head.get("drawing_area") as Control
var head_zoom: float = float(head.get("_zoom"))
var head_pan: Vector2 = head.get("_pan_offset") as Vector2
click.global_position = drawing_area.global_position + head_pan + center * head_zoom
head.call("_handle_right_click", click, center)
await process_frame
var part_menu: EditorContextMenu = head.get("context_menu") as EditorContextMenu
check("Stickman body-part panels open the shared Studio List menu", part_menu.visible)
check("Stickman shape menu uses the shared shape wording", part_menu.get_row_button(9) != null and part_menu.get_row_button(9).text == "Copy Shape" and part_menu.get_row_button(5).text == "Erase Shape")
check("Stickman Erase Shape is coral", part_menu.get_row_button(5) != null and part_menu.get_row_button(5).get_theme_color("font_color").is_equal_approx((editor.get("_editor_visual_theme") as EditorVisualTheme).chrome_color(&"destructive", &"stickman")))
var part_target: Rect2 = head.call("_global_shape_target_rect", shape) as Rect2
check("Stickman part menu leaves the clicked shape visible", not Rect2(Vector2(part_menu.position), Vector2(part_menu.size)).intersects(part_target))
check("Stickman part menu avoids an unnecessary scrollbar", not (part_menu.get_node("MenuShell/MenuScroll") as ScrollContainer).get_v_scroll_bar().visible)
await _save_capture("context_menu_stickman_part.png")
part_menu.hide()
var preview: WholeStickmanPreview = editor.find_child("WholeStickmanPreview", true, false) as WholeStickmanPreview
var preview_area: Control = preview.get("preview_area") as Control
await process_frame
var part_bounds: Dictionary = preview.get("_part_bounds") as Dictionary
var head_bounds: Rect2 = part_bounds.get("head", Rect2()) as Rect2
var preview_zoom: float = float(preview.get("_zoom"))
var preview_pan: Vector2 = preview.get("_pan_offset") as Vector2
var preview_click := InputEventMouseButton.new()
preview_click.position = head_bounds.get_center() * preview_zoom + preview_pan
preview_click.global_position = preview_area.global_position + preview_click.position
preview.call("_handle_right_click", preview_click)
await process_frame
var preview_menu: EditorContextMenu = preview.get("_context_menu") as EditorContextMenu
check("Whole Stickman preview opens the shared Studio List menu", preview_menu.visible)
check("Whole Stickman uses child-readable flip labels", preview_menu.get_row_button(2) != null and preview_menu.get_row_button(2).text == "Flip Left-Right" and preview_menu.get_row_button(3).text == "Flip Up-Down")
var preview_target := Rect2(preview_area.global_position + preview_pan + head_bounds.position * preview_zoom, head_bounds.size * preview_zoom).grow(4.0)
check("Whole Stickman menu leaves the clicked part visible", not Rect2(Vector2(preview_menu.position), Vector2(preview_menu.size)).intersects(preview_target))
await _save_capture("context_menu_stickman_preview.png")
preview_menu.hide()
main_scene.queue_free()
await process_frame
func _save_capture(file_name: String) -> void:
if DisplayServer.get_name() == "headless":
print("SKIP visual capture under headless renderer: %s" % file_name)
return
var viewport_texture: ViewportTexture = root.get_texture()
if viewport_texture == null:
check("visual capture texture exists: %s" % file_name, false)
return
var image: Image = viewport_texture.get_image()
if image == null or image.is_empty():
check("visual capture image is available: %s" % file_name, false)
return
var path: String = ProjectSettings.globalize_path("res://.godot/" + file_name)
var error: Error = image.save_png(path)
check("visual capture saved: %s" % file_name, error == OK and not image.is_empty())
func _dismiss_fixture_recovery_prompt(editor: Control) -> void:
# A real user autosave may be present in user://. Hide the prompt for clean
# visual captures without choosing Restore or Discard or touching that file.
for child: Node in editor.get_children():
if child is ConfirmationDialog and (child as ConfirmationDialog).title == "Restore Prop?":
STAGE_DIALOG_STYLER.hide(child as ConfirmationDialog)
func _on_menu_item_pressed(id: int) -> void:
_last_pressed_id = id
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
+1
View File
@@ -0,0 +1 @@
uid://crigocq28ory
+295
View File
@@ -0,0 +1,295 @@
extends SceneTree
const STICKMAN_SCENE := preload("res://scenes/main.tscn")
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== EDITOR VISUAL HARMONIZATION TEST ===")
root.size = Vector2i(1280, 800)
await _test_stickman_workspace_and_transactions()
await _test_prop_reshape_and_chrome()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
func _test_stickman_workspace_and_transactions() -> void:
var main_scene: Control = STICKMAN_SCENE.instantiate() as Control
root.add_child(main_scene)
await process_frame
await process_frame
var editor: Control = main_scene.find_child("StickmanEditor", true, false) as Control
var panels: Dictionary = editor.get("_body_part_panels") as Dictionary
var preview: Control = editor.find_child("WholeStickmanPreview", true, false) as Control
var menu_bar: Control = editor.get_node("ChromeRoot/MenuBar") as Control
var top_bar: Control = editor.get_node("ChromeRoot/TopBar") as Control
var workspace_toolbar: Control = editor.get_node("ChromeRoot/WorkspaceToolbar") as Control
var workspace_row: Control = editor.get_node("ChromeRoot/WorkspaceRow") as Control
var status_bar: Control = editor.get_node("ChromeRoot/StatusBar") as Control
var stickman_save_status: Label = editor.get_node("ChromeRoot/StatusBar/SaveStatus") as Label
check("Stickman keeps ten live BodyPartPanel instances", panels.size() == 10)
check("one permanent Whole Stickman preview exists", preview != null)
check("wide layout adapts to Overview", int(editor.call("get_workspace_view")) == 0)
check("Stickman chrome rows and workspace do not overlap", top_bar.position.y >= menu_bar.position.y + menu_bar.size.y - 1.0 and workspace_toolbar.position.y >= top_bar.position.y + top_bar.size.y - 1.0 and workspace_row.position.y >= workspace_toolbar.position.y + workspace_toolbar.size.y - 1.0 and status_bar.position.y >= workspace_row.position.y + workspace_row.size.y - 1.0)
check("Overview body panels begin below the chrome", (panels.get("head") as Control).get_global_rect().position.y >= workspace_row.get_global_rect().position.y - 1.0)
check("clean new Stickman shows explicit unsaved status", stickman_save_status.text == "Not saved yet")
var head: Control = panels.get("head") as Control
var head_id: int = head.get_instance_id()
check("head part supports authored shapes", bool(head.call("add_shape", &"rectangle", head.call("drawing_center_world"))))
check("shape creation marks the document dirty", bool(editor.call("has_unsaved_changes")))
check("Stickman dirty transition shows explicit status", stickman_save_status.text == "Unsaved changes")
check("new shape is selected", int(head.call("selected_shape_index")) == 0)
var zoom: InputEventMouseButton = InputEventMouseButton.new()
zoom.button_index = MOUSE_BUTTON_WHEEL_UP
zoom.pressed = true
zoom.position = Vector2(100.0, 80.0)
head.call("_on_drawing_area_gui_input", zoom)
var pan_down: InputEventMouseButton = InputEventMouseButton.new()
pan_down.button_index = MOUSE_BUTTON_MIDDLE
pan_down.pressed = true
pan_down.position = Vector2(100.0, 80.0)
head.call("_on_drawing_area_gui_input", pan_down)
var pan_motion: InputEventMouseMotion = InputEventMouseMotion.new()
pan_motion.position = Vector2(112.0, 91.0)
head.call("_on_drawing_area_gui_input", pan_motion)
var pan_up: InputEventMouseButton = InputEventMouseButton.new()
pan_up.button_index = MOUSE_BUTTON_MIDDLE
pan_up.pressed = false
pan_up.position = Vector2(112.0, 91.0)
head.call("_on_drawing_area_gui_input", pan_up)
var saved_view: Dictionary = head.call("get_view_context") as Dictionary
var saved_pan: Vector2 = saved_view.get("pan_offset", Vector2.ZERO) as Vector2
check("panel zoom and pan are active before moving it", float(saved_view.get("zoom", 1.0)) > 1.0 and saved_pan.length() > 0.0)
var stickman_path: String = "user://editor_visual_harmonization_test.stk"
check("Stickman saves through its document API", bool(editor.call("save_to_path", stickman_path)))
check("successful save clears dirty state", not bool(editor.call("has_unsaved_changes")))
check("saved Stickman shows explicit saved status", stickman_save_status.text == "Saved")
var stickman_file: FileAccess = FileAccess.open(stickman_path, FileAccess.READ)
var stickman_json: Variant = JSON.parse_string(stickman_file.get_as_text())
stickman_file.close()
var saved_parts: Dictionary = (stickman_json as Dictionary).get("body_parts", {}) as Dictionary
var saved_head: Dictionary = saved_parts.get("head", {}) as Dictionary
check("save preserves the existing 1.5 format", str((stickman_json as Dictionary).get("version", "")) == "1.5")
check("save includes authored head shapes", (saved_head.get("shapes", []) as Array).size() == 1)
check("save still writes guide offset metadata", saved_head.has("guide_offset"))
var before_switch_view: Dictionary = head.call("get_view_context") as Dictionary
editor.call("request_workspace_view", 1, true)
check("Focus view keeps the same panel instance", head.get_instance_id() == head_id and int(editor.call("get_workspace_view")) == 1)
check("Focus view preserves panel zoom and pan", _view_contexts_equal(before_switch_view, head.call("get_view_context") as Dictionary))
check("view choice does not dirty authored content", not bool(editor.call("has_unsaved_changes")))
root.size = Vector2i(520, 800)
await process_frame
editor.call("_refresh_responsive_workspace")
check("manual Focus choice survives a narrow resize", int(editor.call("get_workspace_view")) == 1)
var whole_stickman_button: Button = editor.get_node("ChromeRoot/WorkspaceToolbar/WholeStickmanButton") as Button
var back_to_part_button: Button = editor.get_node("ChromeRoot/WorkspaceToolbar/BackToPartButton") as Button
var body_part_workspace: Control = editor.get_node("ChromeRoot/WorkspaceRow/BodyPartWorkspace") as Control
check("narrow Focus keeps part controls and Whole Stickman access visible", body_part_workspace.visible and whole_stickman_button.visible and not preview.visible)
whole_stickman_button.emit_signal("pressed")
await process_frame
check("narrow Whole Stickman control opens the permanent preview", not body_part_workspace.visible and preview.visible and back_to_part_button.visible and int(editor.call("get_workspace_view")) == 1)
back_to_part_button.emit_signal("pressed")
await process_frame
check("Back to Part restores the same live panel at narrow width", body_part_workspace.visible and not preview.visible and whole_stickman_button.visible and head.get_instance_id() == head_id)
root.size = Vector2i(1280, 800)
await process_frame
editor.call("_refresh_responsive_workspace")
editor.call("request_active_part", "torso")
check("part picker switches the active focus panel", str(editor.call("get_active_part_name")) == "torso")
editor.call("request_active_part", "head")
check("switching back restores the original head instance", panels.get("head") == head and head.get_instance_id() == head_id)
check("switching parts preserves head geometry and selection", int(head.call("selected_shape_index")) == 0 and (head.get("shapes") as Array).size() == 1)
editor.call("request_workspace_view", 0, true)
check("Overview restores the same authored panel", int(editor.call("get_workspace_view")) == 0 and head.get_instance_id() == head_id)
head.call("open_selected_color_picker")
var picker: PopupPanel = head.get("color_picker_popup") as PopupPanel
check("open color popup blocks presentation switching", picker.visible and not bool(head.call("can_switch_presentation")))
editor.call("request_active_part", "torso")
editor.call("request_workspace_view", 1, true)
check("deferred view switch waits while popup is open", int(editor.call("get_workspace_view")) == 0 and str(editor.call("get_active_part_name")) == "head")
picker.hide()
await process_frame
await process_frame
check("deferred part and view switch resumes after popup closes", int(editor.call("get_workspace_view")) == 1 and str(editor.call("get_active_part_name")) == "torso")
check("popup and workspace changes remain clean", not bool(editor.call("has_unsaved_changes")))
editor.call("request_active_part", "head")
var preview_area: Control = preview.get("preview_area") as Control
await process_frame
var raw_points: PackedVector2Array = preview.call("_collect_all_raw_points", "head") as PackedVector2Array
var part_center: Vector2 = preview.call("_compute_center", raw_points) as Vector2
var initial_part_position: Vector2 = preview.call("get_part_position", "head") as Vector2
var mouse_down: InputEventMouseButton = InputEventMouseButton.new()
mouse_down.button_index = MOUSE_BUTTON_LEFT
mouse_down.pressed = true
mouse_down.position = part_center
preview.call("_on_preview_gui_input", mouse_down)
var drag: InputEventMouseMotion = InputEventMouseMotion.new()
drag.position = part_center + Vector2(16.0, 12.0)
preview.call("_on_preview_gui_input", drag)
var mouse_up: InputEventMouseButton = InputEventMouseButton.new()
mouse_up.button_index = MOUSE_BUTTON_LEFT
mouse_up.pressed = false
mouse_up.position = drag.position
preview.call("_on_preview_gui_input", mouse_up)
check("preview drag commits an authored part transform", not (preview.call("get_part_position", "head") as Vector2).is_equal_approx(initial_part_position))
check("committed preview transform marks document dirty", bool(editor.call("has_unsaved_changes")))
check("preview input uses the live preview area", preview_area != null and preview_area.size.x > 0.0)
check("save after transform keeps the existing format", bool(editor.call("save_to_path", stickman_path)) and not bool(editor.call("has_unsaved_changes")))
preview.call("_mirror_part_x")
check("mirror transform marks document dirty", bool(editor.call("has_unsaved_changes")) and (preview.call("get_part_scale", "head") as Vector2).x < 0.0)
check("mirror transform can be saved", bool(editor.call("save_to_path", stickman_path)) and not bool(editor.call("has_unsaved_changes")))
preview.call("_bring_forward_part")
check("part order edit marks document dirty", bool(editor.call("has_unsaved_changes")))
check("second shape can be authored", bool(head.call("add_shape", &"line", head.call("drawing_center_world"))))
editor.call("_on_file_menu_id_pressed", 2)
var unsaved_dialog: ConfirmationDialog = editor.get("_unsaved_dialog") as ConfirmationDialog
check("Start Over shows a save/discard/cancel transaction", STAGE_DIALOG_STYLER.is_open(unsaved_dialog))
unsaved_dialog.get_cancel_button().emit_signal("pressed")
await process_frame
check("Cancel leaves dirty content in place", bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).size() == 2)
editor.call("_on_file_menu_id_pressed", 2)
unsaved_dialog.emit_signal("custom_action", "discard")
await process_frame
check("Discard clears authored parts and dirty state", not bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).is_empty())
check("clean new Stickman returns to explicit unsaved status", stickman_save_status.text == "Not saved yet")
check("Start Over keeps the selected workspace view", int(editor.call("get_workspace_view")) == 1)
DirAccess.remove_absolute(stickman_path)
main_scene.queue_free()
await process_frame
func _test_prop_reshape_and_chrome() -> void:
var editor: Control = PROP_SCENE.instantiate() as Control
root.add_child(editor)
await process_frame
await process_frame
var canvas: Control = editor.get("_canvas") as Control
var save_status: Label = editor.get("_save_status") as Label
check("Prop chrome uses wrapping top and mode rows", editor.get("_top_right") is FlowContainer and editor.get("_mode_bar") is FlowContainer)
check("Prop action target meets 44 px minimum", (editor.get("_create_point_button") as Button).custom_minimum_size.y >= 44.0)
check("new Prop has an explicit clean save status", str(save_status.text).ends_with("Not saved yet"))
check("whole-prop action uses approved discoverable wording", str((editor.get("_whole_prop_resize_button") as Button).text) == "Move, Turn, or Resize Prop")
check("material physics tile uses child-facing grip language", str((editor.get("_friction_label") as Label).text) == "Surface grip")
var preset_option: PropInspectorDropdown = editor.get("_preset_option") as PropInspectorDropdown
check("material choices show colored swatches in field and popup", preset_option.item_count == 5 and preset_option.get_item_icon(0) != null and preset_option.get_item_icon(4) != null and preset_option.icon != null)
var material_selected_row: StyleBoxFlat = preset_option.get_popup().get_theme_stylebox("hover") as StyleBoxFlat
var material_items_plain: bool = true
for index: int in preset_option.item_count:
material_items_plain = material_items_plain and not preset_option.get_popup().is_item_checkable(index)
check("material popup uses the full-row selection highlight without radio items", material_items_plain and material_selected_row.bg_color.is_equal_approx(Color("#103B4D")) and material_selected_row.border_color.is_equal_approx(Color("#22C6FF")))
await process_frame
preset_option.show_popup()
await process_frame
check("opening the material popup focuses its current value and spans the field", preset_option.get_popup().get_focused_item() == preset_option.selected and preset_option.get_popup().size.x >= preset_option.size.x)
check("dropdown popup icons reserve a 44 px row target", preset_option.get_popup().get_item_icon(0).get_width() >= 44)
preset_option.hide_popup()
preset_option.get_popup().emit_signal("id_pressed", 1)
check("choosing a material updates the field and editor model", preset_option.selected == 1 and int(editor.get("_material_preset")) == 1)
preset_option.get_popup().emit_signal("id_pressed", 0)
var primitive_option: PropInspectorDropdown = editor.get("_primitive_option") as PropInspectorDropdown
check("2D shape choices show flat icons in field and popup", primitive_option.item_count == 5 and primitive_option.get_item_icon(0) != null and primitive_option.get_item_icon(4) != null and primitive_option.icon != null)
var shape_items_plain: bool = true
for index: int in primitive_option.item_count:
shape_items_plain = shape_items_plain and not primitive_option.get_popup().is_item_checkable(index)
check("shape popup does not draw radio selectors", shape_items_plain)
primitive_option.show_popup()
await process_frame
check("shape popup highlights its current 2D choice", primitive_option.get_popup().get_focused_item() == primitive_option.selected)
primitive_option.hide_popup()
primitive_option.get_popup().emit_signal("id_pressed", 2)
check("choosing a 2D shape updates its parameters", primitive_option.selected == 2 and (editor.get("_param_spins") as Dictionary).has("base"))
primitive_option.get_popup().emit_signal("id_pressed", 0)
var tag_buttons: Dictionary = editor.get("_tag_checks") as Dictionary
var nature_button: Button = tag_buttons.get("nature") as Button
check("artwork tag chip has a colored icon and a wide touch target", nature_button != null and nature_button.icon != null and nature_button.custom_minimum_size.y >= 48.0)
var nature_icon: Texture2D = nature_button.icon
nature_button.set_pressed_no_signal(true)
nature_button.emit_signal("toggled", true)
var selected_tag_style := nature_button.get_theme_stylebox("pressed") as StyleBoxFlat
check("tag selection updates its model", (editor.get("_tags") as Array).has("nature"))
check("selected tag keeps its original colored icon without a badge", nature_button.icon == nature_icon)
check("selected tag style uses a cyan-tinted highlight", selected_tag_style.bg_color.is_equal_approx(Color("#103B4D")))
nature_button.set_pressed_no_signal(false)
nature_button.emit_signal("toggled", false)
check("clearing a tag removes it from the tag model", not (editor.get("_tags") as Array).has("nature"))
check("inspector sections use themed cards", editor.get("_material_section") is PanelContainer and editor.get("_add_section") is PanelContainer and editor.get("_tag_section") is PanelContainer)
check("anchor layer control uses the approved label", (editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) is Label and str(((editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) as Label).text) == "Held prop layer:")
canvas.call("add_primitive", "box", Vector2(150.0, 150.0))
var shapes: Array = canvas.call("get_shape_data") as Array
check("prop primitive is selected for editing", int(canvas.call("get_selected_index")) == 0 and shapes.size() == 1)
check("Prop edit shows explicit unsaved status", str(save_status.text).ends_with("Unsaved changes"))
var prop_path: String = "user://editor_visual_harmonization_test.prop"
var save_result: Variant = await editor.call("_save_to", prop_path, false)
check("clean Prop save shows explicit saved status", bool(save_result) and str(save_status.text).ends_with("Saved"))
editor.call("_on_points_mode_pressed")
var create_button: Button = editor.get("_create_point_button") as Button
check("Reshape exposes the Create Point action", create_button.visible and create_button.text == "📍 Create Point")
create_button.emit_signal("pressed")
check("Create Point arms with a visible Esc hint", bool(canvas.call("is_create_point_armed")) and str((editor.get("_create_point_hint") as Label).text).contains("Press Esc"))
var escape: InputEventKey = InputEventKey.new()
escape.keycode = KEY_ESCAPE
escape.pressed = true
editor.call("_unhandled_key_input", escape)
check("Esc cancels the armed point action", not bool(canvas.call("is_create_point_armed")))
create_button.emit_signal("pressed")
var internal_shapes: Array = canvas.get("shapes") as Array
var first_shape: Dictionary = internal_shapes[0] as Dictionary
var original_points: PackedVector2Array = first_shape.get("points", PackedVector2Array()) as PackedVector2Array
var edge_midpoint: Vector2 = (original_points[0] + original_points[1]) * 0.5
var canvas_zoom: float = float(canvas.get("_zoom"))
var canvas_pan: Vector2 = canvas.get("_pan_offset") as Vector2
var edge_screen_pos: Vector2 = edge_midpoint * canvas_zoom + canvas_pan
check("toolbar click target resolves to the visible shape edge", int(canvas.call("hit_test_segment", edge_midpoint)) == 0)
var edge_click: InputEventMouseButton = InputEventMouseButton.new()
edge_click.button_index = MOUSE_BUTTON_LEFT
edge_click.pressed = true
edge_click.position = edge_screen_pos
canvas.call("_on_canvas_gui_input", edge_click)
shapes = canvas.call("get_shape_data") as Array
var reshaped: Dictionary = shapes[0] as Dictionary
var vertices: PackedVector2Array = reshaped.get("points", PackedVector2Array()) as PackedVector2Array
check("armed edge click inserts a point", vertices.size() == 5)
check("inserted point records reshaped state", bool(reshaped.get("reshaped", false)))
check("inserted point marks Prop document dirty", bool(editor.get("_dirty")))
check("Create Point updates Prop status to unsaved", str(save_status.text).ends_with("Unsaved changes"))
check("one click disarms Create Point", not bool(canvas.call("is_create_point_armed")))
if FileAccess.file_exists(prop_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(prop_path))
editor.queue_free()
await process_frame
func _view_contexts_equal(left: Dictionary, right: Dictionary) -> bool:
var left_pan: Vector2 = left.get("pan_offset", Vector2.ZERO) as Vector2
var right_pan: Vector2 = right.get("pan_offset", Vector2.ZERO) as Vector2
return is_equal_approx(float(left.get("zoom", 1.0)), float(right.get("zoom", 1.0))) and left_pan.is_equal_approx(right_pan)
@@ -0,0 +1 @@
uid://bulw0ym7dnnaj
+75
View File
@@ -0,0 +1,75 @@
extends SceneTree
const EDITOR_VISUAL_THEME := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var defaults: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
check("committed shared theme loads", defaults.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH))
check("default body font is 18", defaults.chrome_px(&"body", &"prop") == 18)
check("default touch target is 44 px", defaults.chrome_px(&"button_min_height", &"stickman") == 44)
check("selected surface has a shared semantic token", defaults.chrome_color(&"selected_surface", &"prop").is_equal_approx(Color("#103B4D")))
check("Studio List rows are 48 px", defaults.chrome_px(&"context_menu_row_height", &"stickman") == 48)
check("Studio List minimum width is 208 px", defaults.chrome_px(&"context_menu_min_width", &"prop") == 208)
check("preview role preserves alpha", is_equal_approx(defaults.chrome_color(&"preview").a, 0.4))
var custom_path: String = "user://editor_visual_theme_test.json"
var custom_file: FileAccess = FileAccess.open(custom_path, FileAccess.WRITE)
custom_file.store_string('{"chrome":{"colors":{"edit":"#22C6FF","focus":"#FF00AA"},"targets":{"button_min_height":48}},"editors":{"prop":{"colors":{"edit":"#FF8800"}}}}')
custom_file.close()
var custom: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
check("custom shared theme loads", custom.load_from(custom_path))
check("prop semantic override wins", custom.chrome_color(&"edit", &"prop").is_equal_approx(Color("#FF8800")))
check("stickman uses common semantic default", custom.chrome_color(&"edit", &"stickman").is_equal_approx(Color("#22C6FF")))
check("common touch target override applies", custom.chrome_px(&"button_min_height", &"prop") == 48)
check("local focus token overrides default", custom.chrome_color(&"focus").is_equal_approx(Color("#FF00AA")))
DirAccess.remove_absolute(custom_path)
var invalid_path: String = "user://editor_visual_theme_test_invalid.json"
var invalid_file: FileAccess = FileAccess.open(invalid_path, FileAccess.WRITE)
invalid_file.store_string('{"chrome":{"colors":{"edit":"electric"},"targets":{"button_min_height":"large"}}}')
invalid_file.close()
var invalid: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
invalid.load_from(invalid_path)
check("invalid color falls back to semantic default", invalid.chrome_color(&"edit").is_equal_approx(Color("#22C6FF")))
check("invalid size falls back to semantic default", invalid.chrome_px(&"button_min_height") == 44)
DirAccess.remove_absolute(invalid_path)
var prop_theme: Theme = defaults.control_theme(&"prop")
var stickman_theme: Theme = defaults.control_theme(&"stickman")
var prop_focus: StyleBoxFlat = prop_theme.get_stylebox("focus", "Button") as StyleBoxFlat
var stickman_focus: StyleBoxFlat = stickman_theme.get_stylebox("focus", "Button") as StyleBoxFlat
check("each editor receives a separate local Theme", prop_theme.get_instance_id() != stickman_theme.get_instance_id())
check("each editor receives separate focus styles", prop_focus.get_instance_id() != stickman_focus.get_instance_id())
check("focus outline uses semantic focus token", prop_focus.border_color.is_equal_approx(defaults.chrome_color(&"focus")))
var action: Button = Button.new()
defaults.style_button_role(action, &"primary", &"prop")
var action_box: StyleBoxFlat = action.get_theme_stylebox("normal") as StyleBoxFlat
check("primary action uses semantic action color", action_box.bg_color.is_equal_approx(defaults.chrome_color(&"action_primary", &"prop")))
check("primary action reaches the minimum target size", action.custom_minimum_size.y >= 44.0)
action.free()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
+1
View File
@@ -0,0 +1 @@
uid://cmve03xjcfcg8
+235
View File
@@ -0,0 +1,235 @@
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const DIALOG_STYLER: Script = preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
var _checks: int = 0
var _failures: int = 0
var _stage: SandboxStage
var _chooser: StageGroundFinishChooser
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await _wait_frames(3)
_open_through_palette_signal()
await _wait_frames(3)
_test_initial_gallery()
await _save_capture("phase19_visual_ground_finish_chooser_1280x720.png")
await _test_narrow_viewport_fit()
await _save_capture("phase19_visual_ground_finish_chooser_470x417.png")
_test_close_keeps_placement_unchanged()
root.size = Vector2i(1280, 720)
await _wait_frames(2)
_open_through_palette_signal()
await process_frame
DIALOG_STYLER.dismiss_on_escape(_chooser)
_test_escape_keeps_placement_unchanged()
_open_through_palette_signal()
await _wait_frames(2)
_test_finish_selection_is_a_draft()
await _save_capture("phase19_visual_ground_finish_chooser_dirt_selected.png")
_test_draw_commits_selected_finish()
if DisplayServer.get_name() != "headless":
Input.warp_mouse(Vector2i(900, 420))
await _wait_frames(3)
_test_ground_ghost_is_armed()
await _save_capture("phase19_visual_ground_finish_placement.png")
await _test_ground_click_commits_selected_finish()
_stage.queue_free()
await _wait_frames(3)
if _failures == 0:
print("RESULT: ALL PASSED (%d checks)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
quit(1)
func _open_through_palette_signal() -> void:
var buttons: Dictionary = _stage.get("_palette_buttons") as Dictionary
var ground_button: AssetCard = buttons.get("ground") as AssetCard
_expect(ground_button != null, "Ground palette card exists")
if ground_button == null:
return
ground_button.set_pressed_no_signal(true)
ground_button.activated.emit(ground_button.payload())
_chooser = _stage.get("_ground_finish_chooser") as StageGroundFinishChooser
func _test_initial_gallery() -> void:
_expect(_chooser != null, "Ground finish chooser is built")
if _chooser == null:
return
_expect(DIALOG_STYLER.is_open(_chooser), "Ground card opens the modal chooser through the palette signal")
_expect(_chooser.title == "Choose Ground", "Chooser title matches the approved gallery")
_expect(_chooser.dialog_text == "Stickmen walk here", "Chooser explains the Ground role")
_expect(_chooser.cancel_button_text == "Close", "Footer dismissal action is Close")
_expect(_chooser.get_ok_button().text == "Draw Grass Ground", "Primary action names the selected finish")
var buttons: Dictionary = _chooser.choice_buttons()
_expect(buttons.size() == 7, "Gallery contains seven exclusive Ground finish choices")
var grass_button: Button = buttons.get(&"finish_ground_grass") as Button
_expect(grass_button != null and grass_button.button_pressed, "Current Grass finish is visibly selected")
_expect(_chooser.selected_preview_finish() == &"finish_ground_grass", "Large sample previews the selected finish")
var grid: GridContainer = _chooser.get("_choice_grid") as GridContainer
var wide_row: HBoxContainer = _chooser.get("_layout_row") as HBoxContainer
var preview_panel: Control = _chooser.get("_preview_panel") as Control
_expect(bool(_chooser.get("_is_wide_layout")) and grid != null and grid.columns == 3, "1280×720 opens the side-by-side three-column gallery")
_expect(grid != null and grid.get_parent() == wide_row and preview_panel != null and preview_panel.visible, "Wide gallery keeps choices beside the selected preview")
var card: Control = _chooser.get_meta(DIALOG_STYLER.CARD_META) as Control
var viewport_size: Vector2 = root.get_visible_rect().size
var card_rect: Rect2 = card.get_global_rect() if card != null else Rect2()
_expect(card != null and card_rect.size.x < viewport_size.x * 0.9, "Roomy chooser card leaves visible horizontal Stage margins")
_expect(card != null and card_rect.size.y < viewport_size.y * 0.82, "Roomy chooser card stays comfortably below the Stage height")
_expect(card != null and card_rect.position.x >= 20.0 and card_rect.end.x <= viewport_size.x - 20.0, "Roomy chooser remains inside the viewport width")
_expect(card != null and card_rect.position.y >= 20.0 and card_rect.end.y <= viewport_size.y - 20.0, "Roomy chooser remains inside the viewport height")
func _test_narrow_viewport_fit() -> void:
if _chooser == null:
return
root.size = Vector2i(470, 417)
await _wait_frames(2)
_chooser.call("_apply_responsive_layout")
DIALOG_STYLER.refit(_chooser)
await _wait_frames(2)
var card: Control = _chooser.get_meta(DIALOG_STYLER.CARD_META) as Control
var viewport_size: Vector2 = root.get_visible_rect().size
var card_rect: Rect2 = card.get_global_rect() if card != null else Rect2()
var grid: GridContainer = _chooser.get("_choice_grid") as GridContainer
var preview_panel: Control = _chooser.get("_preview_panel") as Control
_expect(card != null and card_rect.position.x >= 12.0 and card_rect.end.x <= viewport_size.x - 12.0, "Narrow chooser clamps within horizontal safe margins")
_expect(card != null and card_rect.position.y >= 12.0 and card_rect.end.y <= viewport_size.y - 12.0, "Narrow chooser clamps within vertical safe margins")
_expect(card != null and card_rect.size.x < viewport_size.x and card_rect.size.y < viewport_size.y, "Narrow chooser remains a card instead of a full-scene sheet")
_expect(bool(_chooser.get("_is_compact_layout")) and grid != null and grid.columns == 3, "470×417 uses the compact three-column gallery")
_expect(preview_panel != null and not preview_panel.visible, "Compact gallery hides the redundant large preview")
var body_scroll: ScrollContainer = _chooser.get_meta(DIALOG_STYLER.BODY_SCROLL_META) as ScrollContainer
var body_rect: Rect2 = body_scroll.get_global_rect() if body_scroll != null else Rect2()
var all_choices_visible: bool = grid != null and grid.get_child_count() == 7 and body_scroll != null
if all_choices_visible:
for child: Node in grid.get_children():
var choice: Control = child as Control
var choice_rect: Rect2 = choice.get_global_rect()
if not choice.visible or choice_rect.position.x < body_rect.position.x - 1.0 or choice_rect.end.x > body_rect.end.x + 1.0 or choice_rect.position.y < body_rect.position.y - 1.0 or choice_rect.end.y > body_rect.end.y + 1.0:
all_choices_visible = false
break
_expect(all_choices_visible, "Compact gallery exposes all seven finish cards without scrolling")
var actions: HBoxContainer = _chooser.get_meta(DIALOG_STYLER.ACTIONS_META) as HBoxContainer
var close_button: Button = DIALOG_STYLER.visual_button(_chooser.get_cancel_button())
var draw_button: Button = DIALOG_STYLER.visual_button(_chooser.get_ok_button())
_expect(actions != null and close_button != null and draw_button != null, "Narrow chooser keeps both footer actions in its fixed footer")
_expect(close_button != null and close_button.get_global_rect().intersects(card_rect), "Narrow chooser keeps Close reachable inside the card")
_expect(draw_button != null and draw_button.get_global_rect().intersects(card_rect), "Narrow chooser keeps Draw Ground reachable inside the card")
func _test_close_keeps_placement_unchanged() -> void:
if _chooser == null:
return
var close_button: Button = DIALOG_STYLER.visual_button(_chooser.get_cancel_button())
if close_button != null:
close_button.pressed.emit()
var placement_id: String = String(_stage.get("_placement_id"))
var new_finish: StringName = StringName(_stage.get("_new_ground_finish"))
var ground_button: Button = (_stage.get("_palette_buttons") as Dictionary).get("ground") as Button
_expect(not DIALOG_STYLER.is_open(_chooser), "Close dismisses the chooser")
_expect(placement_id.is_empty(), "Close does not arm Ground placement")
_expect(new_finish == &"finish_ground_grass", "Close discards the unconfirmed finish choice")
_expect(ground_button != null and not ground_button.button_pressed, "Close restores the Ground card to its inactive state")
func _test_escape_keeps_placement_unchanged() -> void:
if _chooser == null:
return
var placement_id: String = String(_stage.get("_placement_id"))
var ground_button: Button = (_stage.get("_palette_buttons") as Dictionary).get("ground") as Button
_expect(not DIALOG_STYLER.is_open(_chooser), "Escape dismisses the chooser")
_expect(placement_id.is_empty(), "Escape does not arm Ground placement")
_expect(ground_button != null and not ground_button.button_pressed, "Escape restores the Ground card to its inactive state")
func _test_finish_selection_is_a_draft() -> void:
if _chooser == null:
return
var dirt_button: Button = _chooser.choice_buttons().get(&"finish_ground_dirt") as Button
if dirt_button != null:
dirt_button.set_pressed_no_signal(true)
dirt_button.pressed.emit()
_expect(_chooser.selected_finish() == &"finish_ground_dirt", "Choosing Dirt updates the draft finish")
_expect(_chooser.selected_preview_finish() == &"finish_ground_dirt", "Choosing Dirt updates the large preview")
_expect(_chooser.get_ok_button().text == "Draw Dirt Ground", "Choosing Dirt updates the primary action")
_expect(StringName(_stage.get("_new_ground_finish")) == &"finish_ground_grass", "Draft selection does not mutate the active Ground finish")
_expect(String(_stage.get("_placement_id")).is_empty(), "Draft selection does not start placement")
func _test_draw_commits_selected_finish() -> void:
if _chooser == null:
return
var draw_button: Button = DIALOG_STYLER.visual_button(_chooser.get_ok_button())
if draw_button != null:
draw_button.pressed.emit()
var painter: StageTerrainPainter = _stage.get("_terrain_painter") as StageTerrainPainter
_expect(not DIALOG_STYLER.is_open(_chooser), "Draw Ground closes the chooser")
_expect(StringName(_stage.get("_new_ground_finish")) == &"finish_ground_dirt", "Draw Ground commits the selected finish")
_expect(String(_stage.get("_placement_id")) == "ground", "Draw Ground enters the existing Ground placement tool")
_expect(painter != null and StringName(painter.get("_ground_finish")) == &"finish_ground_dirt", "Ground painter receives the selected finish")
func _test_ground_ghost_is_armed() -> void:
var ghost: Node2D = _stage.get("_ghost") as Node2D
_expect(ghost != null and is_instance_valid(ghost), "Draw Ground shows the cursor-following Ground ghost")
func _test_ground_click_commits_selected_finish() -> void:
var stage_position := Vector2(900.0, 420.0)
var press := InputEventMouseButton.new()
press.position = stage_position
press.global_position = stage_position
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
_stage._unhandled_input(press)
var release := InputEventMouseButton.new()
release.position = stage_position
release.global_position = stage_position
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
_stage._unhandled_input(release)
await _wait_frames(3)
var placed_dirt_ground: bool = false
var world: Node2D = _stage.get_node("World") as Node2D
if world != null:
for child: Node in world.get_children():
if child is TerrainBlock and (child as TerrainBlock).spawn_id == "ground" and (child as TerrainBlock).finish_id == &"finish_ground_dirt":
placed_dirt_ground = true
break
_expect(placed_dirt_ground, "Production Ground input places the selected Dirt finish")
func _wait_frames(count: int) -> void:
for _frame_index: int in count:
await process_frame
func _save_capture(file_name: String) -> void:
if DisplayServer.get_name() == "headless":
return
await RenderingServer.frame_post_draw
var capture: Image = root.get_texture().get_image()
var path: String = ProjectSettings.globalize_path("res://tests/" + file_name)
var error: Error = capture.save_png(path)
_expect(error == OK, "render capture saved for visual inspection: %s" % path)
print("CAPTURE: %s size=%s result=%s" % [path, str(capture.get_size()), str(error)])
func _expect(condition: bool, description: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % description)
else:
_failures += 1
push_error("FAIL: %s" % description)
@@ -0,0 +1 @@
uid://c1fx16sotrdpp
+5 -5
View File
@@ -20,8 +20,9 @@ func _check(label: String, value: bool) -> void:
push_error("FAIL: %s" % label)
func _run() -> void:
root.size = Vector2i(1200, 800)
var theme := THEME.new()
_check("default inspector background is #262C39", theme.inspector_background_color() == Color("#262C39"))
_check("default inspector background uses shared surface token", theme.inspector_background_color().is_equal_approx(Color("#252B38FA")))
var preview := PREVIEW_SCENE.instantiate() as Control
root.add_child(preview)
await process_frame
@@ -39,7 +40,6 @@ func _run() -> void:
_check("front pose stops gait", int(preview.get("_motion")) == 0 and bool(preview.get("_walk_paused")))
preview.queue_free()
var editor := CANVAS_SCENE.instantiate() as Control
editor.size = Vector2(1200.0, 800.0)
root.add_child(editor)
await process_frame
var canvas := editor.get("_canvas") as PropCanvas
@@ -95,12 +95,12 @@ func _run() -> void:
var eye_data: Dictionary = PROP_FILE.load("res://content/props/eye.prop")
_check("eye prop fixture is available", not eye_data.is_empty())
editor.call("_apply_json_data", eye_data)
editor.size = Vector2(1200.0, 600.0)
root.size = Vector2i(1200, 600)
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
await process_frame
await process_frame
var top_bar := editor.get_node("TopBar") as Control
var status_bar := editor.get_node("StatusBar") as Control
var top_bar := editor.get_node("ChromeRoot/TopBar") as Control
var status_bar := editor.get_node("ChromeRoot/StatusBar") as Control
var holding_preview := editor.get("_attach_preview") as Control
_check("Attach layout stays between name and status bars", main_split.get_global_rect().position.y >= top_bar.get_global_rect().end.y - 1.0 and holding_preview.get_global_rect().end.y <= status_bar.get_global_rect().position.y + 1.0)
var preview_container := holding_preview.get("_viewport_container") as SubViewportContainer
+13 -14
View File
@@ -146,25 +146,24 @@ func _test_ui_tokens() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("font_body 18", theme.font_body() == 18)
check("font_header 24", theme.font_header() == 24)
check("font_menu 22", theme.font_menu() == 22)
check("font_menu_bar 22", theme.font_menu_bar() == 22)
check("font_mode 26", theme.font_mode() == 26)
check("font_status 18", theme.font_status() == 18)
check("font_header 22", theme.font_header() == 22)
check("font_menu 20", theme.font_menu() == 20)
check("font_menu_bar 20", theme.font_menu_bar() == 20)
check("font_mode 24", theme.font_mode() == 24)
check("font_status 16", theme.font_status() == 16)
check("font_prop_name 20", theme.font_prop_name() == 20)
check("font_context_menu 22", theme.font_context_menu() == 22)
check("button_min_height 36", theme.button_min_height() == 36)
check("row_min_height 36", theme.row_min_height() == 36)
check("font_context_menu 20", theme.font_context_menu() == 20)
check("button_min_height 44", theme.button_min_height() == 44)
check("row_min_height 44", theme.row_min_height() == 44)
check("mode_button_min_height 56", theme.mode_button_min_height() == 56)
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F2F2F2", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#9A9A9A", Color.WHITE)))
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F5F7FA", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#AAB2C0", Color.WHITE)))
check("border token", theme.border_color().is_equal_approx(Color.from_string("#FFFFFF33", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#4FA3FF", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#22C6FF", Color.WHITE)))
var path := "user://prop_theme_test_ui.json"
_write_user_theme('{"version":"1.0","ui":{"fonts":{"body":24},"colors":{"accent":"#FF8800"}}}', path)
var custom: PropTheme = PROP_THEME.new()
custom.load_from(path)
check("ui font override", custom.font_body() == 24)
check("unoverridden ui token falls back", custom.font_header() == 24)
check("ui accent override", custom.accent_color().is_equal_approx(Color.from_string("#FF8800", Color.WHITE)))
check("legacy PropTheme ui font does not override shared tokens", custom.font_body() == 18)
check("legacy PropTheme ui accent does not override shared tokens", custom.accent_color().is_equal_approx(Color.from_string("#22C6FF", Color.WHITE)))
DirAccess.remove_absolute(path)