55 changed files with 1644 additions and 263 deletions
+4
View File
@@ -13,6 +13,10 @@ You are the Lead Systems Architect specializing in Godot 4 and idiomatic GDScrip
3. **Design:** Define API payload shapes, static GDScript type hints, node hierarchies, and signal flows. 3. **Design:** Define API payload shapes, static GDScript type hints, node hierarchies, and signal flows.
4. **Log:** Append trade-offs to `docs/architecture/tech_debt_and_optimizations.md`. 4. **Log:** Append trade-offs to `docs/architecture/tech_debt_and_optimizations.md`.
## Feature worktree
Before writing a feature document, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the user has not chosen yet, ask the orchestrator to get that preference before writing. Use the supplied path whether it is a worktree or the current checkout; do not create a worktree or switch branches yourself. Follow `AGENTS.md`.
### Shared Stage inspector UI ### Shared Stage inspector UI
For an in-stage selected-object inspector, first read `docs/specs/stage_panel_ui_system.md` For an in-stage selected-object inspector, first read `docs/specs/stage_panel_ui_system.md`
and inspect `scripts/shared/stage_ui/` plus the `stage_panels` theme tokens. Use the existing and inspect `scripts/shared/stage_ui/` plus the `stage_panels` theme tokens. Use the existing
+2
View File
@@ -32,6 +32,8 @@ Clothing currently has no standalone format. Author clothing as layered shapes i
## Review workflow ## Review workflow
Before creating an asset review bundle, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it before writing. Use the selected path whether it is a worktree or the current checkout; do not create a worktree or switch branches yourself. Follow `AGENTS.md`.
Create every unapproved deliverable under `content/review/artist/<request-slug>/`. Use descriptive, versioned filenames and never overwrite an existing production asset unless the user explicitly requests replacement. Create every unapproved deliverable under `content/review/artist/<request-slug>/`. Use descriptive, versioned filenames and never overwrite an existing production asset unless the user explicitly requests replacement.
Each review bundle must contain: Each review bundle must contain:
+1
View File
@@ -40,6 +40,7 @@ Wait for the user to answer before analyzing or proposing feature work.
## Editing limits ## Editing limits
- **Worktree choice:** Before preparing an accepted phase brief or updating the backlog, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it before writing. Use the supplied path whether it is a worktree or the current checkout. Do not create a worktree, switch branches, or write into another checkout yourself. Follow `AGENTS.md`.
- You may edit only `docs/ideas/feature_backlog.md` and accepted phase briefs under `docs/plans/` during normal use. Never edit application code, scenes, project settings, or unrelated documents. - You may edit only `docs/ideas/feature_backlog.md` and accepted phase briefs under `docs/plans/` during normal use. Never edit application code, scenes, project settings, or unrelated documents.
- Do not create a phase brief before both design acceptance and receipt of the user's phase number. - Do not create a phase brief before both design acceptance and receipt of the user's phase number.
- Keep idea discussion conversational and concise. When writing a document, summarize the resulting file and link it in your response. - Keep idea discussion conversational and concise. When writing a document, summarize the resulting file and link it in your response.
+1
View File
@@ -9,6 +9,7 @@ You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding stan
## Rules ## Rules
- **Worktree choice:** Before persistent edits, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it before editing. Use the supplied path whether it is a worktree or the current checkout. Do not create a worktree, switch branches, merge, or edit another checkout yourself. Follow `AGENTS.md` for the selected path.
- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations. - **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations.
- **Static Typing:** Enforce explicit type hints for all signatures and variables. - **Static Typing:** Enforce explicit type hints for all signatures and variables.
- **Decoupling:** Follow "call down, signal up". - **Decoupling:** Follow "call down, signal up".
+13
View File
@@ -5,6 +5,19 @@ model_reasoning_effort = "high"
sandbox_mode = "read-only" sandbox_mode = "read-only"
developer_instructions = """ developer_instructions = """
## Worktree choice
You are read-only and must not create worktrees or write project files. For any handoff that will create project files, confirm that the orchestrator has asked the user whether to use a worktree and supplied the selected project path and branch. Do not require a worktree when the user chooses the current checkout.
## 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) ## 1. Kid-Friendly UI Guardrails (10-Year-Old Persona)
* **Shared Stage Inspector UI:** Before proposing an in-stage selected-object inspector, read * **Shared Stage Inspector UI:** Before proposing an in-stage selected-object inspector, read
+1
View File
@@ -16,4 +16,5 @@ You are a technical writer for Godot 4 / GDScript projects. You document game ar
1. **Targeted Edits Only:** Touch only the specific markdown sections affected by recent changes. Never output full unchanged files. 1. **Targeted Edits Only:** Touch only the specific markdown sections affected by recent changes. Never output full unchanged files.
2. **Scannable & Technical:** Lead directly with code blocks, tables, and structured lists. Eliminate marketing fluff or generic introductions. 2. **Scannable & Technical:** Lead directly with code blocks, tables, and structured lists. Eliminate marketing fluff or generic introductions.
3. **Worktree Choice:** Before any persistent documentation work, confirm that the orchestrator has asked the user whether this task should use a Git worktree and supplied the selected project path and branch. If the preference is missing, ask the orchestrator to get it first. Use the selected path whether it is a worktree or the current checkout; do not create a worktree yourself.
""" """
+4
View File
@@ -7,6 +7,10 @@ description: "Create or revise Stickman Studio props, stickmen, embedded clothin
Use the `artist` subagent for project graphical-asset and animation work. This skill manages delegation, review, approval, and handoff; it does not replace the Artist's visual judgment. Use the `artist` subagent for project graphical-asset and animation work. This skill manages delegation, review, approval, and handoff; it does not replace the Artist's visual judgment.
## Choose a worktree before creating files
Before creating an asset review bundle or other project files, ask whether the user wants this task to use a Git worktree unless they have already stated a preference. For small, self-contained asset changes, offer the current checkout as the default. Wait for the answer before creating a worktree. Follow `AGENTS.md`, and pass Artist the absolute path and branch the user selected, whether that is a worktree or the current checkout. Do not require a worktree when the user declines one.
## Delegate to Artist ## Delegate to Artist
Spawn `artist` with `agent_type: "artist"`. Reuse the same Artist for revisions while it is available. Tell it that other agents may share the workspace and that it must preserve unrelated changes. Spawn `artist` with `agent_type: "artist"`. Reuse the same Artist for revisions while it is available. Tell it that other agents may share the workspace and that it must preserve unrelated changes.
+3 -1
View File
@@ -37,6 +37,8 @@ Treat the user's clear statement that they are ready to work on the discussed, s
## Stage 3: Write and hand off the brief ## Stage 3: Write and hand off the brief
Once the user is ready and the phase number is known, have Designer create the accepted product/design brief at `docs/plans/phase_<number>_<feature_slug>.md`, following `docs/plans/README.md`. The brief should cover the goal and audience, desired child outcome, behavior/flow and feedback, story or directing value where relevant, success criteria, included and excluded scope, known risks/dependencies, project references, and remaining questions for the spec-pipeline. Keep architecture/API decisions for the architect's later technical specification in `docs/specs/`. Once the user is ready and the phase number is known, ask whether the brief and later feature work should use a Git worktree unless the user has already stated a preference for this task. Follow the choice and the `Feature branches and worktrees` rules in `AGENTS.md`; do not create a worktree before the user answers. Use the selected checkout consistently for the brief, later specs, implementation, and verification. Pass its absolute path and current branch to Designer. Do not switch branches or write outside the selected checkout.
Have Designer create the accepted product/design brief at `docs/plans/phase_<number>_<feature_slug>.md`, following `docs/plans/README.md`. The brief should cover the goal and audience, desired child outcome, behavior/flow and feedback, story or directing value where relevant, success criteria, included and excluded scope, known risks/dependencies, project references, and remaining questions for the spec-pipeline. Keep architecture/API decisions for the architect's later technical specification in `docs/specs/`.
Do not overwrite an existing phase brief without the user's direction. Confirm the resulting file exists, update the matching backlog entry to `Planned` with a link to the brief, then tell the user where the plan is using a clickable local file link. Stop there; this skill creates the design handoff, not the technical spec or implementation. Do not overwrite an existing phase brief without the user's direction. Confirm the resulting file exists, update the matching backlog entry to `Planned` with a link to the brief, then tell the user where the plan is using a clickable local file link. Stop there; this skill creates the design handoff, not the technical spec or implementation.
+10
View File
@@ -5,8 +5,17 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
## Execution Logic ## Execution Logic
### Feature branch and worktree setup
Before creating a worktree for a task, ask the user whether they want one and wait for the answer. For small, self-contained fixes or documentation changes, offer the current checkout as the default; for larger feature phases, explain the benefit of a dedicated worktree. Do not create a worktree before the user chooses. Keep the user's choice for the task from the first persistent artifact through implementation and verification.
Follow the `Feature branches and worktrees` rules in `AGENTS.md`: inspect `git status --short`, the current branch, and worktree list before the first persistent change. If the user chooses the current checkout, stay on its branch. If they choose a worktree, reuse a matching one or create `.worktrees/<feature-slug>` on `feature/<feature-slug>` from the integration branch, including the phase number when known. Do not switch branches, transfer uncommitted changes without direction, or merge automatically.
Pass every delegated agent the absolute selected project path and its branch, whether that path is a worktree or the current checkout. Tell each agent to use it for all repository reads, writes, Godot runs, and visual checks. Do not stop file-writing work solely because the user chose not to use a worktree.
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)** 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. - `@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. - 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. - 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. - `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
@@ -15,6 +24,7 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
2. **Phase 0b: UI Spec Generation** 2. **Phase 0b: UI Spec Generation**
- User approves an option or provides feedback. - User approves an option or provides feedback.
- `@uiux` outputs the UI/UX specification (control layouts, widget behaviors, visual feedback states, and cursor logic). - `@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. - 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** 3. **Phase 1a: Architecture Proposal & Clarification**
+1
View File
@@ -1,5 +1,6 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
.worktrees/
.images/ .images/
/android/ /android/
addons/ addons/
+15
View File
@@ -6,6 +6,17 @@
- Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`. - Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`.
- **Main scene:** `res://scenes/main.tscn` (set as `run/main_scene` in `project.godot`; hosts the Stickman Editor) - **Main scene:** `res://scenes/main.tscn` (set as `run/main_scene` in `project.godot`; hosts the Stickman Editor)
## Feature branches and worktrees
- Before creating a Git worktree for a task, ask the user whether they want this task to use one. Do not create a worktree until they answer. For small, self-contained fixes and documentation changes, offer the current checkout as the default; for larger feature phases, explain why a worktree may help and let the user choose.
- Read-only discussion and proposals do not need a worktree. Before the first persistent change, inspect `git status --short`, `git branch --show-current`, and `git worktree list`.
- If the user chooses the current checkout, keep working on its current branch. Do not switch branches, stash, reset, clean, move, or overwrite unrelated work.
- If the user chooses a worktree, reuse a matching one when present. Otherwise, create `.worktrees/<feature-slug>` with branch `feature/<feature-slug>`, based on the integration branch (usually `main`). Include the phase number in the slug when known, such as `phase-20-stage-search`. Example: `git worktree add .worktrees/phase-20-stage-search -b feature/phase-20-stage-search main`. Confirm the path and branch with `git worktree list` and `git branch --show-current`.
- If relevant uncommitted changes need to be carried into a chosen worktree, ask how to handle them before proceeding.
- Pass every subagent the absolute path and branch the user chose, whether that is a worktree or the current checkout. All project reads, writes, Godot commands, and visual checks must target that path. Do not require a worktree when the user chose the current checkout.
- Do not merge feature branches automatically. Keep each feature branch available for review and merge only when the user asks or the active workflow explicitly authorizes it.
- `.worktrees/` is a local worktree container and must remain gitignored.
## Repository layout ## Repository layout
- `scripts/` and `scenes/` stay as separate roots with matching feature groups. Editor controllers live in `scripts/editors/{stickman,prop,stage}`; stage, stickman runtime, props, terrain, and speech systems live in `scripts/{stage,characters/stickman,props,terrain,speech}`. - `scripts/` and `scenes/` stay as separate roots with matching feature groups. Editor controllers live in `scripts/editors/{stickman,prop,stage}`; stage, stickman runtime, props, terrain, and speech systems live in `scripts/{stage,characters/stickman,props,terrain,speech}`.
@@ -78,6 +89,10 @@ the configured `run/main_scene`.
custom UI/UX agent defined in `.codex/agents/uiux.toml`. Treat that prefix as an explicit 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 delegation request. The UI/UX agent is read-only; carry its design guidance into implementation
separately when implementation is requested. 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 - 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 [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 and controls, clear borders, blue primary actions with bold white labels, comfortable button
+47 -28
View File
@@ -1,7 +1,7 @@
# Editor Visual Style Guide # 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. **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 future editor design and implementation. It documents current patterns and gaps; it does not assert that the editors already share the Stage theme or components. **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 ## 1. Product direction
@@ -22,9 +22,10 @@ The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-f
| --- | --- | --- | | --- | --- | --- |
| 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. | | 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 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 dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) styles child-facing dialogs hosted by the Stage overlay. | Reuse its visual contract. Its overlay host and lifecycle are Stage-specific unless deliberately generalized. | | 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. |
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json); the controller creates a local inspector Theme. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep values token-driven and scoped. Current Prop colors and target sizes differ from Stage tokens. | | 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. |
| Stickman Editor | [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd) coordinates ten BodyPartPanels and the Whole Stickman preview. The scene currently uses fixed top-bar offsets and a three-column workspace. | The canvas/preview model is established, but it has no apparent SandboxTheme or PropTheme integration. Harmonization is future work. | | 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. | | 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. 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.
@@ -41,7 +42,7 @@ Use color to reinforce meaning consistently. Keep labels, shapes, icons, or stat
| Raised surface | global.surface_color #252B38FA | Use for toolbars, inspector shells, and cards. Distinguish adjacent surfaces by value, border, or spacing. | | 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. | | 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. | | 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 | Suitable for active edit mode, selection accents, and editor guidance. It is not the current PropTheme accent. | | 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. | | 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. | | 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. | | Primary action | Stage dialog/action blue #087DBA | Use for the single most important forward action in a dialog. |
@@ -51,16 +52,14 @@ Use color to reinforce meaning consistently. Keep labels, shapes, icons, or stat
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. 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 Current token differences to resolve during harmonization ### 3.2 Shared chrome and intentional editor differences
The current palettes are related but not at parity: 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 uses ui.colors.accent **#4FA3FF**, while SandboxTheme EDIT uses **#22C6FF**. - `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.
- PropTheme defaults its button and row minimum heights to **36 px** and its mode button minimum height to **56 px**. Stage action targets are **48 px**, and child-facing UI guidance calls for at least **44 × 44 logical pixels**. - `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.
- PropTheme's canvas is #1F1F1F; its inspector token is #262C39. The Stage uses #171B24F2 / #252B38FA for global background and surface.
- The older Stickman Editor has no apparent editor theme integration. Its scene uses 32 px menu/top bars, a 28 px status bar, 4 px preview corners, and a 14 px preview title; body-part headings are around 13–14 px. It defines preview surface and borders directly in the scene.
Treat these as migration work, not evidence that the editors already match the Stage. Before implementation, decide whether to harmonize the accent, minimum targets, border/focus states, and surface roles. Preserve canvas contrast where it serves drawing readability. 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 ### 3.3 Type, spacing, and shape
@@ -96,7 +95,7 @@ For drawing handles, keep a contrasting backing/halo where the canvas can contai
### 4.1 Use the right pattern for the job ### 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. - **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. Reuse spacing, hierarchy, colors, and control states; do not force the floating Stage card structure into this column. - **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. - **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. - **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. - **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.
@@ -111,7 +110,7 @@ For drawing handles, keep a contrasting backing/halo where the canvas can contai
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. 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. 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.
The current code already demonstrates local theme ownership in Prop Editor (_ui_theme) and Stage token validation/cache behavior in SandboxTheme. This guide recommends extending those strengths; it does not claim the editor themes are currently unified. 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 ### 4.3 Responsive workspace behavior
@@ -120,7 +119,22 @@ The current code already demonstrates local theme ownership in Prop Editor (_ui_
- Use container-driven rows (FlowContainer, HBoxContainer, VBoxContainer) instead of fixed offsets for control groups that can wrap or grow with theme fonts. - 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 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. - 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.
- For Stickman Editor, the current scene uses fixed top bar offsets and a three-column layout. A responsive pass should specifically validate narrower windows, custom fonts, longer translations, and panel resizing before claiming parity with Prop Editor's split workspace. - 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 ## 5. Editor flows and state maps
@@ -195,7 +209,7 @@ For either editor, the expected child-facing sequence is:
1. State the action in the title and one short sentence. 1. State the action in the title and one short sentence.
2. Show the effect or consequence when it may be surprising. 2. Show the effect or consequence when it may be surprising.
3. Present one primary action, any needed destructive action, and a visible **Cancel**. 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. 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. 5. On success, close the dialog and update the affected editor state once. On cancel, leave the document unchanged.
@@ -208,6 +222,11 @@ This is a target interaction contract for editor dialogs. Existing controls shou
- 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. - 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 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 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. - 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. - 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. - Preserve the world point under the cursor during zoom. Keep selected gizmos and handles visible above artwork and grid.
@@ -228,7 +247,7 @@ Prefer concise, concrete verbs and familiar terms:
| “Choose where this prop attaches.” | Internal terms such as “anchor payload” | | “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 | | “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 Prop 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. 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 ### 6.3 Icons and emoji
@@ -236,7 +255,8 @@ Emoji can help distinguish common actions, as used by Prop Editor menus, but sho
- Keep the text label as the source of meaning. Provide a tooltip or accessible name for icon-only canvas tools. - 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. - 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. - Avoid using an emoji's color as the only state cue; fonts and platforms may render it differently.
- The Prop Editor already uses emoji in File and mode labels. The Stickman Editor currently uses plain menu labels. Treat a visual icon pass as a recommendation that should be applied consistently, not as a current shared convention. - 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 ### 6.4 Empty, loading, and error states
@@ -249,7 +269,7 @@ The current editors have different levels of state guidance. The following table
| 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. | | 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 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. | | 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 status bar reports cursor coordinates and snap state. | Make 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. | | 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. 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.
@@ -317,14 +337,13 @@ This is recommended implementation order, not a claim that every current editor
### Recommended harmonization order ### Recommended harmonization order
1. **Agree on token ownership and names.** Define shared semantic roles for background, surface, text, muted text, EDIT accent, focus, primary/destructive action, spacing, radii, borders, and minimum targets. Keep canvas-specific colors separate. 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. **Resolve token drift.** Decide whether Prop's #4FA3FF accent and 36 px button/row minima should move toward the Stage reference, or remain intentional editor overrides. Record the choice and add an accessibility rationale for target sizes. 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. **Bring Stickman chrome into token ownership.** Move its scene-level colors, font sizes, spacing, and bar sizing into a validated theme source while preserving the panel/preview composition that supports drawing. 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. **Improve responsive layout.** Replace brittle fixed offsets where font or window growth can clip controls; establish minimum column widths and wrapping behavior for both editors. 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. **Unify states and dialogs.** Verify focus, disabled reasons, save/error status, destructive confirmation, and cancel behavior. Reuse dialog styling only after its host/lifecycle works in the editor. 5. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
6. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
This order is a recommendation. It does not select a new shared-theme class or authorize a source migration; choose the theme architecture when implementation scope is defined. 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 ### Visual QA checklist
@@ -344,8 +363,8 @@ This order is a recommendation. It does not select a new shared-theme class or a
To make this guide a stronger long-term design reference, add these pieces as the editor UI evolves: 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. 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 shared token inventory.** Decide semantic names and authoritative ownership for surfaces, accents, focus, destructive actions, canvas grid, selection halo, typography, spacing, and minimum target sizes. Reconcile SandboxTheme, PropTheme, and Stickman Editor scene-level values in a planned migration. 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 Stickman Editor's three columns adapt. 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. 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. 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. 6. **Content and localization rules.** Include label length budgets, wrapping/truncation behavior, tooltip standards, pluralization, and translation expansion checks.
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

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 ## 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. - 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. - 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 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. - 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. - 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. - 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. - 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.
+1 -1
View File
@@ -98,7 +98,7 @@ StickmanEditor : Control (document, save/load, dirty, active part, view choice)
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. 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 a text label and visible border cue in Overview and Focus. `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: Suggested typed additions:
+10 -1
View File
@@ -5,6 +5,7 @@
"background": "#171B24F2", "background": "#171B24F2",
"surface": "#252B38FA", "surface": "#252B38FA",
"surface_hover": "#30394A", "surface_hover": "#30394A",
"selected_surface": "#103B4D",
"text": "#F5F7FA", "text": "#F5F7FA",
"text_muted": "#AAB2C0", "text_muted": "#AAB2C0",
"edit": "#22C6FF", "edit": "#22C6FF",
@@ -24,6 +25,8 @@
"section": 22, "section": 22,
"title": 26, "title": 26,
"menu": 20, "menu": 20,
"menu_bar": 22,
"menu_popup": 26,
"status": 16, "status": 16,
"mode": 24, "mode": 24,
"prop_name": 20, "prop_name": 20,
@@ -32,7 +35,13 @@
"spacing": { "small": 8, "medium": 12, "large": 16 }, "spacing": { "small": 8, "medium": 12, "large": 16 },
"radii": { "control": 8, "panel": 10 }, "radii": { "control": 8, "panel": 10 },
"borders": { "normal": 1, "focus": 2 }, "borders": { "normal": 1, "focus": 2 },
"targets": { "button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56 } "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": {} }, "editors": { "prop": {}, "stickman": {} },
"stickman_canvas": { "stickman_canvas": {
+4
View File
@@ -159,9 +159,13 @@
"choice_font_size": 18, "choice_font_size": 18,
"choice_min_width": 104, "choice_min_width": 104,
"choice_corner_radius": 12, "choice_corner_radius": 12,
"choice_content_inset_x": 8,
"choice_content_inset_y": 6,
"compact_choice_height": 50, "compact_choice_height": 50,
"compact_choice_preview_height": 22, "compact_choice_preview_height": 22,
"compact_choice_font_size": 16, "compact_choice_font_size": 16,
"compact_choice_content_inset": 4,
"compact_choice_content_separation": 2,
"compact_grid_gap": 6, "compact_grid_gap": 6,
"compact_layout_max_width": 600, "compact_layout_max_width": 600,
"compact_layout_max_height": 460, "compact_layout_max_height": 460,
+3
View File
@@ -159,3 +159,6 @@ layout_mode = 2
text = "" text = ""
horizontal_alignment = 2 horizontal_alignment = 2
vertical_alignment = 1 vertical_alignment = 1
[node name="StageDialogLayer" type="CanvasLayer" parent="."]
layer = 100
+5 -3
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=3 format=3] [gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/editors/stickman/body_part_panel.gd" id="1_panel_script"] [ext_resource type="Script" path="res://scripts/editors/stickman/body_part_panel.gd" id="1_panel_script"]
[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"] [sub_resource type="StyleBoxFlat" id="StyleBox_drawing"]
bg_color = Color(0.12, 0.12, 0.12, 1) bg_color = Color(0.12, 0.12, 0.12, 1)
@@ -14,6 +15,7 @@ border_color = Color(0.25, 0.25, 0.25, 1)
custom_minimum_size = Vector2(230, 160) custom_minimum_size = Vector2(230, 160)
offset_right = 230.0 offset_right = 230.0
offset_bottom = 160.0 offset_bottom = 160.0
size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
script = ExtResource("1_panel_script") script = ExtResource("1_panel_script")
@@ -38,9 +40,9 @@ mouse_filter = 1
clip_contents = true clip_contents = true
theme_override_styles/panel = SubResource("StyleBox_drawing") theme_override_styles/panel = SubResource("StyleBox_drawing")
[node name="ContextMenu" type="PopupMenu" parent="."] [node name="ContextMenu" type="PopupPanel" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
item_count = 0 script = ExtResource("2_context_menu_script")
[node name="ColorPickerPopup" type="PopupPanel" parent="."] [node name="ColorPickerPopup" type="PopupPanel" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
+15 -10
View File
@@ -1,7 +1,8 @@
[gd_scene load_steps=3 format=3 uid="uid://6wqo0sp4eij6"] [gd_scene load_steps=4 format=3 uid="uid://6wqo0sp4eij6"]
[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://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"] [ext_resource type="Script" uid="uid://codv3g6qw1tef" path="res://scripts/editors/stickman/whole_stickman_preview.gd" id="2_preview_script"]
[ext_resource type="Script" path="res://scripts/shared/editor_ui/editor_selection_dropdown.gd" id="3_selection_dropdown_script"]
[node name="StickmanEditor" type="Control"] [node name="StickmanEditor" type="Control"]
layout_mode = 3 layout_mode = 3
@@ -60,9 +61,10 @@ layout_mode = 2
text = "Focus on Part" text = "Focus on Part"
toggle_mode = true toggle_mode = true
[node name="PartPicker" type="OptionButton" parent="ChromeRoot/WorkspaceToolbar"] [node name="PartPicker" type="MenuButton" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
script = ExtResource("3_selection_dropdown_script")
[node name="AddShapeButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"] [node name="AddShapeButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true unique_name_in_owner = true
@@ -115,16 +117,22 @@ vertical_scroll_mode = 1
[node name="OverviewColumns" type="HBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll"] [node name="OverviewColumns" type="HBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 8 theme_override_constants/separation = 8
[node name="LeftColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"] [node name="LeftColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6 theme_override_constants/separation = 6
[node name="CenterColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"] [node name="CenterColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6 theme_override_constants/separation = 6
[node name="FocusHost" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"] [node name="FocusHost" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
@@ -138,12 +146,6 @@ grow_vertical = 2
visible = false visible = false
theme_override_constants/separation = 8 theme_override_constants/separation = 8
[node name="FocusPartLabel" type="Label" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
unique_name_in_owner = true
layout_mode = 2
text = "Head"
horizontal_alignment = 1
[node name="FocusPanelHost" type="Control" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"] [node name="FocusPanelHost" type="Control" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
@@ -219,9 +221,9 @@ filters = PackedStringArray("*.stk ; Stickman Files")
[node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."] [node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
title = "Start Over" title = "Start Over?"
ok_button_text = "Start Over" ok_button_text = "Start Over"
dialog_text = "Start over with a blank stickman?" dialog_text = "This will clear your current stickman. Start over?"
[node name="UnsavedChangesDialog" type="ConfirmationDialog" parent="."] [node name="UnsavedChangesDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
@@ -254,3 +256,6 @@ max_value = 100.0
value = 15.0 value = 15.0
step = 1.0 step = 1.0
rounded = true rounded = true
[node name="StageDialogLayer" type="CanvasLayer" parent="."]
layer = 100
+32 -12
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 PropAnchorOverlayScript := preload("res://scripts/props/prop_anchor_overlay.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd") const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const WholeStickmanPreviewScript := preload("res://scripts/editors/stickman/whole_stickman_preview.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_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85) const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -122,7 +123,7 @@ var _grid_size: int = 15
var _snap_enabled: bool = false var _snap_enabled: bool = false
var _has_clipboard: bool = false var _has_clipboard: bool = false
var _create_point_armed: bool = false var _create_point_armed: bool = false
var _context_menu: PopupMenu var _context_menu: EditorContextMenu
var _edit_mode: EditMode = EditMode.TRANSFORM var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
@@ -189,7 +190,7 @@ func _ready() -> void:
_anchor_overlay = PropAnchorOverlayScript.new() _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw()) _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()) _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" _context_menu.name = "ContextMenu"
add_child(_context_menu) add_child(_context_menu)
if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed): if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed):
@@ -983,7 +984,12 @@ func set_has_clipboard(state: bool) -> void:
## Sets the font size of the right-click context menu (Round 4, theme-driven). ## Sets the font size of the right-click context menu (Round 4, theme-driven).
func set_context_menu_font_size(size: int) -> void: func set_context_menu_font_size(size: int) -> void:
if _context_menu != null: 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: func is_cursor_over_drawing(global_pos: Vector2) -> bool:
@@ -1335,8 +1341,8 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
if context_shape >= 0 and context_vertex >= 0: if context_shape >= 0 and context_vertex >= 0:
_context_menu.set_meta("context_shape", context_shape) _context_menu.set_meta("context_shape", context_shape)
_context_menu.set_meta("context_vertex", context_vertex) _context_menu.set_meta("context_vertex", context_vertex)
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT) _context_menu.add_destructive_item("Remove Point", MENU_REMOVE_POINT)
_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 return
# Segment proximity -> Create Point (selects the shape first). # Segment proximity -> Create Point (selects the shape first).
@@ -1348,7 +1354,7 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
queue_redraw() queue_redraw()
shape_selected.emit(seg_shape) shape_selected.emit(seg_shape)
_context_menu.add_item("Create Point", MENU_CREATE_POINT) _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 return
# Interior body: select only, no menu. # Interior body: select only, no menu.
@@ -1381,17 +1387,18 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
_context_menu.set_meta("click_position", world_pos) _context_menu.set_meta("click_position", world_pos)
queue_redraw() queue_redraw()
shape_selected.emit(hit_shape) shape_selected.emit(hit_shape)
_context_menu.add_item("Copy", MENU_COPY) _context_menu.add_item("Copy Shape", MENU_COPY)
if _has_clipboard: if _has_clipboard:
_context_menu.add_item("Paste", MENU_PASTE) _context_menu.add_item("Paste Shape", MENU_PASTE)
_context_menu.add_item("Erase", MENU_DELETE)
_context_menu.add_separator() _context_menu.add_separator()
_context_menu.add_item("Flip Left-Right", MENU_MIRROR_X) _context_menu.add_item("Flip Left-Right", MENU_MIRROR_X)
_context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y) _context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y)
_context_menu.add_separator() _context_menu.add_separator()
_context_menu.add_item("Bring Forward", MENU_BRING_FORWARD) _context_menu.add_item("Bring Forward", MENU_BRING_FORWARD)
_context_menu.add_item("Send Back", MENU_SEND_BACK) _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 return
# Empty canvas -> add primitives. # Empty canvas -> add primitives.
@@ -1403,8 +1410,8 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
_context_menu.add_item("Add Circle", MENU_ADD_CIRCLE) _context_menu.add_item("Add Circle", MENU_ADD_CIRCLE)
if _has_clipboard: if _has_clipboard:
_context_menu.add_separator() _context_menu.add_separator()
_context_menu.add_item("Paste", MENU_PASTE) _context_menu.add_item("Paste Shape", MENU_PASTE)
_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: func _handle_left_press(world_pos: Vector2) -> void:
@@ -1857,6 +1864,19 @@ func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom 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: func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size) var gs := float(_grid_size)
if gs <= 0.0: if gs <= 0.0:
+276 -118
View File
@@ -13,6 +13,8 @@ extends Control
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd") const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd") const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_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 PropBlockScript := preload("res://scripts/props/prop_block.gd")
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd") const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd") const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
@@ -20,6 +22,7 @@ const ThumbnailCacheScript := preload("res://scripts/shared/thumbnails/thumbnail
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd") const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const PropAttachPreviewScene := preload("res://scenes/editors/prop/prop_attach_preview.tscn") const PropAttachPreviewScene := preload("res://scenes/editors/prop/prop_attach_preview.tscn")
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd") 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 PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"] const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
@@ -116,6 +119,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
var _theme: PropTheme var _theme: PropTheme
var _editor_visual_theme: EditorVisualTheme var _editor_visual_theme: EditorVisualTheme
var _dialog_theme: SandboxTheme
var _material_preset: int = PropBlockScript.MaterialPreset.NONE var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = "" var _current_path: String = ""
var _dirty: bool = false var _dirty: bool = false
@@ -128,11 +132,14 @@ var _thumb_cache: ThumbnailCache
var _ui_theme: Theme var _ui_theme: Theme
# Inspector controls # Inspector controls
var _preset_option: OptionButton var _preset_option: PropInspectorDropdown
var _material_section: VBoxContainer var _material_section: PanelContainer
var _mass_label: Label var _mass_label: Label
var _mass_value: Label
var _friction_label: Label var _friction_label: Label
var _friction_value: Label
var _bounce_label: Label var _bounce_label: Label
var _bounce_value: Label
var _fill_button: ColorPickerButton var _fill_button: ColorPickerButton
var _outline_button: ColorPickerButton var _outline_button: ColorPickerButton
var _width_spin: SpinBox var _width_spin: SpinBox
@@ -143,14 +150,14 @@ var _vertex_label: Label
var _delete_button: Button var _delete_button: Button
var _create_point_button: Button var _create_point_button: Button
var _create_point_hint: Label var _create_point_hint: Label
var _shape_section: VBoxContainer var _shape_section: PanelContainer
var _primitive_option: OptionButton var _primitive_option: PropInspectorDropdown
var _params_box: VBoxContainer var _params_box: VBoxContainer
var _param_spins: Dictionary = {} var _param_spins: Dictionary = {}
var _add_button: Button var _add_button: Button
var _updating_inspector: bool = false var _updating_inspector: bool = false
var _tag_checks: Dictionary = {} var _tag_checks: Dictionary = {}
var _tag_section: VBoxContainer var _tag_section: PanelContainer
var _stage_authoring: bool = false var _stage_authoring: bool = false
var _stage_return_pending: bool = false var _stage_return_pending: bool = false
var _stage_place_button: Button var _stage_place_button: Button
@@ -177,7 +184,7 @@ var _points_button: Button
var _attach_button: Button var _attach_button: Button
# Add-shape inspector section (hidden in Reshape mode, Round 3) # Add-shape inspector section (hidden in Reshape mode, Round 3)
var _add_section: VBoxContainer var _add_section: PanelContainer
var _anchor_section: VBoxContainer var _anchor_section: VBoxContainer
var _anchor_list: ItemList var _anchor_list: ItemList
var _anchor_role: OptionButton var _anchor_role: OptionButton
@@ -218,6 +225,8 @@ func _ready() -> void:
get_tree().auto_accept_quit = false get_tree().auto_accept_quit = false
_editor_visual_theme = EditorVisualThemeScript.new() _editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH) _editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_dialog_theme = SandboxThemeScript.new()
_dialog_theme.load_from(SandboxThemeScript.DEFAULT_PATH)
_theme = PropThemeScript.new() _theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH) _theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new() _thumb_cache = ThumbnailCacheScript.new()
@@ -254,6 +263,7 @@ func _ready() -> void:
_canvas.set_grid_size(_theme.grid_size()) _canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false) _canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(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_context_menu_font_size(_theme.font_context_menu())
_canvas.set_anchor_data(_anchors) _canvas.set_anchor_data(_anchors)
var origin_marker_color: Color = _theme.text_muted_color() var origin_marker_color: Color = _theme.text_muted_color()
@@ -503,26 +513,26 @@ func _setup_menu_bar() -> void:
var file_menu := PopupMenu.new() var file_menu := PopupMenu.new()
file_menu.name = "FileMenu" file_menu.name = "FileMenu"
file_menu.add_item("🆕 New", MENU_NEW) file_menu.add_item("🆕 New", MENU_NEW)
file_menu.add_item("💾 Save", MENU_SAVE) file_menu.add_item("📂 Load…", MENU_LOAD)
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
file_menu.add_item("📂 Load...", MENU_LOAD)
file_menu.add_separator()
var recent_popup := PopupMenu.new() var recent_popup := PopupMenu.new()
recent_popup.name = "RecentProps" recent_popup.name = "RecentProps"
recent_popup.id_pressed.connect(_on_recent_prop_selected) recent_popup.id_pressed.connect(_on_recent_prop_selected)
file_menu.add_child(recent_popup) 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.id_pressed.connect(_on_menu_id_pressed)
file_menu.about_to_popup.connect(_update_recent_menu) file_menu.about_to_popup.connect(_update_recent_menu)
_menu_bar.add_child(file_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() var view_menu := PopupMenu.new()
view_menu.name = "ViewMenu" 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) view_menu.id_pressed.connect(_on_menu_id_pressed)
_menu_bar.add_child(view_menu) _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")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -617,25 +627,31 @@ func _setup_dialogs() -> void:
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"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_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop") _editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_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() _build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new() _rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor" _rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename" _rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme _rename_anchor_dialog.set_meta(StageDialogStylerScript.ICON_META, "📝")
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
_rename_anchor_edit = LineEdit.new() _rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name" _rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit) _rename_anchor_dialog.add_child(_rename_anchor_edit)
add_child(_rename_anchor_dialog) 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_changed.connect(_update_rename_anchor_confirmation)
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted) _rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename) _rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename) _rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
func _on_rename_anchor_text_submitted(_text: String) -> void: 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 return
var ok_button := _rename_anchor_dialog.get_ok_button() var ok_button := _rename_anchor_dialog.get_ok_button()
if not ok_button.disabled: if not ok_button.disabled:
@@ -886,30 +902,31 @@ func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
func _build_exit_dialog() -> void: func _build_exit_dialog() -> void:
_exit_dialog = ConfirmationDialog.new() _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.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save" _exit_dialog.ok_button_text = "Save"
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard") var discard_button: Button = _exit_dialog.add_button("Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel" _exit_dialog.get_cancel_button().text = "Cancel"
_exit_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_exit_dialog) 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.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action) _exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel) _exit_dialog.canceled.connect(_on_exit_cancel)
_off_origin_save_dialog = ConfirmationDialog.new() _off_origin_save_dialog = ConfirmationDialog.new()
_off_origin_save_dialog.title = "🎯 Center Prop?" _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.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.ok_button_text = "Center & Save"
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway") 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" _off_origin_save_dialog.get_cancel_button().text = "Cancel"
_off_origin_save_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_off_origin_save_dialog) 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.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void: _off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"save_anyway": await _execute_pending_save(false)) if action == &"save_anyway": await _execute_pending_save(false))
@@ -931,7 +948,7 @@ func _request_close() -> void:
_pending_quit = true _pending_quit = true
_pending_new = false _pending_new = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?" _exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.popup_centered() StageDialogStylerScript.popup(_exit_dialog)
func _request_new() -> void: func _request_new() -> void:
@@ -942,7 +959,7 @@ func _request_new() -> void:
_pending_new = true _pending_new = true
_pending_quit = false _pending_quit = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?" _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: func _request_stage_return() -> void:
@@ -958,11 +975,11 @@ func _request_stage_return() -> void:
_pending_new = false _pending_new = false
_pending_quit = false _pending_quit = false
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage." _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: func _on_exit_save() -> void:
_exit_dialog.hide() StageDialogStylerScript.hide(_exit_dialog)
if _current_path.is_empty(): if _current_path.is_empty():
_save_dialog.popup_centered() _save_dialog.popup_centered()
return return
@@ -971,7 +988,7 @@ func _on_exit_save() -> void:
func _on_exit_custom_action(action: StringName) -> void: func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard": if action == &"discard":
_exit_dialog.hide() StageDialogStylerScript.hide(_exit_dialog)
if not _stage_authoring: if not _stage_authoring:
_clear_autosave() _clear_autosave()
if _stage_return_pending: if _stage_return_pending:
@@ -1046,47 +1063,60 @@ func _on_menu_id_pressed(id: int) -> void:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _build_inspector() -> void: func _build_inspector() -> void:
var header_size: int = _theme.font_header()
var row_height: int = _theme.row_min_height() 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 preset section
_material_section = VBoxContainer.new() _material_section = _make_inspector_card()
_material_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_material_section) _inspector_list.add_child(_material_section)
var material_title := Label.new() var material_content: VBoxContainer = _inspector_card_content(_material_section)
material_title.text = "Material" material_content.add_child(_make_inspector_header("Material", PropInspectorIconsScript.section("material")))
material_title.add_theme_font_size_override("font_size", header_size)
_material_section.add_child(material_title)
_preset_option = OptionButton.new() _preset_option = PropInspectorDropdown.new()
for label: String in PRESET_LABELS: for index: int in PRESET_LABELS.size():
_preset_option.add_item(label) _preset_option.add_icon_item(
PropInspectorIconsScript.material(index, _theme.material_tint(index, "fill")),
PRESET_LABELS[index]
)
_preset_option.selected = _material_preset _preset_option.selected = _material_preset
_preset_option.custom_minimum_size.y = float(_theme.button_min_height()) _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) _preset_option.item_selected.connect(_on_preset_selected)
_material_section.add_child(_preset_option) material_content.add_child(_preset_option)
_mass_label = Label.new() var stats := HBoxContainer.new()
_friction_label = Label.new() stats.add_theme_constant_override("separation", 6)
_bounce_label = Label.new() material_content.add_child(stats)
_material_section.add_child(_mass_label) var mass_tile: Array[Label] = _make_stat_tile(stats, "Mass")
_material_section.add_child(_friction_label) _mass_label = mass_tile[0]
_material_section.add_child(_bounce_label) _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() _update_physics_labels()
# Selected-shape section # Selected-shape section
_shape_section = VBoxContainer.new() _shape_section = _make_inspector_card()
_shape_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_shape_section) _inspector_list.add_child(_shape_section)
var shape_content: VBoxContainer = _inspector_card_content(_shape_section)
var shape_title := Label.new() shape_content.add_child(_make_inspector_header("Selected Shape", PropInspectorIconsScript.section("selected_shape")))
shape_title.text = "Selected Shape"
shape_title.add_theme_font_size_override("font_size", header_size)
_shape_section.add_child(shape_title)
var fill_row := HBoxContainer.new() var fill_row := HBoxContainer.new()
fill_row.custom_minimum_size.y = float(row_height) 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() var fill_label := Label.new()
fill_label.text = "Fill:" fill_label.text = "Fill:"
fill_label.custom_minimum_size = Vector2(90.0, 0.0) fill_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1099,7 +1129,7 @@ func _build_inspector() -> void:
var outline_row := HBoxContainer.new() var outline_row := HBoxContainer.new()
outline_row.custom_minimum_size.y = float(row_height) 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() var outline_label := Label.new()
outline_label.text = "Outline:" outline_label.text = "Outline:"
outline_label.custom_minimum_size = Vector2(90.0, 0.0) outline_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1112,7 +1142,7 @@ func _build_inspector() -> void:
var width_row := HBoxContainer.new() var width_row := HBoxContainer.new()
width_row.custom_minimum_size.y = float(row_height) 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() var width_label := Label.new()
width_label.text = "Width:" width_label.text = "Width:"
width_label.custom_minimum_size = Vector2(90.0, 0.0) width_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1128,7 +1158,7 @@ func _build_inspector() -> void:
_radius_row = HBoxContainer.new() _radius_row = HBoxContainer.new()
_radius_row.custom_minimum_size.y = float(row_height) _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() var radius_label := Label.new()
radius_label.text = "Radius:" radius_label.text = "Radius:"
radius_label.custom_minimum_size = Vector2(90.0, 0.0) radius_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1145,10 +1175,10 @@ func _build_inspector() -> void:
_reshaped_note = Label.new() _reshaped_note = Label.new()
_reshaped_note.text = "✏️ This shape was reshaped" _reshaped_note.text = "✏️ This shape was reshaped"
_reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color()) _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() _vertex_label = Label.new()
_shape_section.add_child(_vertex_label) shape_content.add_child(_vertex_label)
_create_point_button = Button.new() _create_point_button = Button.new()
_create_point_button.text = "📍 Create Point" _create_point_button.text = "📍 Create Point"
@@ -1156,46 +1186,55 @@ func _build_inspector() -> void:
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height()) _create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop") _editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
_create_point_button.pressed.connect(_on_create_point_pressed) _create_point_button.pressed.connect(_on_create_point_pressed)
_shape_section.add_child(_create_point_button) shape_content.add_child(_create_point_button)
_create_point_hint = Label.new() _create_point_hint = Label.new()
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color()) _create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_create_point_hint) shape_content.add_child(_create_point_hint)
_delete_button = Button.new() _delete_button = Button.new()
_delete_button.text = "Erase Shape" _delete_button.text = "Erase Shape"
_delete_button.custom_minimum_size.y = float(_theme.button_min_height()) _delete_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop") _editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
_delete_button.pressed.connect(_on_delete_shape_pressed) _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-shape section (hidden in Reshape mode, Round 3)
_add_section = VBoxContainer.new() _add_section = _make_inspector_card()
_add_section.add_theme_constant_override("separation", 8)
_inspector_list.add_child(_add_section) _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() _primitive_option = PropInspectorDropdown.new()
add_title.text = "Add Shape" for index: int in PRIMITIVE_LABELS.size():
add_title.add_theme_font_size_override("font_size", header_size) _primitive_option.add_icon_item(
_add_section.add_child(add_title) PropInspectorIconsScript.shape(PRIMITIVE_IDS[index], _primitive_icon_color(PRIMITIVE_IDS[index])),
PRIMITIVE_LABELS[index]
_primitive_option = OptionButton.new() )
for label: String in PRIMITIVE_LABELS:
_primitive_option.add_item(label)
_primitive_option.custom_minimum_size.y = float(_theme.button_min_height()) _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) _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 = VBoxContainer.new()
_params_box.add_theme_constant_override("separation", 4) _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 = Button.new()
_add_button.text = "Add a Shape" _add_button.text = "Add a Shape"
_add_button.custom_minimum_size.y = float(_theme.button_min_height()) _add_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop") _editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
_add_button.pressed.connect(_on_add_pressed) _add_button.pressed.connect(_on_add_pressed)
_add_section.add_child(_add_button) add_content.add_child(_add_button)
_apply_control_theme(_inspector_list) _apply_control_theme(_inspector_list)
_rebuild_primitive_params() _rebuild_primitive_params()
@@ -1204,6 +1243,117 @@ func _build_inspector() -> void:
_refresh_tag_controls() _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: func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
if top_bar == null: if top_bar == null:
@@ -1244,29 +1394,30 @@ func _apply_authoring_context() -> void:
func _build_tag_inspector() -> void: func _build_tag_inspector() -> void:
_tag_section = VBoxContainer.new() _tag_section = _make_inspector_card()
_tag_section.add_theme_constant_override("separation", 4)
_inspector_list.add_child(_tag_section) _inspector_list.add_child(_tag_section)
var title := Label.new() var tag_content: VBoxContainer = _inspector_card_content(_tag_section)
title.text = "Artwork Tags" tag_content.add_child(_make_inspector_header("Artwork Tags", PropInspectorIconsScript.section("tags")))
title.add_theme_font_size_override("font_size", _theme.font_header())
_tag_section.add_child(title)
var hint := Label.new() var hint := Label.new()
hint.text = "Choose a few so this art is easy to find." hint.text = "Choose a few so this art is easy to find."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART 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() var rows := FlowContainer.new()
rows.add_theme_constant_override("h_separation", 6) rows.add_theme_constant_override("h_separation", 6)
rows.add_theme_constant_override("v_separation", 4) rows.add_theme_constant_override("v_separation", 6)
_tag_section.add_child(rows) tag_content.add_child(rows)
for index: int in ART_TAG_IDS.size(): for index: int in ART_TAG_IDS.size():
var tag_id: String = ART_TAG_IDS[index] var tag_id: String = ART_TAG_IDS[index]
var check := CheckBox.new() var tag_button := Button.new()
check.text = ART_TAG_LABELS[index] tag_button.text = ART_TAG_LABELS[index]
check.custom_minimum_size.y = float(_theme.button_min_height()) tag_button.toggle_mode = true
check.toggled.connect(_on_art_tag_toggled.bind(tag_id)) tag_button.custom_minimum_size = Vector2(122.0, 50.0)
rows.add_child(check) tag_button.icon = PropInspectorIconsScript.tag(tag_id)
_tag_checks[tag_id] = check 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: func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
@@ -1285,9 +1436,9 @@ func _refresh_tag_controls() -> void:
return return
_updating_inspector = true _updating_inspector = true
for tag_id: String in ART_TAG_IDS: for tag_id: String in ART_TAG_IDS:
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox var tag_button: Button = _tag_checks.get(tag_id) as Button
if check != null: if tag_button != null:
check.set_pressed_no_signal(_tags.has(tag_id)) tag_button.set_pressed_no_signal(_tags.has(tag_id))
_updating_inspector = false _updating_inspector = false
func _build_anchor_inspector() -> void: func _build_anchor_inspector() -> void:
@@ -1397,13 +1548,17 @@ func _on_rename_anchor_pressed() -> void:
_rename_anchor_source_id = _selected_anchor_id _rename_anchor_source_id = _selected_anchor_id
_rename_anchor_edit.text = _rename_anchor_source_id _rename_anchor_edit.text = _rename_anchor_source_id
_update_rename_anchor_confirmation(_rename_anchor_edit.text) _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("grab_focus")
_rename_anchor_edit.call_deferred("select_all") _rename_anchor_edit.call_deferred("select_all")
func _update_rename_anchor_confirmation(value: String) -> void: func _update_rename_anchor_confirmation(value: String) -> void:
if _rename_anchor_dialog != null: 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: func _commit_anchor_rename() -> void:
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id): if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
@@ -1463,10 +1618,10 @@ func _apply_control_theme(node: Node) -> void:
func _update_physics_labels() -> void: func _update_physics_labels() -> void:
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset) var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset) _mass_value.text = "%.1f" % PropBlockScript.mass_for(_material_preset)
_friction_label.text = "Surface grip: %.2f" % mat.friction _friction_value.text = "%.2f" % mat.friction
_friction_label.tooltip_text = "Higher values make sliding harder." _friction_label.tooltip_text = "Higher values make sliding harder."
_bounce_label.text = "Bounce: %.2f" % mat.bounce _bounce_value.text = "%.2f" % mat.bounce
func _on_preset_selected(index: int) -> void: func _on_preset_selected(index: int) -> void:
@@ -1786,14 +1941,16 @@ func _check_autosave_recovery() -> void:
var dialog := ConfirmationDialog.new() var dialog := ConfirmationDialog.new()
dialog.title = "Restore Prop?" dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?" dialog.set_meta(StageDialogStylerScript.ICON_META, "🔄")
dialog.ok_button_text = "🔄 Restore" dialog.dialog_text = "An unsaved prop was recovered. Restore this work?"
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard") dialog.ok_button_text = "Restore"
dialog.theme = _ui_theme var discard_button: Button = dialog.add_button("Discard", false, "discard")
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop") dialog.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
add_child(dialog) add_child(dialog)
StageDialogStylerScript.apply(
dialog, _dialog_theme, _dialog_theme.font(),
dialog.get_ok_button(), [discard_button]
)
dialog.confirmed.connect(func() -> void: dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true) _apply_json_data(raw, true)
_current_path = source_path _current_path = source_path
@@ -1803,7 +1960,8 @@ func _check_autosave_recovery() -> void:
if action == &"discard": if action == &"discard":
_clear_autosave() _clear_autosave()
dialog.queue_free()) dialog.queue_free())
dialog.popup_centered() dialog.canceled.connect(func() -> void: dialog.queue_free())
StageDialogStylerScript.popup(dialog)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1879,7 +2037,7 @@ func _begin_save_request(path: String, with_thumbnail: bool) -> void:
_pending_save_path = path _pending_save_path = path
_pending_save_with_thumbnail = with_thumbnail _pending_save_with_thumbnail = with_thumbnail
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()): 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 return
await _execute_pending_save(false) await _execute_pending_save(false)
@@ -1944,4 +2102,4 @@ func _on_load_file_selected(path: String) -> void:
func _show_error(message: String) -> void: func _show_error(message: String) -> void:
_error_dialog.dialog_text = message _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
+8 -6
View File
@@ -426,17 +426,19 @@ func _build_file_menu(parent: HBoxContainer) -> void:
var file_popup: PopupMenu = _file_menu.get_popup() var file_popup: PopupMenu = _file_menu.get_popup()
var popup_font_size: int = _theme_integer("fonts.sizes.file_menu_popup", 26, 12, 64) 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_theme_font_size_override("font_size", popup_font_size)
file_popup.add_item("New", 0) file_popup.add_item("🆕 New", 0)
file_popup.add_item("Open…", 1) file_popup.add_item("📂 Open…", 1)
var recent_popup := PopupMenu.new() var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent" recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", popup_font_size) recent_popup.add_theme_font_size_override("font_size", popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_requested) recent_popup.id_pressed.connect(_on_recent_scene_requested)
file_popup.add_child(recent_popup) file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent") file_popup.add_submenu_item("🕑 Open Recent", "OpenRecent")
file_popup.add_item("Save", 2) file_popup.add_separator()
file_popup.add_item("Save As…", 3) file_popup.add_item("💾 Save", 2)
file_popup.add_item("Rename Scene…", 4) 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.id_pressed.connect(_on_file_menu_requested)
file_popup.about_to_popup.connect(_on_recent_menu_requested) file_popup.about_to_popup.connect(_on_recent_menu_requested)
parent.add_child(_file_menu) parent.add_child(_file_menu)
+39 -17
View File
@@ -64,7 +64,7 @@ const ZOOM_STEP: float = 1.10
@onready var title_label: Label = %TitleLabel @onready var title_label: Label = %TitleLabel
@onready var drawing_area: Control = %DrawingArea @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_popup: PopupPanel = %ColorPickerPopup
@onready var color_picker: ColorPicker = %ColorPicker @onready var color_picker: ColorPicker = %ColorPicker
@onready var color_picker_button_row: HBoxContainer = %ButtonRow @onready var color_picker_button_row: HBoxContainer = %ButtonRow
@@ -127,6 +127,8 @@ func _ready() -> void:
cancel_button.pressed.connect(_on_color_picker_cancel) cancel_button.pressed.connect(_on_color_picker_cancel)
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden): if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
context_menu.popup_hide.connect(_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): if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden) color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
cancel_button.text = "Cancel" cancel_button.text = "Cancel"
@@ -298,6 +300,8 @@ func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo:
_hint_color = hint_color _hint_color = hint_color
_button_target_height = button_target_height _button_target_height = button_target_height
_editor_visual_theme = editor_visual_theme _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: if drawing_area != null:
drawing_area.queue_redraw() drawing_area.queue_redraw()
@@ -421,6 +425,24 @@ func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom 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: func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size) var gs := float(_grid_size)
if gs <= 0.0: if gs <= 0.0:
@@ -638,9 +660,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
if hit_idx >= 0: if hit_idx >= 0:
_context_shape_idx = _selected_shape_idx _context_shape_idx = _selected_shape_idx
context_menu.clear() 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.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 return
for i: int in range(shapes.size() - 1, -1, -1): for i: int in range(shapes.size() - 1, -1, -1):
@@ -651,9 +673,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
if hit_idx >= 0: if hit_idx >= 0:
_context_shape_idx = i _context_shape_idx = i
context_menu.clear() 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.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 return
# Step 2: Check if right-click is over any shape (reverse order = front first). # Step 2: Check if right-click is over any shape (reverse order = front first).
@@ -677,22 +699,22 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
context_menu.add_item("Create Point", 3) context_menu.add_item("Create Point", 3)
context_menu.add_separator() context_menu.add_separator()
context_menu.add_item("Color...", 4) context_menu.add_item("Change Color…", 4)
context_menu.add_separator() context_menu.add_separator()
context_menu.add_item("Send Back", 7) context_menu.add_item("Copy Shape", 9)
context_menu.add_item("Bring Forward", 8)
context_menu.add_separator()
context_menu.add_item("Copy", 9)
if _has_clipboard: if _has_clipboard:
context_menu.add_item("Paste", 10) context_menu.add_item("Paste Shape", 10)
context_menu.add_separator() context_menu.add_separator()
context_menu.add_item("Mirror X", 11) context_menu.add_item("Flip Left-Right", 11)
context_menu.add_item("Mirror Y", 12) context_menu.add_item("Flip Up-Down", 12)
context_menu.add_separator() 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.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 return
# Step 3: No shape under mouse -> shape creation menu. # Step 3: No shape under mouse -> shape creation menu.
@@ -702,9 +724,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
context_menu.add_item("Circle", 2) context_menu.add_item("Circle", 2)
if _has_clipboard: if _has_clipboard:
context_menu.add_separator() 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.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: func _handle_left_press(world_pos: Vector2) -> void:
+60 -33
View File
@@ -17,6 +17,8 @@ extends Control
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd") const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_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")
enum WorkspaceView { OVERVIEW, FOCUS } enum WorkspaceView { OVERVIEW, FOCUS }
enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT } enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT }
@@ -110,11 +112,10 @@ const WORKSPACE_GAP: float = 8.0
@onready var _overview_scroll: ScrollContainer = %OverviewScroll @onready var _overview_scroll: ScrollContainer = %OverviewScroll
@onready var _focus_host: VBoxContainer = %FocusHost @onready var _focus_host: VBoxContainer = %FocusHost
@onready var _focus_panel_host: Control = %FocusPanelHost @onready var _focus_panel_host: Control = %FocusPanelHost
@onready var _focus_part_label: Label = %FocusPartLabel
@onready var _workspace_row: HSplitContainer = %WorkspaceRow @onready var _workspace_row: HSplitContainer = %WorkspaceRow
@onready var _overview_button: Button = %OverviewButton @onready var _overview_button: Button = %OverviewButton
@onready var _focus_button: Button = %FocusButton @onready var _focus_button: Button = %FocusButton
@onready var _part_picker: OptionButton = %PartPicker @onready var _part_picker: EditorSelectionDropdown = %PartPicker
@onready var _add_shape_button: Button = %AddShapeButton @onready var _add_shape_button: Button = %AddShapeButton
@onready var _color_button: Button = %ColorButton @onready var _color_button: Button = %ColorButton
@onready var _whole_stickman_button: Button = %WholeStickmanButton @onready var _whole_stickman_button: Button = %WholeStickmanButton
@@ -132,6 +133,7 @@ const WORKSPACE_GAP: float = 8.0
@onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog @onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog
var _editor_visual_theme: EditorVisualTheme var _editor_visual_theme: EditorVisualTheme
var _dialog_theme: SandboxTheme
var _body_part_origins: Dictionary = {} var _body_part_origins: Dictionary = {}
var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW
var _view_choice_was_manual: bool = false var _view_choice_was_manual: bool = false
@@ -173,6 +175,8 @@ var _view_menu: PopupMenu
func _ready() -> void: func _ready() -> void:
_editor_visual_theme = EditorVisualThemeScript.new() _editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH) _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") theme = _editor_visual_theme.control_theme(&"stickman")
_setup_menu_bar() _setup_menu_bar()
_setup_dialogs() _setup_dialogs()
@@ -188,6 +192,7 @@ func _ready() -> void:
_editor_visual_theme.stickman_canvas_color(&"selection_halo"), _editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"preview", &"stickman") _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) _stickman_name_edit.text_changed.connect(_on_stickman_name_changed)
_load_settings() _load_settings()
@@ -233,17 +238,18 @@ func _process(_delta: float) -> void:
func _setup_menu_bar() -> void: func _setup_menu_bar() -> void:
var file_menu: PopupMenu = PopupMenu.new() var file_menu: PopupMenu = PopupMenu.new()
file_menu.name = "FileMenu" 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_separator()
file_menu.add_item("Start Over", 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) file_menu.id_pressed.connect(_on_file_menu_id_pressed)
_menu_bar.add_child(file_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")
_edit_menu = PopupMenu.new() _edit_menu = PopupMenu.new()
_edit_menu.name = "EditMenu" _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_separator()
_edit_menu.add_item(_snap_menu_label(), 1) _edit_menu.add_item(_snap_menu_label(), 1)
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed) _edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
@@ -253,7 +259,7 @@ func _setup_menu_bar() -> void:
var view_menu: PopupMenu = PopupMenu.new() var view_menu: PopupMenu = PopupMenu.new()
view_menu.name = "ViewMenu" 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_separator()
view_menu.add_item(_guide_menu_label(), 1) view_menu.add_item(_guide_menu_label(), 1)
view_menu.id_pressed.connect(_on_view_menu_id_pressed) view_menu.id_pressed.connect(_on_view_menu_id_pressed)
@@ -266,10 +272,6 @@ func _setup_menu_bar() -> void:
func _setup_dialogs() -> void: func _setup_dialogs() -> void:
_save_dialog.theme = theme _save_dialog.theme = theme
_load_dialog.theme = theme _load_dialog.theme = theme
_clear_confirm.theme = theme
_unsaved_dialog.theme = theme
_error_dialog.theme = theme
_grid_config_dialog.theme = theme
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR) var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
_save_dialog.current_dir = content_dir _save_dialog.current_dir = content_dir
_load_dialog.current_dir = content_dir _load_dialog.current_dir = content_dir
@@ -280,17 +282,34 @@ func _setup_dialogs() -> void:
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"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") _editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"stickman")
_clear_confirm.confirmed.connect(_on_clear_confirmed) _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" _clear_confirm.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(_clear_confirm.get_ok_button(), &"destructive", &"stickman") StageDialogStylerScript.apply(
_editor_visual_theme.style_button_role(_clear_confirm.get_cancel_button(), &"secondary", &"stickman") _clear_confirm, _dialog_theme, _dialog_theme.font(),
var discard_button: Button = _unsaved_dialog.add_button("Discard Changes", false, "discard") 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.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(_unsaved_dialog.get_ok_button(), &"primary", &"stickman") _unsaved_dialog.ok_button_text = "Save"
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"stickman") StageDialogStylerScript.apply(
_editor_visual_theme.style_button_role(_unsaved_dialog.get_cancel_button(), &"secondary", &"stickman") _unsaved_dialog, _dialog_theme, _dialog_theme.font(),
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"stickman") _unsaved_dialog.get_ok_button(), [discard_button]
_editor_visual_theme.style_button_role(_grid_config_dialog.get_ok_button(), &"primary", &"stickman") )
_editor_visual_theme.style_button_role(_grid_config_dialog.get_cancel_button(), &"secondary", &"stickman") _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.confirmed.connect(_on_unsaved_save_pressed)
_unsaved_dialog.custom_action.connect(_on_unsaved_custom_action) _unsaved_dialog.custom_action.connect(_on_unsaved_custom_action)
_unsaved_dialog.canceled.connect(_cancel_pending_document_action) _unsaved_dialog.canceled.connect(_cancel_pending_document_action)
@@ -303,14 +322,24 @@ func _setup_workspace_controls() -> void:
_focus_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")) _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")) _focus_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"mode_button_min_height", &"stickman"))
_part_picker.custom_minimum_size = Vector2(160.0, float(_editor_visual_theme.chrome_px(&"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")) _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") _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")) _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") _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")) _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")) _back_to_part_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_focus_part_label.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"section", &"stickman"))
_status_cursor_coords.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"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_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")) _status_snap_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
@@ -324,9 +353,9 @@ func _setup_workspace_controls() -> void:
_whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed) _whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed)
_back_to_part_button.pressed.connect(_on_back_to_part_button_pressed) _back_to_part_button.pressed.connect(_on_back_to_part_button_pressed)
_add_shape_menu = PopupMenu.new() _add_shape_menu = PopupMenu.new()
_add_shape_menu.add_item("Line", 0) _add_shape_menu.add_item("〰️ Line", 0)
_add_shape_menu.add_item("Rectangle", 1) _add_shape_menu.add_item("▭ Rectangle", 1)
_add_shape_menu.add_item("Circle", 2) _add_shape_menu.add_item("⚪ Circle", 2)
_add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed) _add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed)
_add_shape_menu.theme = theme _add_shape_menu.theme = theme
add_child(_add_shape_menu) add_child(_add_shape_menu)
@@ -526,8 +555,6 @@ func _update_active_part_presentation() -> void:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel != null: if panel != null:
panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size) panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size)
if is_instance_valid(_focus_part_label):
_focus_part_label.text = BODY_PART_DISPLAY.get(_active_part_name, _active_part_name)
func _refresh_workspace_controls() -> void: func _refresh_workspace_controls() -> void:
@@ -686,7 +713,7 @@ func _request_document_action(action: PendingDocumentAction) -> void:
_unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?" _unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?"
PendingDocumentAction.QUIT: PendingDocumentAction.QUIT:
_unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?" _unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?"
_unsaved_dialog.popup_centered() StageDialogStylerScript.popup(_unsaved_dialog)
return return
_continue_document_action(action) _continue_document_action(action)
@@ -696,7 +723,7 @@ func _continue_document_action(action: PendingDocumentAction) -> void:
PendingDocumentAction.LOAD: PendingDocumentAction.LOAD:
_load_dialog.popup_centered_ratio(0.6) _load_dialog.popup_centered_ratio(0.6)
PendingDocumentAction.CLEAR: PendingDocumentAction.CLEAR:
_clear_confirm.popup_centered() StageDialogStylerScript.popup(_clear_confirm)
PendingDocumentAction.QUIT: PendingDocumentAction.QUIT:
get_tree().quit() get_tree().quit()
_pending_document_action = PendingDocumentAction.NONE _pending_document_action = PendingDocumentAction.NONE
@@ -763,7 +790,7 @@ func _on_edit_menu_id_pressed(id: int) -> void:
match id: match id:
0: # Configure Grid... 0: # Configure Grid...
_grid_size_spin_box.value = float(_grid_size) _grid_size_spin_box.value = float(_grid_size)
_grid_config_dialog.popup_centered() StageDialogStylerScript.popup(_grid_config_dialog)
1: # Snap to Grid (checkable) 1: # Snap to Grid (checkable)
_snap_enabled = not _snap_enabled _snap_enabled = not _snap_enabled
if _edit_menu: if _edit_menu:
@@ -808,7 +835,7 @@ func _update_guide_menu_item() -> void:
func _guide_menu_label() -> String: 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: func _on_grid_config_confirmed() -> void:
@@ -862,7 +889,7 @@ func _on_clear_confirmed() -> void:
func _show_error(message: String) -> void: func _show_error(message: String) -> void:
_error_dialog.dialog_text = message _error_dialog.dialog_text = message
_error_dialog.popup_centered() StageDialogStylerScript.popup(_error_dialog)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signal handlers - body-part panels # Signal handlers - body-part panels
@@ -1198,7 +1225,7 @@ func _broadcast_settings() -> void:
func _snap_menu_label() -> String: 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: func _update_snap_status_label() -> void:
@@ -26,6 +26,7 @@ signal part_order_changed(part_name: String, new_order: Array[String])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15) 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 MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0 const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10 const ZOOM_STEP: float = 1.10
@@ -142,7 +143,7 @@ var _pan_start: Vector2 = Vector2.ZERO
# Phase 5: z-ordering # Phase 5: z-ordering
var _part_order: Array[String] = [] var _part_order: Array[String] = []
var _context_menu: PopupMenu var _context_menu: EditorContextMenu
# Phase 6: selection gizmos rendered on top # Phase 6: selection gizmos rendered on top
var _selected_gizmo_bounds: Rect2 = Rect2() var _selected_gizmo_bounds: Rect2 = Rect2()
@@ -289,6 +290,11 @@ func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo:
preview_area.queue_redraw() 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 ## Returns the preview-space position of a guide joint (a master-space
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against ## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
## unknown joint names. ## unknown joint names.
@@ -334,7 +340,7 @@ func _ready() -> void:
if not preview_area.gui_input.is_connected(_on_preview_gui_input): if not preview_area.gui_input.is_connected(_on_preview_gui_input):
preview_area.gui_input.connect(_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" _context_menu.name = "PreviewContextMenu"
add_child(_context_menu) add_child(_context_menu)
_context_menu.id_pressed.connect(_on_context_menu_id_pressed) _context_menu.id_pressed.connect(_on_context_menu_id_pressed)
@@ -1024,12 +1030,14 @@ func _handle_right_click(mb: InputEventMouseButton) -> void:
if rect.has_point(world_pos): if rect.has_point(world_pos):
_selected_part = part_name _selected_part = part_name
_context_menu.clear() _context_menu.clear()
_context_menu.add_item("Send Back", 0)
_context_menu.add_item("Bring Forward", 1) _context_menu.add_item("Bring Forward", 1)
_context_menu.add_item("Send Back", 0)
_context_menu.add_separator() _context_menu.add_separator()
_context_menu.add_item("Mirror X", 2) _context_menu.add_item("Flip Left-Right", 2)
_context_menu.add_item("Mirror Y", 3) _context_menu.add_item("Flip Up-Down", 3)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE)) 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() preview_area.queue_redraw()
return return
@@ -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
@@ -13,6 +13,7 @@ const DEFAULT_CONFIG: Dictionary = {
"background": "#171B24F2", "background": "#171B24F2",
"surface": "#252B38FA", "surface": "#252B38FA",
"surface_hover": "#30394A", "surface_hover": "#30394A",
"selected_surface": "#103B4D",
"text": "#F5F7FA", "text": "#F5F7FA",
"text_muted": "#AAB2C0", "text_muted": "#AAB2C0",
"edit": "#22C6FF", "edit": "#22C6FF",
@@ -32,6 +33,8 @@ const DEFAULT_CONFIG: Dictionary = {
"section": 22, "section": 22,
"title": 26, "title": 26,
"menu": 20, "menu": 20,
"menu_bar": 22,
"menu_popup": 26,
"status": 16, "status": 16,
"mode": 24, "mode": 24,
"prop_name": 20, "prop_name": 20,
@@ -40,7 +43,13 @@ const DEFAULT_CONFIG: Dictionary = {
"spacing": {"small": 8, "medium": 12, "large": 16}, "spacing": {"small": 8, "medium": 12, "large": 16},
"radii": {"control": 8, "panel": 10}, "radii": {"control": 8, "panel": 10},
"borders": {"normal": 1, "focus": 2}, "borders": {"normal": 1, "focus": 2},
"targets": {"button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56}, "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": {}}, "editors": {"prop": {}, "stickman": {}},
"stickman_canvas": { "stickman_canvas": {
@@ -54,6 +63,7 @@ const COLOR_ROLE_SECTIONS: Dictionary = {
"background": "colors", "background": "colors",
"surface": "colors", "surface": "colors",
"surface_hover": "colors", "surface_hover": "colors",
"selected_surface": "colors",
"text": "colors", "text": "colors",
"text_muted": "colors", "text_muted": "colors",
"edit": "colors", "edit": "colors",
@@ -73,6 +83,8 @@ const SIZE_ROLE_SECTIONS: Dictionary = {
"section": "fonts", "section": "fonts",
"title": "fonts", "title": "fonts",
"menu": "fonts", "menu": "fonts",
"menu_bar": "fonts",
"menu_popup": "fonts",
"status": "fonts", "status": "fonts",
"mode": "fonts", "mode": "fonts",
"prop_name": "fonts", "prop_name": "fonts",
@@ -87,6 +99,8 @@ const SIZE_ROLE_SECTIONS: Dictionary = {
"button_min_height": "targets", "button_min_height": "targets",
"row_min_height": "targets", "row_min_height": "targets",
"mode_button_min_height": "targets", "mode_button_min_height": "targets",
"context_menu_row_height": "targets",
"context_menu_min_width": "targets",
} }
const CONFIG_SIZE_KEYS: Dictionary = { const CONFIG_SIZE_KEYS: Dictionary = {
"normal_border": "normal", "normal_border": "normal",
@@ -208,13 +222,12 @@ func control_theme(editor: StringName) -> Theme:
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor)) 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) 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", "PanelContainer", panel_box)
theme.set_stylebox("normal", "PopupMenu", panel_box.duplicate())
theme.set_stylebox("panel", "MenuBar", panel_box.duplicate()) theme.set_stylebox("panel", "MenuBar", panel_box.duplicate())
theme.set_font_size("font_size", "Label", chrome_px(&"body", editor)) theme.set_font_size("font_size", "Label", chrome_px(&"body", editor))
theme.set_color("font_color", "Label", text) theme.set_color("font_color", "Label", text)
theme.set_color("font_hover_color", "Label", text) theme.set_color("font_hover_color", "Label", text)
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu", editor)) theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu_popup", editor))
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu", editor)) theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu_bar", editor))
theme.set_color("font_color", "MenuBar", text) theme.set_color("font_color", "MenuBar", text)
return theme return theme
+38 -13
View File
@@ -1,7 +1,7 @@
class_name StageDialogStyler class_name StageDialogStyler
extends RefCounted extends RefCounted
## Renders child-facing dialogs as a shared in-stage card while preserving the ## Renders child-facing stage and editor dialogs as a shared card while preserving
## native AcceptDialog/ConfirmationDialog signals used by the stage workflows. ## the native AcceptDialog/ConfirmationDialog signals used by their workflows.
const THEME_DEFAULTS := preload("res://scripts/stage/sandbox_theme.gd") const THEME_DEFAULTS := preload("res://scripts/stage/sandbox_theme.gd")
const OVERLAY_META := &"stage_dialog_overlay" const OVERLAY_META := &"stage_dialog_overlay"
@@ -20,7 +20,9 @@ const ICON_META := &"stage_dialog_icon"
const ICON_LABEL_META := &"stage_dialog_icon_label" const ICON_LABEL_META := &"stage_dialog_icon_label"
const ICON_BADGE_META := &"stage_dialog_icon_badge" const ICON_BADGE_META := &"stage_dialog_icon_badge"
const PRIMARY_META := &"stage_dialog_primary_button" const PRIMARY_META := &"stage_dialog_primary_button"
const SECONDARY_META := &"stage_dialog_secondary_buttons"
const DESTRUCTIVE_META := &"stage_dialog_destructive_buttons" const DESTRUCTIVE_META := &"stage_dialog_destructive_buttons"
const DEFAULT_PRIMARY_META := &"stage_dialog_default_primary"
const OPEN_META := &"stage_dialog_open" const OPEN_META := &"stage_dialog_open"
@@ -29,7 +31,9 @@ static func apply(
stage_theme: SandboxTheme, stage_theme: SandboxTheme,
ui_font: Font, ui_font: Font,
primary_button: Button = null, primary_button: Button = null,
destructive_buttons: Array = [] destructive_buttons: Array = [],
secondary_buttons: Array = [],
default_ok_is_primary: bool = true
) -> void: ) -> void:
if dialog == null: if dialog == null:
return return
@@ -37,10 +41,14 @@ static func apply(
if not dialog.has_meta(OVERLAY_META): if not dialog.has_meta(OVERLAY_META):
_install_dialog_surface(dialog, active_theme, ui_font) _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 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(PRIMARY_META, primary)
dialog.set_meta(SECONDARY_META, secondary_buttons.duplicate())
dialog.set_meta(DESTRUCTIVE_META, destructive_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: static func popup(dialog: Window) -> void:
@@ -48,9 +56,11 @@ static func popup(dialog: Window) -> void:
return return
var active_theme: SandboxTheme = _theme_for(dialog) 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 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 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 var overlay: Control = dialog.get_meta(OVERLAY_META) as Control
if overlay == null: if overlay == null:
return return
@@ -71,8 +81,10 @@ static func popup(dialog: Window) -> void:
if is_instance_valid(tree): if is_instance_valid(tree):
tree.process_frame.connect(fit_after_layout, Object.CONNECT_ONE_SHOT) tree.process_frame.connect(fit_after_layout, Object.CONNECT_ONE_SHOT)
tree.process_frame.connect(defer_fit_one_frame, 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) 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: if focus_target != null and focus_target.visible:
focus_target.call_deferred("grab_focus") focus_target.call_deferred("grab_focus")
@@ -232,6 +244,7 @@ static func _install_dialog_surface(dialog: Window, stage_theme: SandboxTheme, u
var overlay := Control.new() var overlay := Control.new()
overlay.name = "StageDialogOverlay" overlay.name = "StageDialogOverlay"
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
overlay.theme = stage_theme.control_theme()
overlay.mouse_filter = Control.MOUSE_FILTER_STOP overlay.mouse_filter = Control.MOUSE_FILTER_STOP
overlay.z_index = 100 overlay.z_index = 100
overlay.z_as_relative = false overlay.z_as_relative = false
@@ -385,7 +398,9 @@ static func _refresh_dialog_surface(
stage_theme: SandboxTheme, stage_theme: SandboxTheme,
ui_font: Font, ui_font: Font,
primary_button: Button = null, primary_button: Button = null,
destructive_buttons: Array = [] destructive_buttons: Array = [],
secondary_buttons: Array = [],
default_ok_is_primary: bool = true
) -> void: ) -> void:
if not dialog.has_meta(OVERLAY_META): if not dialog.has_meta(OVERLAY_META):
return return
@@ -458,16 +473,24 @@ static func _refresh_dialog_surface(
for old_button: Node in action_row.get_children(): for old_button: Node in action_row.get_children():
action_row.remove_child(old_button) action_row.remove_child(old_button)
old_button.queue_free() 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: if ok_button != null:
ok_button.visible = false ok_button.visible = false
if cancel_button != null: if cancel_button != null:
cancel_button.visible = false 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: for raw_button: Variant in destructive_buttons:
if raw_button is Button: if raw_button is Button:
(raw_button as Button).visible = false (raw_button as Button).visible = false
var ordered: Array[Button] = [] var ordered: Array[Button] = []
_append_unique_button(ordered, primary) _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: for raw_destructive: Variant in destructive_buttons:
if raw_destructive is Button: if raw_destructive is Button:
_append_unique_button(ordered, raw_destructive as Button) _append_unique_button(ordered, raw_destructive as Button)
@@ -476,6 +499,8 @@ static func _refresh_dialog_surface(
for original: Button in ordered: for original: Button in ordered:
var visual: Button = Button.new() var visual: Button = Button.new()
visual.text = original.text visual.text = original.text
visual.disabled = original.disabled
visual.tooltip_text = original.tooltip_text
visual.name = "Action_%s" % String(original.name) visual.name = "Action_%s" % String(original.name)
visual.size_flags_horizontal = Control.SIZE_SHRINK_END visual.size_flags_horizontal = Control.SIZE_SHRINK_END
var role: StringName = &"secondary" var role: StringName = &"secondary"
@@ -535,18 +560,18 @@ static func _find_overlay_host(dialog: Window) -> Node:
return ancestor return ancestor
ancestor = ancestor.get_parent() ancestor = ancestor.get_parent()
var tree: SceneTree = dialog.get_tree() 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: if scene != null:
var ui_layer: Node = scene.get_node_or_null("UI") var ui_layer: Node = scene.get_node_or_null("UI")
if ui_layer is CanvasLayer: if ui_layer is CanvasLayer:
return ui_layer 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: if existing is CanvasLayer:
return existing return existing
var layer := CanvasLayer.new() var layer := CanvasLayer.new()
layer.name = "StageDialogLayer" layer.name = "StageDialogLayer"
layer.layer = 100 layer.layer = 100
scene.add_child(layer) scene.add_child.call_deferred(layer)
return layer return layer
return null return null
@@ -33,6 +33,10 @@ var _grid_gap: int = 8
var _compact_choice_height: int = 54 var _compact_choice_height: int = 54
var _compact_choice_preview_height: int = 22 var _compact_choice_preview_height: int = 22
var _compact_choice_font_size: int = 16 var _compact_choice_font_size: int = 16
var _choice_content_inset_x: int = 8
var _choice_content_inset_y: int = 6
var _compact_choice_content_inset: int = 4
var _compact_choice_content_separation: int = 2
var _compact_grid_gap: int = 6 var _compact_grid_gap: int = 6
var _compact_layout_max_width: int = 600 var _compact_layout_max_width: int = 600
var _compact_layout_max_height: int = 460 var _compact_layout_max_height: int = 460
@@ -107,6 +111,10 @@ func _configure_theme_values() -> void:
_compact_choice_height = _theme_int("compact_choice_height", 50, 48, 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_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_choice_font_size = _theme_int("compact_choice_font_size", 16, 14, 20)
_choice_content_inset_x = _theme_int("choice_content_inset_x", 8, 4, 20)
_choice_content_inset_y = _theme_int("choice_content_inset_y", 6, 3, 16)
_compact_choice_content_inset = _theme_int("compact_choice_content_inset", 4, 3, 12)
_compact_choice_content_separation = _theme_int("compact_choice_content_separation", 2, 0, 8)
_compact_grid_gap = _theme_int("compact_grid_gap", 6, 4, 12) _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_width = _theme_int("compact_layout_max_width", 600, 440, 760)
_compact_layout_max_height = _theme_int("compact_layout_max_height", 460, 360, 640) _compact_layout_max_height = _theme_int("compact_layout_max_height", 460, 360, 640)
@@ -158,7 +166,9 @@ func _build_choice_buttons() -> void:
button.focus_mode = Control.FOCUS_ALL button.focus_mode = Control.FOCUS_ALL
_apply_choice_style(button) _apply_choice_style(button)
var contents := VBoxContainer.new() var contents := VBoxContainer.new()
contents.name = "ChoiceContent"
contents.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) contents.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
contents.clip_contents = true
contents.add_theme_constant_override("separation", 4) contents.add_theme_constant_override("separation", 4)
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sample := GROUND_PREVIEW.new() as StageGroundFinishPreview var sample := GROUND_PREVIEW.new() as StageGroundFinishPreview
@@ -327,13 +337,28 @@ func _apply_choice_density(compact: bool) -> void:
var button: Button = _choice_buttons[finish_id] as Button var button: Button = _choice_buttons[finish_id] as Button
button.custom_minimum_size.y = target_height button.custom_minimum_size.y = target_height
_apply_choice_style(button) _apply_choice_style(button)
var inset_y: float = float(_compact_choice_content_inset if compact else _choice_content_inset_y)
var content_separation: int = _compact_choice_content_separation if compact else 4
var label_height: float = 18.0 if compact else 20.0
var available_preview_height: float = maxf(
16.0,
target_height - inset_y * 2.0 - label_height - float(content_separation)
)
var preview_height: float = minf(target_preview_height, available_preview_height)
var contents: VBoxContainer = button.get_node_or_null("ChoiceContent") as VBoxContainer
if contents != null:
contents.offset_left = float(_choice_content_inset_x)
contents.offset_top = inset_y
contents.offset_right = -float(_choice_content_inset_x)
contents.offset_bottom = -inset_y
contents.add_theme_constant_override("separation", content_separation)
var sample: StageGroundFinishPreview = _choice_previews[finish_id] as StageGroundFinishPreview var sample: StageGroundFinishPreview = _choice_previews[finish_id] as StageGroundFinishPreview
if sample != null: if sample != null:
sample.custom_minimum_size.y = target_preview_height sample.custom_minimum_size.y = preview_height
var label: Label = _choice_labels[finish_id] as Label var label: Label = _choice_labels[finish_id] as Label
if label != null: if label != null:
label.add_theme_font_size_override("font_size", target_font_size) label.add_theme_font_size_override("font_size", target_font_size)
label.custom_minimum_size.y = 18.0 if compact else 20.0 label.custom_minimum_size.y = label_height
func _focus_selected_choice() -> void: func _focus_selected_choice() -> void:
@@ -363,9 +388,9 @@ func _choice_box(background: Color, border: Color, border_width: int) -> StyleBo
style.border_color = border style.border_color = border
style.set_border_width_all(border_width) style.set_border_width_all(border_width)
style.set_corner_radius_all(_theme_int("choice_corner_radius", 12, 8, 24)) style.set_corner_radius_all(_theme_int("choice_corner_radius", 12, 8, 24))
style.content_margin_left = 8.0 style.content_margin_left = float(_choice_content_inset_x)
style.content_margin_right = 8.0 style.content_margin_right = float(_choice_content_inset_x)
var vertical_padding: float = 2.0 if _is_compact_layout else 6.0 var vertical_padding: float = float(_compact_choice_content_inset) if _is_compact_layout else float(_choice_content_inset_y)
style.content_margin_top = vertical_padding style.content_margin_top = vertical_padding
style.content_margin_bottom = vertical_padding style.content_margin_bottom = vertical_padding
return style return style
+4
View File
@@ -134,9 +134,13 @@ func _apply_phase19_defaults() -> void:
"choice_font_size": 18, "choice_font_size": 18,
"choice_min_width": 104, "choice_min_width": 104,
"choice_corner_radius": 12, "choice_corner_radius": 12,
"choice_content_inset_x": 8,
"choice_content_inset_y": 6,
"compact_choice_height": 50, "compact_choice_height": 50,
"compact_choice_preview_height": 22, "compact_choice_preview_height": 22,
"compact_choice_font_size": 16, "compact_choice_font_size": 16,
"compact_choice_content_inset": 4,
"compact_choice_content_separation": 2,
"compact_grid_gap": 6, "compact_grid_gap": 6,
"compact_layout_max_width": 600, "compact_layout_max_width": 600,
"compact_layout_max_height": 460, "compact_layout_max_height": 460,
+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
+46 -2
View File
@@ -2,6 +2,7 @@ extends SceneTree
const STICKMAN_SCENE := preload("res://scenes/main.tscn") const STICKMAN_SCENE := preload("res://scenes/main.tscn")
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.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 _checks: int = 0
var _failures: int = 0 var _failures: int = 0
@@ -171,7 +172,7 @@ func _test_stickman_workspace_and_transactions() -> void:
check("second shape can be authored", bool(head.call("add_shape", &"line", head.call("drawing_center_world")))) 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) editor.call("_on_file_menu_id_pressed", 2)
var unsaved_dialog: ConfirmationDialog = editor.get("_unsaved_dialog") as ConfirmationDialog var unsaved_dialog: ConfirmationDialog = editor.get("_unsaved_dialog") as ConfirmationDialog
check("Start Over shows a save/discard/cancel transaction", unsaved_dialog.visible) check("Start Over shows a save/discard/cancel transaction", STAGE_DIALOG_STYLER.is_open(unsaved_dialog))
unsaved_dialog.get_cancel_button().emit_signal("pressed") unsaved_dialog.get_cancel_button().emit_signal("pressed")
await process_frame await process_frame
check("Cancel leaves dirty content in place", bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).size() == 2) check("Cancel leaves dirty content in place", bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).size() == 2)
@@ -197,7 +198,50 @@ func _test_prop_reshape_and_chrome() -> void:
check("Prop action target meets 44 px minimum", (editor.get("_create_point_button") as Button).custom_minimum_size.y >= 44.0) 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("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("whole-prop action uses approved discoverable wording", str((editor.get("_whole_prop_resize_button") as Button).text) == "Move, Turn, or Resize Prop")
check("material inspector uses child-facing grip language", str((editor.get("_friction_label") as Label).text).begins_with("Surface grip:")) 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:") 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)) canvas.call("add_primitive", "box", Vector2(150.0, 150.0))
+3
View File
@@ -15,6 +15,9 @@ func _run() -> void:
check("committed shared theme loads", defaults.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH)) 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 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("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)) 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_path: String = "user://editor_visual_theme_test.json"