diff --git a/.junie/plans/make-map-editor-work-on-windows.md b/.junie/plans/make-map-editor-work-on-windows.md index 04b4f79..5bf4704 100644 --- a/.junie/plans/make-map-editor-work-on-windows.md +++ b/.junie/plans/make-map-editor-work-on-windows.md @@ -2,6 +2,141 @@ sessionId: session-260928-112553-atgt --- +# Requirements + +### Overview & Goals +Manually clicking a save button after every map modification introduces recurring cognitive friction, slows down editing workflows, and creates a risk of lost progress if the application closes unexpectedly. Automating the persistence layer eliminates redundant user effort, optimizes editing efficiency, and ensures maximum utility by immediately guaranteeing that every map node movement or property adjustment is reliably written to disk without requiring explicit manual intervention. + +### Scope +- **In Scope**: + - Automatically persisting modified system coordinates when node dragging finishes (`OnPointerReleased`). + - Automatically persisting modified coordinates with a debounce delay when typing in the Inspector's X and Y text entries (`OnPositionTextChanged`). + - Auto-saving newly assigned placeholder positions when loading folders containing star systems without coordinates. + - Updating toolbar elements and status labels to provide real-time save state feedback ("Saved", "Saving...", "All changes saved") rather than relying on a required manual "Save" button. + - Ensuring background/asynchronous file writes or safe dispatcher execution so the UI remains fluid and uninterrupted. +- **Out of Scope**: + - Modifying the underlying frontmatter format parsed by `StarSystemDocStore`. + - Adding undo/redo history stack (can be addressed as a separate productivity enhancement). + - File system watching for external file modifications outside the editor. + +### User Stories +- As a map designer, I want my node movements and coordinate edits to save automatically to the underlying Markdown documents, so that I can focus entirely on layout without worrying about saving or losing progress. +- As an editor user, I want subtle, immediate visual feedback confirming that my changes have been successfully persisted, so that I have complete confidence in the integrity of my work. + +### Functional Requirements +- **Drag-and-Drop Auto-Save**: When a user drags one or more star systems and releases the pointer, all dirty nodes in the selection must automatically trigger a persistence pass to `StarSystemDocStore.SavePositions`. +- **Debounced Inspector Entry Auto-Save**: When a user modifies X or Y coordinates directly in the Inspector text boxes, the change must automatically save after a brief pause (e.g. 300–500ms debounce), preventing excessive disk I/O on every single keystroke while ensuring prompt persistence. +- **Auto-Save on Initial Layout Assignment**: When loading documents missing coordinates (`!HasPosition`), placeholder coordinates assigned by `DefaultPosition` must be auto-persisted. +- **Non-Intrusive Status Feedback**: Status messages must indicate when changes are being saved and confirm successful persistence ("All changes saved" / "Auto-saved N system(s)"), gracefully updating without blocking canvas interactions. +- **Toolbar Streamlining**: The previous manual "Save" button will be repurposed or streamlined into a status indicator / optional manual sync trigger that reflects clean save states. + +### Non-Functional Requirements +- **Efficiency & Utility**: Debounced I/O minimizes unnecessary disk writes and prevents UI hitching during rapid user input. +- **Fault Tolerance**: File write failures (e.g. read-only files or transient file locks) must be caught safely and reported via status messages without crashing the editor. +- **Platform Compatibility**: Works identically across Windows WinUI desktop and MacCatalyst environments. + +--- + +# Technical Design + +### Current Implementation +- `ZeroSpace.MapEditor.Views.MapEditorPage`: + - Maintains `_nodes` and `_selectedNodes`. Dragging updates `node.X`, `node.Y`, and sets `node.Doc.IsDirty = true`. + - `OnPositionTextChanged` updates `_selected.X`, `_selected.Y`, and sets `node.Doc.IsDirty = true`. + - `OnSaveClicked` explicitly calls `StarSystemDocStore.SavePositions(_nodes.Select(static node => node.Doc))`. + - `UpdateDirtyState` enables/disables `SaveButton` and updates `StatusLabel` with "N unsaved position change(s)". +- `ZeroSpace.MapEditor.Services.StarSystemDocStore`: + - `SavePositions(IEnumerable docs)` iterates through dirty documents, rewrites the YAML frontmatter `x:` and `y:` lines preserving byte formatting, and clears `IsDirty = false`. + +### Key Decisions +1. **Debounced Timer for Text Input vs Immediate on Pointer Release**: + - *Decision*: Trigger auto-save immediately on `OnPointerReleased` when dragging has occurred, and use a lightweight debounced timer (`IDispatcherTimer` or `CancellationTokenSource` task delay) of ~400ms for `OnPositionTextChanged`. + - *Rationale*: Releasing a drag gesture is a discrete user milestone where immediate disk write gives instant safety. Typing numbers is continuous, where a 400ms debounce prevents intermediate invalid or half-typed states from generating excessive disk writes while still guaranteeing persistence within a fraction of a second. +2. **Safe Background & Error Handling**: + - *Decision*: Wrap save operations in robust exception handling and perform file operations asynchronously or off the main thread if needed, updating the UI status seamlessly. + - *Rationale*: Eliminates the risk of application crashes due to transient file access locks and preserves high UI framerates during continuous editing. +3. **Save Button Repurposing**: + - *Decision*: Update the Save button to either act as an optional instant-flush control or transition it into a save status badge. + - *Rationale*: Maximizes utility for users who want visual confirmation or immediate flushing while removing the burden of mandatory manual saving. + +### Proposed Changes + +#### 1. Add Auto-Save Controller / Debounce Logic to `MapEditorPage.xaml.cs` +- Add a debounce mechanism for text input (e.g. `CancellationTokenSource? _autoSaveCts` or `IDispatcherTimer`). +- Create `ScheduleAutoSave()` and `ExecuteAutoSaveAsync()` methods. +- In `OnPointerReleased`: if `_draggingNode` was active and nodes moved, trigger auto-save immediately. +- In `OnPositionTextChanged`: if values changed, schedule debounced auto-save. +- In `LoadFolder`: after loading docs and assigning missing positions, execute auto-save to persist initial coordinates. + +#### 2. Update `Views/MapEditorPage.xaml` & Status Feedback +- Update toolbar: `SaveButton` can display "Saved" (disabled/subtle) or "Saving..." while persisting, and remain clickable for manual force-save if desired. +- Status label updates to reflect "Auto-saved at [time]" or "All changes saved to [folder]". + +### Components +- `MapEditorPage` (`Views/MapEditorPage.xaml.cs`): Orchestrates drag/text events, schedules debounced persistence, and updates status UI. +- `StarSystemDocStore` (`Services/StarSystemDocStore.cs`): Executes thread-safe, atomic-preserving frontmatter file writes for dirty documents. + +### File Structure +``` +ZeroSpace/ZeroSpace.MapEditor/ +├── Services/ +│ └── StarSystemDocStore.cs [File persistence logic] +└── Views/ + ├── MapEditorPage.xaml [Toolbar & Inspector UI] + └── MapEditorPage.xaml.cs [Auto-save orchestration & event handlers] +``` + +### Architecture Diagram +```mermaid +graph TD + subgraph UserInteractions["User Interaction Triggers"] + DragRelease["Pointer Released (Drag End)"] -->|Immediate Trigger| AutoSaveEngine["Auto-Save Orchestrator"] + TextEntry["Inspector X/Y Entry Changed"] -->|400ms Debounce| AutoSaveEngine + FolderLoad["Folder Loaded (Missing Coords)"] -->|Post-Layout Trigger| AutoSaveEngine + end + + subgraph AutoSaveEngine["Persistence Layer (MapEditorPage)"] + AutoSaveEngine -->|Query Dirty Docs| DocFilter["Filter node.Doc.IsDirty == true"] + DocFilter -->|Write Frontmatter| Store["StarSystemDocStore.SavePositions()"] + Store -->|Clear IsDirty & Update UI| StatusUI["StatusLabel & Toolbar Feedback"] + end + + subgraph FileSystem["Disk Storage"] + Store -->|Write Frontmatter YAML| MarkdownFiles["zs.docs/StarSystem/*.md"] + end +``` + +### Risks & Mitigations +- **Risk**: Rapid typing in X/Y fields creating concurrent or conflicted file writes. + - *Mitigation*: Debounce timer cancels previous pending save passes, ensuring only the latest coordinate value is written once typing pauses. +- **Risk**: File lock or permission error during background auto-save interrupting the user. + - *Mitigation*: Gracefully catch `IOException` / `UnauthorizedAccessException`, update status label with descriptive warning ("Auto-save failed: ..."), and retain `IsDirty = true` so subsequent passes retry automatically. + +--- + +# Testing + +### Validation Approach +Verification will be conducted using automated build verification on the .NET SDK and unit/integration testing of the debounced persistence flow and frontmatter updater. + +### Key Scenarios +1. **Drag-and-Drop Auto-Persistence**: + - Move a map node via canvas drag and release pointer. + - Verify document frontmatter `x:` and `y:` values in `zs.docs/StarSystem/*.md` update on disk without clicking Save. +2. **Inspector Entry Debounce Persistence**: + - Type new coordinate values into `XEntry` or `YEntry`. + - Verify that after the debounce period (400ms), dirty status clears and file content on disk matches the new input values. +3. **Multi-Node Auto-Save**: + - Marquee-select multiple nodes and drag them simultaneously. + - Verify all moved nodes are saved with updated coordinates in their respective markdown files. +4. **Missing Coordinates Auto-Save**: + - Load a folder containing documents without `x` / `y` properties. + - Verify default generated coordinates are automatically persisted. + +### Edge Cases +- **Rapid Successive Changes**: Repeated fast drags and typing bursts do not produce race conditions or corrupt file contents. +- **Read-Only / Locked Files**: Handled cleanly with user notification and automatic dirty state preservation for subsequent retry. + # Delivery Steps ### ✓ Step 1: Implementation @@ -71,11 +206,35 @@ Add a search feature to the editor that will highlight matching nodes in the Zer tool name: submit tool parameters: - - - + +## Orchestrated Plan Task implement according to the plan - + + +### Summary +- Implemented the ZeroSpace.MapEditor search feature so users can search star systems by name and visually highlight matching nodes on the map. +- Added live search behavior plus search-submit navigation to the first match, while keeping the Windows build and full solution build clean. -This task has no prior planning phase. Before implementation, analyze the task and codebase, define acceptance criteria if not explicitly provided in the task description, and plan your approach. -The Reviewer must independently define its own acceptance criteria and will verify them. \ No newline at end of file +### Changes +- Added `IsHighlighted` state to `Models/MapNode.cs` and updated `Views/MapDrawable.cs` to render a gold highlight ring while preserving a distinct selection ring. +- Added a `SearchBar` to `Views/MapEditorPage.xaml` and wired `TextChanged` / `SearchButtonPressed` events. +- Implemented search application, status updates, first-match focus, viewport centering, and search reapplication on folder reload in `Views/MapEditorPage.xaml.cs`. + +### Verification +- Verified `dotnet build ZeroSpace\ZeroSpace.MapEditor\ZeroSpace.MapEditor.csproj -f net10.0-windows10.0.19041.0` succeeds with 0 warnings and 0 errors. +- Verified `dotnet build ZeroSpace\ZeroSpace.sln` succeeds with 0 warnings and 0 errors; reviewer found no blocking issues. + +### Notes +- GUI interaction was not exercised in this headless environment, but the feature logic and build integration were reviewed successfully. + + +## Plan Task +Make it so when you click a connect planet system via the selector, it selects it. +Make it so you can drag click to select multiple planets. +Remove the y 0 boundary. I want to be able to drag it anywhere. + + +tool name: submit +tool parameters: + + \ No newline at end of file diff --git a/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs b/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs index d08f193..b00514f 100644 --- a/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs +++ b/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs @@ -33,7 +33,8 @@ public static class StarSystemDocStore public static List LoadFolder(string folder) { var docs = new List(); - foreach (var path in Directory.GetFiles(folder, "*.md").OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)) + foreach (var path in Directory.GetFiles(folder, "*.md") + .OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)) { var doc = LoadFile(path); if (doc is not null) @@ -151,18 +152,53 @@ public static class StarSystemDocStore /// Writes x/y back into each dirty file's frontmatter, preserving /// every other line byte-for-byte. /// - public static void SavePositions(IEnumerable docs) + public static int SavePositions(IEnumerable docs) { - foreach (var doc in docs.Where(static d => d.IsDirty)) + var count = 0; + var errors = new List(); + var items = docs.Where(static d => d.IsDirty) + .Select(static d => (Doc: d, TargetX: d.X, TargetY: d.Y)) + .ToList(); + + foreach (var item in items) { - SavePosition(doc); - doc.IsDirty = false; + try + { + SavePosition(item.Doc.FilePath, item.TargetX, item.TargetY); + if (item.Doc.X == item.TargetX && item.Doc.Y == item.TargetY) + { + item.Doc.IsDirty = false; + } + + count++; + } + catch (Exception ex) + { + errors.Add(new IOException($"Failed to save '{item.Doc.Name}': {ex.Message}", ex)); + } } + + if (errors.Count == 1) + { + throw errors[0]; + } + + if (errors.Count > 1) + { + throw new AggregateException(errors); + } + + return count; } public static void SavePosition(StarSystemDoc doc) { - var text = File.ReadAllText(doc.FilePath); + SavePosition(doc.FilePath, doc.X, doc.Y); + } + + public static void SavePosition(string filePath, double x, double y) + { + var text = File.ReadAllText(filePath); var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).ToList(); if (lines.Count < 2 || lines[0].Trim() != "---") @@ -176,8 +212,8 @@ public static class StarSystemDocStore return; } - var xLine = $"x: {doc.X.ToString("F1", CultureInfo.InvariantCulture)}"; - var yLine = $"y: {doc.Y.ToString("F1", CultureInfo.InvariantCulture)}"; + var xLine = $"x: {x.ToString("F1", CultureInfo.InvariantCulture)}"; + var yLine = $"y: {y.ToString("F1", CultureInfo.InvariantCulture)}"; var foundX = false; var foundY = false; @@ -207,7 +243,7 @@ public static class StarSystemDocStore lines.Insert(closeIndex, yLine); } - File.WriteAllText(doc.FilePath, string.Join(newline, lines)); + File.WriteAllText(filePath, string.Join(newline, lines)); } /// @@ -220,7 +256,7 @@ public static class StarSystemDocStore var radius = (WorldSize / 2 - 30) * Math.Sqrt(index / (double)Math.Max(count, 1)); var theta = index * goldenAngle; return (WorldSize / 2 + radius * Math.Cos(theta), - WorldSize / 2 + radius * Math.Sin(theta)); + WorldSize / 2 + radius * Math.Sin(theta)); } private static string? KeyOf(string line) @@ -232,7 +268,7 @@ public static class StarSystemDocStore private static bool IsTrue(Dictionary scalars, string key) { return scalars.TryGetValue(key, out var value) && - string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); } private static bool TryParseDouble(string raw, out double value) @@ -258,4 +294,4 @@ public static class StarSystemDocStore return value; } -} +} \ No newline at end of file diff --git a/ZeroSpace/ZeroSpace.MapEditor/Views/MapDrawable.cs b/ZeroSpace/ZeroSpace.MapEditor/Views/MapDrawable.cs index 65c62ba..26cba9d 100644 --- a/ZeroSpace/ZeroSpace.MapEditor/Views/MapDrawable.cs +++ b/ZeroSpace/ZeroSpace.MapEditor/Views/MapDrawable.cs @@ -23,6 +23,8 @@ public sealed class MapDrawable : IDrawable public double OffsetY { get; set; } + public RectF? SelectionBox { get; set; } + public void Draw(ICanvas canvas, RectF dirtyRect) { canvas.FillColor = Color.FromArgb("#1e2430"); @@ -72,6 +74,15 @@ public sealed class MapDrawable : IDrawable canvas.DrawCircle(center, node.IsHighlighted ? radius + 8 : radius + 6); } } + + if (SelectionBox is { Width: > 0, Height: > 0 } box) + { + canvas.FillColor = Color.FromArgb("#334a90d9"); + canvas.FillRectangle(box); + canvas.StrokeColor = Color.FromArgb("#4a90d9"); + canvas.StrokeSize = 1.5f; + canvas.DrawRectangle(box); + } } public static Color ColorForKind(string kind) => kind switch diff --git a/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml b/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml index bade5f4..d08411f 100644 --- a/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml +++ b/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml @@ -6,10 +6,11 @@