Phase 14 - Organized directories.
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
# Phase 9 Prop Editor — Round 3 (Reshape-Mode Menu, Window Chrome, Exit & Autosave)
|
||||
|
||||
Status: **approved** (2026-09-18; all nine open choices resolved as proposed).
|
||||
|
||||
Source: twelve user feedback points against the shipped Round 2 Prop Editor
|
||||
(`scripts/editors/prop/prop_canvas.gd`, `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_theme.gd`,
|
||||
`prop_theme.json`, `scenes/editors/prop/prop_editor.tscn`). Autosave/exit reference:
|
||||
`scripts/editors/stage/sandbox_stage.gd` + `scripts/stage/stage_scene_io.gd`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Feedback summary (verbatim intent)
|
||||
|
||||
1. In "Edit Points" mode, right-clicking some segments shows the add-shape menu
|
||||
instead of Create Point — a segment right-click should always offer creating a point.
|
||||
2. A created point sometimes appears on a different segment; it must appear where the
|
||||
cursor was when created.
|
||||
3. In "Edit Points" mode, the RMB menu must ONLY offer point editing: "Create Point"
|
||||
(segment) / "Remove Point" (vertex). No Copy/Erase/Flip/z-order items.
|
||||
4. In "Edit Points" mode, do NOT allow adding shapes (empty-canvas RMB shows nothing).
|
||||
5. In "Edit Points" mode, the inspector must NOT allow adding or erasing a shape
|
||||
(hide the Add-Shape section and the Erase Shape button; sync on `mode_changed`).
|
||||
6. Rename modes: "Move & Resize" → **"Arrange"**, "Edit Points" → **"Reshape"**
|
||||
(enum values stay TRANSFORM/POINTS — only labels/emoji change).
|
||||
7. Mode toggle becomes a segmented **[ Arrange | Reshape ]** pair **centered** in the
|
||||
ModeBar, with a **bigger font** and a **related emoji** on each button.
|
||||
8. "View → Reset Views" becomes **"View → Reset View"** (singular).
|
||||
9. File/Edit/View menu bar + items get a **bigger font** (theme-driven) plus
|
||||
**kid-friendly emoji** in labels.
|
||||
10. **Window title** = `[Prop name][filename.prop][*]`: name (default **"Untitled"**),
|
||||
filename (basename with `.prop`, only when saved), `*` when modified.
|
||||
11. **Exit dialog**: intercept window close while modified; **Discard / Save / Cancel**
|
||||
with emoji; async save-before-quit.
|
||||
12. **Autosave/recovery**: temp prop file on modification, debounced; startup restore
|
||||
prompt; explicit clear semantics.
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnoses (verified root causes)
|
||||
|
||||
### 1.1 Point 2 — created point lands on a different segment
|
||||
|
||||
`PropCanvas._get_context_world_pos()` (`scripts/editors/prop/prop_canvas.gd:1102-1108`) discards any
|
||||
stored click position whose world coordinates fall outside the visible `[0,0 .. size/zoom]`
|
||||
rect and replaces it with the **canvas center**:
|
||||
|
||||
```gdscript
|
||||
func _get_context_world_pos() -> Vector2:
|
||||
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2(100.0, 60.0))
|
||||
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2(100.0, 60.0)
|
||||
var world_size := size / _zoom
|
||||
if world_pos.x < 0.0 or world_pos.y < 0.0 or world_pos.x > world_size.x or world_pos.y > world_size.y:
|
||||
return _screen_to_world(size * 0.5)
|
||||
return world_pos
|
||||
```
|
||||
|
||||
Negative world coordinates are **normal after panning** (shapes live around the world
|
||||
origin). Right-clicking a segment at e.g. `(-40, 120)` stores a valid world position that is
|
||||
then thrown away, so `create_point_on_edge` (called from `_on_context_menu_id_pressed`,
|
||||
`MENU_CREATE_POINT`, line 993) projects the **canvas center** onto whatever segment is nearest
|
||||
*it* — a different segment. **Fix:** remove the bounds fallback entirely (the stored position
|
||||
is always valid world space); only guard non-finite values.
|
||||
|
||||
### 1.2 Point 1 — right-clicking some segments shows the Add-Shape menu
|
||||
|
||||
In POINTS mode, `_handle_right_click` (lines 744-813) targets a shape only through
|
||||
`_is_point_in_shape` (lines 774-778). For **closed** shapes `_is_point_in_shape` is
|
||||
**fill-only** (`Geometry2D.is_point_in_polygon`, lines 1168-1169); for open lines it is a
|
||||
distance-to-outline test. A right-click **on a segment** that lies slightly outside the fill
|
||||
(concave corners, a 12-gon circle's edge chord, just beyond a rectangle edge) therefore falls
|
||||
through to the empty-canvas **Add-Shape** menu (lines 802-813). The existing "Create Point"
|
||||
branch (line 786) is only reachable **after** `_is_point_in_shape` already returned true, so
|
||||
it cannot rescue the case. For open lines, a click beyond `EDGE_HIT_THRESHOLD / _zoom` also
|
||||
falls through. **Fix:** in POINTS mode, replace the fill-only shape gate with
|
||||
**segment-proximity targeting** (see §2.1). Combined with points 3/4 (the empty-canvas Add
|
||||
menu disappears in POINTS mode), the entire fall-through disappears.
|
||||
|
||||
---
|
||||
|
||||
## 2. Static contracts
|
||||
|
||||
### 2.1 POINTS-mode right-click menu matrix + segment-proximity targeting
|
||||
|
||||
New public helper (directly testable):
|
||||
|
||||
```gdscript
|
||||
# prop_canvas.gd — returns the frontmost shape whose segment is within
|
||||
# EDGE_HIT_THRESHOLD / _zoom of world_pos, else -1.
|
||||
func hit_test_segment(world_pos: Vector2) -> int:
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
if _is_point_near_outline(i, world_pos):
|
||||
return i
|
||||
return -1
|
||||
```
|
||||
|
||||
`_is_point_near_outline` already measures distance to the **segments only**
|
||||
(`_distance_to_shape_pts`, lines 1183-1189), so this is the segment-proximity test the fix
|
||||
needs.
|
||||
|
||||
Revised `_handle_right_click` right-click decision table:
|
||||
|
||||
| Mode | Click target | Menu shown |
|
||||
| --- | --- | --- |
|
||||
| POINTS | on a vertex (selected first, then others front→back) | **Remove Point** (only) |
|
||||
| POINTS | on/near a **segment** (within `EDGE_HIT_THRESHOLD/zoom`, front→back) | **Create Point** (only); shape is selected first |
|
||||
| POINTS | interior body of a closed shape (inside fill, *not* near any segment) | **nothing**; shape is selected (no menu pops) |
|
||||
| POINTS | empty canvas | **nothing** (no Add-Shape menu) |
|
||||
| TRANSFORM | on a shape body (fill/outline, front→back) | Copy / Paste / Erase / Flip L-R / Flip U-D / Bring Forward / Send Back (unchanged) |
|
||||
| TRANSFORM | empty canvas | Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste) (unchanged) |
|
||||
|
||||
Revised `_handle_right_click` shape (POINTS branch only; TRANSFORM branch is unchanged):
|
||||
|
||||
```gdscript
|
||||
func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
||||
# --- POINTS mode: point editing only ---
|
||||
if _edit_mode == EditMode.POINTS:
|
||||
# 1. Vertex (selected first, then others front->back) -> Remove Point
|
||||
var context_shape := -1
|
||||
var context_vertex := -1
|
||||
if _selected_shape_idx >= 0:
|
||||
var hit_idx := _hit_test_vertex(_selected_shape_idx, world_pos)
|
||||
if hit_idx >= 0:
|
||||
context_shape = _selected_shape_idx
|
||||
context_vertex = hit_idx
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
if context_shape >= 0:
|
||||
break
|
||||
if i == _selected_shape_idx:
|
||||
continue
|
||||
var hit_idx := _hit_test_vertex(i, world_pos)
|
||||
if hit_idx >= 0:
|
||||
context_shape = i
|
||||
context_vertex = hit_idx
|
||||
_context_menu.clear()
|
||||
if context_shape >= 0 and context_vertex >= 0:
|
||||
_context_menu.set_meta("context_shape", context_shape)
|
||||
_context_menu.set_meta("context_vertex", context_vertex)
|
||||
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
return
|
||||
|
||||
# 2. Segment proximity -> Create Point (selects the shape)
|
||||
var seg_shape := hit_test_segment(world_pos)
|
||||
if seg_shape >= 0:
|
||||
_selected_shape_idx = seg_shape
|
||||
_context_menu.set_meta("context_shape", seg_shape)
|
||||
_context_menu.set_meta("click_position", world_pos)
|
||||
queue_redraw()
|
||||
shape_selected.emit(seg_shape)
|
||||
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
return
|
||||
|
||||
# 3. Interior body: select only, NO menu
|
||||
var body_shape := -1
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
if _is_point_in_shape(i, world_pos):
|
||||
body_shape = i
|
||||
break
|
||||
if body_shape >= 0:
|
||||
_selected_shape_idx = body_shape
|
||||
queue_redraw()
|
||||
shape_selected.emit(body_shape)
|
||||
return # empty canvas -> no menu either
|
||||
|
||||
# --- TRANSFORM mode: existing shape-hit / empty-canvas-add logic (unchanged) ---
|
||||
...
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The vertex branch keeps precedence over the segment branch (a vertex sits at a segment end).
|
||||
- `_on_context_menu_id_pressed` already routes `MENU_CREATE_POINT` → `create_point_on_edge(
|
||||
_get_context_world_pos())`; the `context_shape` meta is set on the menu so the selected shape
|
||||
is the one `create_point_on_edge` operates on.
|
||||
- Programmatic APIs (`insert_vertex`, `remove_vertex`, `create_point_on_edge`,
|
||||
`hit_test_segment`, `set_edit_mode`/`get_edit_mode`) remain mode-agnostic; only the context
|
||||
menu gates them.
|
||||
|
||||
### 2.2 `_get_context_world_pos` fix (Point 2)
|
||||
|
||||
```gdscript
|
||||
func _get_context_world_pos() -> Vector2:
|
||||
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2.ZERO)
|
||||
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2.ZERO
|
||||
# The stored click position is always a valid world-space coordinate
|
||||
# (from _screen_to_world at click time). Negative coords are normal after
|
||||
# panning — no bounds clamp. Only guard non-finite values.
|
||||
if not is_finite(world_pos.x) or not is_finite(world_pos.y):
|
||||
return _screen_to_world(size * 0.5)
|
||||
return world_pos
|
||||
```
|
||||
|
||||
This also fixes TRANSFORM-mode empty-canvas Add placement (a click far off the origin now
|
||||
places the primitive there instead of silently snapping to center).
|
||||
|
||||
### 2.3 Mode labels/emoji — location, centering, font token (Points 6, 7)
|
||||
|
||||
**Location decision:** the canvas keeps only the enum (`enum EditMode { TRANSFORM, POINTS }`).
|
||||
All human-facing strings (mode labels/emoji, menu labels/emoji, dialog copy) live in
|
||||
`prop_editor.gd` — the UI controller. The canvas stays a string-free widget; the label mapping
|
||||
is a pure static function for headless testing.
|
||||
|
||||
```gdscript
|
||||
# prop_editor.gd
|
||||
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"] # index == EditMode
|
||||
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
|
||||
|
||||
static func mode_button_text(mode: int) -> String:
|
||||
var idx: int = clampi(mode, 0, MODE_LABELS.size() - 1)
|
||||
return "%s %s" % [MODE_EMOJI[idx], MODE_LABELS[idx]]
|
||||
```
|
||||
|
||||
Emoji choice (kid-friendly, distinct, justified):
|
||||
|
||||
| Mode | Emoji | Label | Rationale |
|
||||
| --- | --- | --- | --- |
|
||||
| TRANSFORM | 🖐️ | Arrange | open hand = "grab and move things around" (translate/rotate/scale gizmos) |
|
||||
| POINTS | ✏️ | Reshape | pencil = "draw / edit points" (vertex handles, Create/Remove Point) |
|
||||
|
||||
**Toolbar centering.** The `%ModeBar` `HBoxContainer` (`scenes/editors/prop/prop_editor.tscn` line 78) is
|
||||
the row above the canvas. Set `_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER` in
|
||||
`_ready()` so the segmented pair sits centered in the canvas width (interpreting "centered
|
||||
vertically" as "centered in the bar" — see §6 open choices). `_make_mode_button` keeps
|
||||
`toggle_mode` + shared `ButtonGroup` (the segmented affordance), drops `SIZE_SHRINK_BEGIN` in
|
||||
favor of the container alignment, and gains a bigger font + min height.
|
||||
|
||||
**Font/hit-target tokens.** Add to `PropTheme.DEFAULT_CONFIG` + committed `prop_theme.json`
|
||||
(additive; `version` stays `"1.0"`):
|
||||
|
||||
```json
|
||||
"ui": {
|
||||
"fonts": { "body": 16, "header": 20, "menu": 18, "menu_bar": 18, "mode": 20, "status": 14 },
|
||||
"hit_targets": { "button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 44 }
|
||||
}
|
||||
```
|
||||
|
||||
New `PropTheme` accessors (clamped, fallback to `DEFAULT_CONFIG`):
|
||||
|
||||
```gdscript
|
||||
func font_mode() -> int: return integer("ui.fonts.mode", 20, 8, 96)
|
||||
func font_menu_bar() -> int: return integer("ui.fonts.menu_bar", 18, 8, 72)
|
||||
func mode_button_min_height() -> int: return integer("ui.hit_targets.mode_button_min_height", 44, 16, 200)
|
||||
```
|
||||
|
||||
`_make_mode_button` applies `add_theme_font_size_override("font_size", _theme.font_mode())`
|
||||
and `custom_minimum_size.y = float(_theme.mode_button_min_height())`, and sets
|
||||
`text = mode_button_text(mode)`.
|
||||
|
||||
### 2.4 Menu bar emoji + font tokens (Points 8, 9)
|
||||
|
||||
`_build_control_theme()` adds a `MenuBar` font size; `_setup_menu_bar()` rewrites titles/items:
|
||||
|
||||
```gdscript
|
||||
# _build_control_theme() addition:
|
||||
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
|
||||
# (PopupMenu items already use font_menu, now default 18.)
|
||||
|
||||
# _setup_menu_bar() rewrite (emoji + singular "Reset View"):
|
||||
_menu_bar.set_menu_title(idx_file, "📁 File")
|
||||
file_menu.add_item("💾 Save", MENU_SAVE)
|
||||
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
|
||||
file_menu.add_item("📂 Load...", MENU_LOAD)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("🗑️ Clear", MENU_CLEAR)
|
||||
_menu_bar.set_menu_title(idx_edit, "🔧 Edit")
|
||||
edit_menu.add_item("📏 Configure Grid / Snap...", MENU_GRID_SETTINGS)
|
||||
_menu_bar.set_menu_title(idx_view, "👁️ View")
|
||||
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS) # was "Reset Views"
|
||||
```
|
||||
|
||||
Proposed menu emoji set (unique within the menu bar; 💾/🗑️ reappear in the exit dialog by
|
||||
design — same meaning):
|
||||
|
||||
| Label | Emoji | Justification |
|
||||
| --- | --- | --- |
|
||||
| File | 📁 | folder container = file operations |
|
||||
| Save | 💾 | floppy disk = classic save (matches exit-dialog Save) |
|
||||
| Save As… | 📄 | page = "write to a new document" |
|
||||
| Load… | 📂 | open folder = open existing |
|
||||
| Clear | 🗑️ | trash = discard content (matches exit-dialog Discard) |
|
||||
| Edit | 🔧 | wrench = tools/settings |
|
||||
| Configure Grid / Snap… | 📏 | ruler = measurement/grid |
|
||||
| View | 👁️ | eye = visibility |
|
||||
| Reset View | 🔍 | magnifier = look again / reset |
|
||||
|
||||
Emoji-in-labels is an established pattern in this codebase (sandbox uses `💾 Save`,
|
||||
`🗑️ Discard`, `📋 Edit Queue…`, `⚡ Edit Rules…`), so it renders via the project's fallback
|
||||
font.
|
||||
|
||||
### 2.5 Window title builder + update points (Point 10)
|
||||
|
||||
Pure, headless-testable builder in `prop_editor.gd`:
|
||||
|
||||
```gdscript
|
||||
const UNTITLED_NAME := "Untitled"
|
||||
|
||||
static func build_window_title(prop_name: String, file_path: String, dirty: bool) -> String:
|
||||
var name := prop_name.strip_edges()
|
||||
if name.is_empty():
|
||||
name = UNTITLED_NAME
|
||||
var title := name
|
||||
if not file_path.is_empty():
|
||||
title += " — " + file_path.get_file() # get_file() keeps the ".prop" extension
|
||||
if dirty:
|
||||
title += " *"
|
||||
return title
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
| name | file_path | dirty | title |
|
||||
| --- | --- | --- | --- |
|
||||
| `""` | `""` | false | `Untitled` |
|
||||
| `"My Crate"` | `""` | true | `My Crate *` |
|
||||
| `"My Crate"` | `res://content/props/mycrate.prop` | false | `My Crate — mycrate.prop` |
|
||||
| `""` | `res://content/props/mycrate.prop` | true | `Untitled — mycrate.prop *` |
|
||||
|
||||
Update points — one choke point:
|
||||
|
||||
```gdscript
|
||||
func _update_window_title() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty)
|
||||
|
||||
func _update_save_status() -> void:
|
||||
... existing status-bar text ...
|
||||
_update_window_title() # single re-derivation site
|
||||
```
|
||||
|
||||
`_update_save_status()` is already invoked on every relevant transition (`_on_name_changed`,
|
||||
`_on_canvas_shape_changed`, `_on_add_pressed`, `_save_to`, `_load_from`,
|
||||
`_on_clear_confirmed`, `_apply_json_data`), and `_ready()` calls it once — so the title stays
|
||||
in sync for free. **Extension enforcement:** `_on_save_file_selected` normalizes the path to
|
||||
`.prop` (mirroring sandbox `_on_scene_save_path_selected`) so `get_file()` always includes the
|
||||
extension:
|
||||
|
||||
```gdscript
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
if path.get_extension().to_lower() != "prop":
|
||||
path = path.get_basename() + ".prop"
|
||||
...
|
||||
```
|
||||
|
||||
### 2.6 Exit dialog flow (Point 11)
|
||||
|
||||
Mirrors sandbox (`auto_accept_quit = false` + `NOTIFICATION_WM_CLOSE_REQUEST` +
|
||||
save-then-quit). The save path is **async only because of the thumbnail render**, so the
|
||||
exit-save path **skips the thumbnail** (cache nicety, not data) — see §6.
|
||||
|
||||
```gdscript
|
||||
# state
|
||||
var _exit_dialog: ConfirmationDialog
|
||||
var _pending_quit: bool = false
|
||||
|
||||
# _ready(): add
|
||||
get_tree().auto_accept_quit = false
|
||||
|
||||
# _setup_dialogs(): build (same _ui_theme as _clear_confirm)
|
||||
_exit_dialog = ConfirmationDialog.new()
|
||||
_exit_dialog.title = "⚠️ Unsaved Changes"
|
||||
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
|
||||
_exit_dialog.ok_button_text = "💾 Save"
|
||||
_exit_dialog.add_button("🗑️ Discard", false, "discard")
|
||||
_exit_dialog.get_cancel_button().text = "❌ Cancel" # or add_button(..., true, "cancel")
|
||||
_exit_dialog.theme = _ui_theme
|
||||
add_child(_exit_dialog)
|
||||
_exit_dialog.confirmed.connect(_on_exit_save)
|
||||
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
||||
_exit_dialog.canceled.connect(_on_exit_cancel)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
_request_close()
|
||||
|
||||
func _request_close() -> void:
|
||||
if not _dirty:
|
||||
get_tree().quit()
|
||||
return
|
||||
_pending_quit = true
|
||||
_exit_dialog.popup_centered()
|
||||
|
||||
func _on_exit_save() -> void:
|
||||
_exit_dialog.hide()
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered() # _pending_quit stays true
|
||||
return
|
||||
await _save_and_maybe_quit(_current_path)
|
||||
|
||||
func _save_and_maybe_quit(path: String) -> void:
|
||||
var ok: bool = await _save_to(path, false) # skip thumbnail on exit-save
|
||||
if ok and _pending_quit:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
|
||||
func _on_exit_custom_action(action: StringName) -> void:
|
||||
if action == &"discard":
|
||||
_exit_dialog.hide()
|
||||
_clear_autosave()
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
|
||||
func _on_exit_cancel() -> void:
|
||||
_pending_quit = false # stay in the editor
|
||||
|
||||
# _on_save_file_selected (save dialog path) — compose with pending-quit:
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
if path.get_extension().to_lower() != "prop":
|
||||
path = path.get_basename() + ".prop"
|
||||
var ok: bool = await _save_to(path, not _pending_quit) # thumbnail only on normal save
|
||||
if _pending_quit and ok:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
```
|
||||
|
||||
`_save_to` changes signature to return success and accept a thumbnail flag:
|
||||
|
||||
```gdscript
|
||||
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
|
||||
var ok: bool = PropFileScript.save(path, _build_json_data())
|
||||
if not ok:
|
||||
_show_error("Could not save the prop to '%s'." % path)
|
||||
return false
|
||||
_current_path = path
|
||||
_dirty = false
|
||||
_clear_autosave()
|
||||
_update_save_status()
|
||||
if with_thumbnail:
|
||||
await _save_prop_thumbnail()
|
||||
return true
|
||||
```
|
||||
|
||||
Flow summary:
|
||||
|
||||
```text
|
||||
Window close (NOTIFICATION_WM_CLOSE_REQUEST)
|
||||
├─ clean -> quit()
|
||||
└─ dirty -> _exit_dialog
|
||||
├─ ❌ Cancel -> _pending_quit=false, stay
|
||||
├─ 🗑️ Discard -> clear autosave, quit()
|
||||
└─ 💾 Save
|
||||
├─ no _current_path -> Save dialog; on select -> save(skip thumb) -> quit
|
||||
└─ has _current_path -> save(skip thumb) -> quit (or show error, no quit)
|
||||
```
|
||||
|
||||
`_ask_confirm`-style theming already exists: the exit dialog uses the same `_ui_theme` and
|
||||
`ConfirmationDialog` shape as `_clear_confirm` (built in `_setup_dialogs()`), and gets the
|
||||
same `⚠️` title convention as the sandbox's unsaved-changes dialog.
|
||||
|
||||
### 2.7 Autosave / recovery (Point 12)
|
||||
|
||||
Mirrors `sandbox_stage.gd` (`AUTOSAVE_PATH`, debounce-free write-on-modify, `write_file_safe`,
|
||||
`_check_autosave_recovery`, stale-check, clear-on-save/load). A small atomic writer is added to
|
||||
`PropFile` (the scene IO's `write_file_safe` is sandbox-specific):
|
||||
|
||||
```gdscript
|
||||
# prop_file.gd — atomic write mirroring StageSceneIO.write_file_safe.
|
||||
static func save_safe(path: String, data: Dictionary) -> bool:
|
||||
var tmp := path + ".tmp"
|
||||
var bak := path + ".bak"
|
||||
var file := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not write temporary file '%s'." % tmp)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.flush()
|
||||
file.close()
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
|
||||
DirAccess.remove_absolute(tmp)
|
||||
return false
|
||||
if DirAccess.rename_absolute(tmp, path) != OK:
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.rename_absolute(bak, path)
|
||||
return false
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
return true
|
||||
```
|
||||
|
||||
Editor constants + state:
|
||||
|
||||
```gdscript
|
||||
const AUTOSAVE_PATH := "user://prop_editor_autosave.prop"
|
||||
const AUTOSAVE_DEBOUNCE := 1.0
|
||||
|
||||
var _autosave_remaining: float = 0.0 # > 0 = counting down to a write
|
||||
```
|
||||
|
||||
Dirty setpoints go through a single helper that schedules the autosave:
|
||||
|
||||
```gdscript
|
||||
func _mark_dirty() -> void:
|
||||
_dirty = true
|
||||
_update_save_status()
|
||||
_autosave_remaining = AUTOSAVE_DEBOUNCE
|
||||
|
||||
# _on_canvas_shape_changed / _on_name_changed / _on_add_pressed -> _mark_dirty()
|
||||
```
|
||||
|
||||
Debounce tick (appended to the existing `_process`):
|
||||
|
||||
```gdscript
|
||||
func _process(delta: float) -> void:
|
||||
_update_cursor_coords()
|
||||
if _autosave_remaining > 0.0:
|
||||
_autosave_remaining -= delta
|
||||
if _autosave_remaining <= 0.0:
|
||||
_autosave_remaining = 0.0
|
||||
_write_autosave()
|
||||
|
||||
func _write_autosave() -> void:
|
||||
if not _dirty:
|
||||
return
|
||||
var data := _build_json_data()
|
||||
data["metadata"]["autosave"] = true
|
||||
data["metadata"]["source_path"] = _current_path
|
||||
data["metadata"]["modified_unix"] = Time.get_unix_time_from_system()
|
||||
PropFileScript.save_safe(AUTOSAVE_PATH, data)
|
||||
|
||||
func _clear_autosave() -> void:
|
||||
if FileAccess.file_exists(AUTOSAVE_PATH):
|
||||
DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||||
```
|
||||
|
||||
The autosave never triggers another autosave (it writes the file, does not touch `_dirty` or
|
||||
emit `shape_changed`); `_apply_json_data`/`clear` set `_dirty = false` and do not schedule.
|
||||
|
||||
Startup recovery (called from `_ready()` after dialogs are built; mirrors
|
||||
`sandbox._check_autosave_recovery`, lines 1482-1500):
|
||||
|
||||
```gdscript
|
||||
func _check_autosave_recovery() -> void:
|
||||
if not FileAccess.file_exists(AUTOSAVE_PATH):
|
||||
return
|
||||
var raw: Dictionary = PropFileScript.load(AUTOSAVE_PATH)
|
||||
if raw.is_empty():
|
||||
_clear_autosave(); return
|
||||
var raw_meta: Variant = raw.get("metadata", {})
|
||||
var metadata: Dictionary = raw_meta if raw_meta is Dictionary else {}
|
||||
if not bool(metadata.get("autosave", false)):
|
||||
_clear_autosave(); return
|
||||
var source_path := String(metadata.get("source_path", ""))
|
||||
# Stale if the saved source exists and is newer-or-equal than the autosave.
|
||||
if not source_path.is_empty() and FileAccess.file_exists(source_path) \
|
||||
and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
|
||||
_clear_autosave(); return
|
||||
|
||||
var dialog := ConfirmationDialog.new()
|
||||
dialog.title = "Restore Prop?"
|
||||
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
|
||||
dialog.ok_button_text = "🔄 Restore"
|
||||
dialog.add_button("🗑️ Discard", false, "discard")
|
||||
dialog.theme = _ui_theme
|
||||
add_child(dialog)
|
||||
dialog.confirmed.connect(func() -> void:
|
||||
_apply_json_data(raw, true)
|
||||
_current_path = source_path
|
||||
_update_save_status()
|
||||
dialog.queue_free())
|
||||
dialog.custom_action.connect(func(action: StringName) -> void:
|
||||
if action == &"discard":
|
||||
_clear_autosave()
|
||||
dialog.queue_free())
|
||||
dialog.popup_centered()
|
||||
```
|
||||
|
||||
`_apply_json_data` gains an `is_recovery` flag so a restored prop stays **dirty** (its content
|
||||
is newer than any saved source):
|
||||
|
||||
```gdscript
|
||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
...
|
||||
_dirty = is_recovery # recovery keeps dirty; normal load clears
|
||||
...
|
||||
```
|
||||
|
||||
**Autosave clear semantics (explicit table):**
|
||||
|
||||
| Event | Autosave cleared? |
|
||||
| --- | --- |
|
||||
| Successful manual save (`_save_to`, non-autosave) | **yes** |
|
||||
| Successful load (`_load_from`) | **yes** |
|
||||
| File → Clear confirmed (`_on_clear_confirmed`) | **yes** |
|
||||
| Exit dialog Discard → quit | **yes** |
|
||||
| Exit dialog Save → successful save → quit | **yes** (via `_save_to`) |
|
||||
| Recovery prompt Discard | **yes** |
|
||||
| Recovery prompt Restore | **no** (kept until a manual save/load/clear) |
|
||||
| Autosave is stale (source newer) at startup | **yes** |
|
||||
|
||||
---
|
||||
|
||||
## 3. Task-to-implementation mapping & acceptance criteria
|
||||
|
||||
| # | Feedback point | Target | Acceptance criteria |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Segment right-click → Create Point | `prop_canvas.gd` | `hit_test_segment(world_pos) -> int` public; `_handle_right_click` POINTS branch uses segment proximity (not fill-only) per §2.1; Create Point offered for any segment within `EDGE_HIT_THRESHOLD/zoom`; vertex still takes precedence (Remove Point). |
|
||||
| 2 | Created point on correct segment | `prop_canvas.gd` | `_get_context_world_pos` no longer rejects negative/out-of-bounds coords; only non-finite guard; created vertex == cursor projection onto nearest segment. |
|
||||
| 3 | POINTS menu = point editing only | `prop_canvas.gd` | POINTS right-click matrix per §2.1: vertex→Remove Point, segment→Create Point, interior/empty→no menu; no Copy/Erase/Flip/z-order items in POINTS mode. |
|
||||
| 4 | No add menu in POINTS | `prop_canvas.gd` | POINTS empty-canvas right-click pops nothing (early return before the Add-Shape block). |
|
||||
| 5 | Inspector hides Add/Erase in POINTS | `prop_editor.gd` | `_add_section` VBoxContainer wraps the Add-Shape section; `_sync_mode_controls(mode)` hides `_add_section` + `_delete_button` in POINTS and shows them in TRANSFORM; called from `_on_canvas_mode_changed` and `_ready()`. |
|
||||
| 6 | Rename modes | `prop_editor.gd` | `MODE_LABELS = ["Arrange","Reshape"]`; `mode_button_text()` returns emoji + label; enum values unchanged; no canvas change. |
|
||||
| 7 | Segmented centered pair, bigger font, emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | `_mode_bar.alignment = ALIGNMENT_CENTER`; `_make_mode_button` uses `font_mode()` + `mode_button_min_height()` + `mode_button_text(mode)`; tokens `ui.fonts.mode` (20), `ui.hit_targets.mode_button_min_height` (44) present in `DEFAULT_CONFIG` + JSON. |
|
||||
| 8 | "Reset View" singular | `prop_editor.gd` | `view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)` (label only). |
|
||||
| 9 | Menu font + emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | MenuBar font token `ui.fonts.menu_bar` (18) applied in `_build_control_theme`; `font_menu` bumped to 18; titles/items per §2.4 emoji table. |
|
||||
| 10 | Window title | `prop_editor.gd` | `build_window_title(name, path, dirty)` static pure; `_update_window_title()` called from `_update_save_status()`; `get_window().title` set; `.prop` extension enforced in `_on_save_file_selected`. |
|
||||
| 11 | Exit dialog | `prop_editor.gd` | `auto_accept_quit = false` in `_ready()`; `_notification(NOTIFICATION_WM_CLOSE_REQUEST)`; themed exit dialog (💾/🗑️/❌); `_save_to(path, with_thumbnail) -> bool`; save-then-quit sequencing; Cancel stays; save failure aborts quit. |
|
||||
| 12 | Autosave/recovery | `prop_editor.gd` + `prop_file.gd` | `AUTOSAVE_PATH`, `AUTOSAVE_DEBOUNCE = 1.0`, `_mark_dirty()` schedules, `_write_autosave()` (metadata.autosave + source_path + modified_unix), `PropFile.save_safe` atomic write, `_check_autosave_recovery()` prompt, `_apply_json_data(is_recovery)`, clear-semantics table. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Files modified
|
||||
|
||||
| File | Changes |
|
||||
| --- | --- |
|
||||
| `scripts/editors/prop/prop_canvas.gd` | `hit_test_segment` (public); `_handle_right_click` POINTS matrix; `_get_context_world_pos` bounds-fix. |
|
||||
| `scripts/editors/prop/prop_editor.gd` | Mode labels/emoji + `mode_button_text`; toolbar centering + font; menu emoji + "Reset View"; window title builder + update points; exit dialog + `auto_accept_quit` + `_notification`; autosave (path/debounce/write/recovery/clear); `_save_to(path, with_thumbnail) -> bool`; `_apply_json_data(is_recovery)`; `_add_section` wrapper + `_sync_mode_controls`. |
|
||||
| `scripts/editors/prop/prop_theme.gd` | `font_mode()`, `font_menu_bar()`, `mode_button_min_height()`; `DEFAULT_CONFIG` `ui` additions; `font_menu` default 16→18. |
|
||||
| `prop_theme.json` | Additive `ui` keys (`fonts.mode`, `fonts.menu_bar`, `fonts.menu` 18, `hit_targets.mode_button_min_height`); `version` stays `"1.0"`. |
|
||||
| `scripts/editors/prop/prop_file.gd` | `save_safe(path, data) -> bool` atomic write. |
|
||||
| `tests/test_prop_editor_canvas.gd` | Add `_test_segment_hit_test` (and optionally `_test_context_world_pos` if the helper is exposed). |
|
||||
| `tests/test_prop_editor_round3.gd` | **new** — title builder, `mode_button_text`, `PropFile.save_safe` round-trip, autosave metadata. |
|
||||
| `docs/architecture/tech_debt_and_optimizations.md` | Log exit-save thumbnail skip + autosave debounce + mode-label location decisions. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Test coverage
|
||||
|
||||
**Headless — `tests/test_prop_editor_canvas.gd` (existing suite, public API):**
|
||||
|
||||
- New `_test_segment_hit_test()`: box at `(100,100)` (bbox `(76,76)-(124,124)`);
|
||||
`hit_test_segment(Vector2(110, 70))` (6 px above top edge, within threshold) → `0`;
|
||||
`hit_test_segment(Vector2(100, 100))` (interior, ~24 px from every edge) → `-1`;
|
||||
`hit_test_segment(Vector2(400, 400))` → `-1`.
|
||||
- Existing `_test_create_point_on_edge` already asserts projection + `vertex_created`; the
|
||||
Round 3 fix (stored click position) is verified by a new direct `_get_context_world_pos`
|
||||
assertion only if that helper is exposed (optional — otherwise manual F6 covers it).
|
||||
|
||||
**Headless — `tests/test_prop_editor_round3.gd` (new; static funcs only, no scene instantiation):**
|
||||
|
||||
```gdscript
|
||||
const PE := preload("res://scripts/editors/prop/prop_editor.gd")
|
||||
const PF := preload("res://scripts/editors/prop/prop_file.gd")
|
||||
|
||||
# Title builder (pure static)
|
||||
PE.build_window_title("", "", false) == "Untitled"
|
||||
PE.build_window_title("My Crate", "", true) == "My Crate *"
|
||||
PE.build_window_title("My Crate", "res://content/props/mycrate.prop", false) == "My Crate — mycrate.prop"
|
||||
PE.build_window_title("", "res://content/props/mycrate.prop", true) == "Untitled — mycrate.prop *"
|
||||
|
||||
# Mode labels/emoji (pure static, clamping)
|
||||
PE.mode_button_text(0) == "🖐️ Arrange"
|
||||
PE.mode_button_text(1) == "✏️ Reshape"
|
||||
PE.mode_button_text(99) == "✏️ Reshape" # clamps to last
|
||||
|
||||
# save_safe atomic round-trip (PropFile)
|
||||
var p := "user://round3_autosave_test.prop"
|
||||
var d := {"version":"1.0","prop_name":"X","material_preset":"none","shapes":[],
|
||||
"metadata":{"autosave":true,"source_path":"","modified_unix":123}}
|
||||
PF.save_safe(p, d) == true
|
||||
PF.load(p) deep-equals d (JSON round-trip)
|
||||
FileAccess.file_exists(p + ".tmp") == false and FileAccess.file_exists(p + ".bak") == false
|
||||
# overwrite path works, still no leftover
|
||||
```
|
||||
|
||||
**Manual F6 verification (not headless-assertable):** emoji glyph rendering; toolbar centering
|
||||
+ font/height visually; POINTS-mode right-click feel (segment→Create Point, vertex→Remove
|
||||
Point, interior/empty→nothing); Create Point at a **negative** world coordinate after panning
|
||||
(regression for Point 2); inspector Add/Erase hide on mode switch; window title updates across
|
||||
name/save/load/clear/dirty; exit dialog Save/Discard/Cancel incl. save-then-quit; autosave
|
||||
recovery prompt on a simulated unclean shutdown (kill the process with edits, relaunch).
|
||||
|
||||
---
|
||||
|
||||
## 6. Design choices — user-approved resolutions
|
||||
|
||||
1. **Emoji sets.** **Approved as proposed:** buttons 🖐️ Arrange / ✏️ Reshape; menus 📁💾📄📂🗑️🔧📏👁️🔍; exit dialog 💾 Save / 🗑️ Discard / ❌ Cancel; restore 🔄 Restore.
|
||||
2. **"Untitled" vs "Unnamed Prop".** **Approved:** window title default **"Untitled"**; status bar keeps **"Unnamed Prop"**.
|
||||
3. **Filename with extension.** **Approved:** with `.prop` (`get_file()`), extension enforced on save (`.prp` treated as a typo for `.prop`).
|
||||
4. **Autosave debounce.** **Approved:** **1.0 s**.
|
||||
5. **Restore flow.** **Approved:** **prompt** with 🔄 Restore / 🗑️ Discard (mirrors the sandbox).
|
||||
6. **Autosave clear semantics.** **Approved:** per §2.7 table (clear on save/load/clear/discard-quit; keep after Restore; drop stale autosave at startup).
|
||||
7. **Exit-save thumbnail.** **Approved:** **skip** the thumbnail on exit-save.
|
||||
8. **Mode button height.** **Approved:** **44 px** (`mode_button_min_height`) with `font_mode` 20.
|
||||
9. **Centering.** **Approved:** horizontally center the segmented pair in the ModeBar row (`ALIGNMENT_CENTER`).
|
||||
|
||||
---
|
||||
|
||||
*Validation follows the existing pattern: per-script `--check-only`, then
|
||||
`tests/test_prop_editor_canvas.gd` and `tests/test_prop_editor_round3.gd` headless suites
|
||||
printing PASS/FAIL and `quit(0/1)`. Interactive feel (emoji, centering, exit dialog, autosave
|
||||
recovery) is manual F6 verification.*
|
||||
Reference in New Issue
Block a user