Vibe improvements to editor tool

This commit is contained in:
2026-09-29 18:21:21 +00:00
committed by JonathanMcCaffrey
parent 3bcdd359a9
commit eaffd00137
11 changed files with 623 additions and 70 deletions
+165 -6
View File
@@ -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<StarSystemDoc> 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:
</previous_issue_solution>
</plan_session_history>
<plan_task>
<previous_issue>
## Orchestrated Plan Task
implement according to the plan
</plan_task>
</previous_issue>
<previous_issue_solution>
### 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.
### 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.
</previous_issue_solution>
<previous_issue>
## 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.
</previous_issue>
<previous_issue_solution>
tool name: submit
tool parameters:
</previous_issue_solution>
</plan_session_history>
@@ -33,7 +33,8 @@ public static class StarSystemDocStore
public static List<StarSystemDoc> LoadFolder(string folder)
{
var docs = new List<StarSystemDoc>();
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.
/// </summary>
public static void SavePositions(IEnumerable<StarSystemDoc> docs)
public static int SavePositions(IEnumerable<StarSystemDoc> docs)
{
foreach (var doc in docs.Where(static d => d.IsDirty))
var count = 0;
var errors = new List<Exception>();
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));
}
/// <summary>
@@ -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
@@ -6,10 +6,11 @@
<Grid RowDefinitions="Auto,*" ColumnDefinitions="*,340">
<HorizontalStackLayout Grid.Row="0" Grid.ColumnSpan="2" Padding="8" Spacing="8">
<Button Text="Open Folder" Clicked="OnOpenFolderClicked" />
<Button x:Name="SaveButton" Text="Save" Clicked="OnSaveClicked" IsEnabled="False" />
<Button x:Name="SaveButton" Text="Saved" Clicked="OnSaveClicked" IsEnabled="False" />
<Button Text="+" WidthRequest="44" Clicked="OnZoomInClicked" />
<Button Text="−" WidthRequest="44" Clicked="OnZoomOutClicked" />
<Button Text="Reset View" Clicked="OnResetViewClicked" />
<Button x:Name="PanModeButton" Text="Pan: Off" Clicked="OnPanModeClicked" />
<SearchBar x:Name="SearchControl"
Placeholder="Search systems..."
WidthRequest="220"
@@ -55,10 +56,15 @@
<Entry x:Name="YEntry" Keyboard="Numeric" TextChanged="OnPositionTextChanged" />
<Label Text="Connected systems" FontAttributes="Bold" />
<CollectionView x:Name="ConnectionsView" SelectionMode="None">
<CollectionView x:Name="ConnectionsView"
SelectionMode="Single"
SelectionChanged="OnConnectionSelectionChanged">
<CollectionView.ItemTemplate>
<DataTemplate>
<Label Text="{Binding}" Padding="0,2" />
<Label Text="{Binding}"
Padding="4,4"
TextColor="#4a90d9"
TextDecorations="Underline" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
@@ -10,15 +10,24 @@ public partial class MapEditorPage : ContentPage
private readonly MapDrawable _drawable = new();
private List<MapNode> _nodes = new();
private Dictionary<string, MapNode> _byName = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<MapNode> _selectedNodes = new();
private MapNode? _selected;
private MapNode? _pressNode;
private Point _pressPoint;
private Point _lastPoint;
private Point _dragStartWorldPoint;
private readonly Dictionary<MapNode, (double X, double Y)> _dragStartWorldPositions = new();
private readonly HashSet<MapNode> _preMarqueeSelected = new();
private bool _draggingNode;
private bool _marqueeSelecting;
private bool _panning;
private bool _panModeEnabled;
private bool _wasAlreadySelectedInGroup;
private bool _viewFitted;
private bool _updatingInspector;
private string? _folder;
private readonly SemaphoreSlim _saveLock = new(1, 1);
private CancellationTokenSource? _autoSaveCts;
public MapEditorPage()
{
@@ -45,10 +54,20 @@ public partial class MapEditorPage : ContentPage
}
const double margin = 60;
const double world = StarSystemDocStore.WorldSize;
_drawable.Scale = Math.Min((width - margin * 2) / world, (height - margin * 2) / world);
_drawable.OffsetX = width / 2 - world / 2 * _drawable.Scale;
_drawable.OffsetY = height / 2 - world / 2 * _drawable.Scale;
double minX = 0, minY = 0, maxX = StarSystemDocStore.WorldSize, maxY = StarSystemDocStore.WorldSize;
if (_nodes.Count > 0)
{
minX = Math.Min(0, _nodes.Min(static n => n.X));
minY = Math.Min(0, _nodes.Min(static n => n.Y));
maxX = Math.Max(StarSystemDocStore.WorldSize, _nodes.Max(static n => n.X));
maxY = Math.Max(StarSystemDocStore.WorldSize, _nodes.Max(static n => n.Y));
}
var worldW = Math.Max(maxX - minX, 100);
var worldH = Math.Max(maxY - minY, 100);
_drawable.Scale = Math.Min((width - margin * 2) / worldW, (height - margin * 2) / worldH);
_drawable.OffsetX = width / 2 - (minX + worldW / 2) * _drawable.Scale;
_drawable.OffsetY = height / 2 - (minY + worldH / 2) * _drawable.Scale;
_viewFitted = true;
MapView.Invalidate();
}
@@ -82,6 +101,15 @@ public partial class MapEditorPage : ContentPage
FitView();
}
private void OnPanModeClicked(object? sender, EventArgs e)
{
_panModeEnabled = !_panModeEnabled;
PanModeButton.Text = _panModeEnabled ? "Pan: ON" : "Pan: Off";
StatusLabel.Text = _panModeEnabled
? "Pan mode enabled. Drag anywhere to move the view."
: "Select mode enabled. Drag empty space to multi-select.";
}
private void CenterOnNode(MapNode node)
{
var width = MapView.Width;
@@ -196,9 +224,100 @@ public partial class MapEditorPage : ContentPage
_pressPoint = position.Value;
_lastPoint = position.Value;
_pressNode = HitTest(position.Value);
_draggingNode = false;
_panning = _pressNode is null;
_marqueeSelecting = false;
_panning = false;
_wasAlreadySelectedInGroup = false;
var isRightOrMiddle = false;
var isShiftOrCtrl = false;
var isAltPressed = false;
#if WINDOWS
if (e.PlatformArgs?.PointerRoutedEventArgs is { } winArgs)
{
var pt = winArgs.GetCurrentPoint(null);
isRightOrMiddle = pt.Properties.IsRightButtonPressed || pt.Properties.IsMiddleButtonPressed;
isShiftOrCtrl = winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Shift) ||
winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Control);
isAltPressed = winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Menu);
}
#endif
if (_panModeEnabled || isRightOrMiddle || isAltPressed)
{
_panning = true;
_pressNode = null;
return;
}
_pressNode = HitTest(position.Value);
if (_pressNode is not null)
{
if (isShiftOrCtrl)
{
if (_selectedNodes.Contains(_pressNode))
{
_selectedNodes.Remove(_pressNode);
_pressNode.IsSelected = false;
_selected = _selectedNodes.FirstOrDefault();
if (_selected is not null)
{
PopulateInspector(_selected);
}
else
{
InspectorScroll.IsVisible = false;
}
}
else
{
_selectedNodes.Add(_pressNode);
_pressNode.IsSelected = true;
_selected = _pressNode;
InspectorScroll.IsVisible = true;
PopulateInspector(_selected);
}
MapView.Invalidate();
}
else
{
if (_selectedNodes.Contains(_pressNode))
{
_wasAlreadySelectedInGroup = _selectedNodes.Count > 1;
_selected = _pressNode;
PopulateInspector(_selected);
}
else
{
Select(_pressNode);
}
}
_dragStartWorldPositions.Clear();
var (clickWorldX, clickWorldY) = ToWorld(position.Value);
_dragStartWorldPoint = new Point(clickWorldX, clickWorldY);
foreach (var node in _selectedNodes)
{
_dragStartWorldPositions[node] = (node.X, node.Y);
}
}
else
{
_marqueeSelecting = true;
_preMarqueeSelected.Clear();
if (isShiftOrCtrl)
{
foreach (var n in _selectedNodes)
{
_preMarqueeSelected.Add(n);
}
}
_drawable.SelectionBox = null;
}
}
private void OnPointerMoved(object? sender, PointerEventArgs e)
@@ -209,6 +328,15 @@ public partial class MapEditorPage : ContentPage
return;
}
if (_panning)
{
_drawable.OffsetX += position.Value.X - _lastPoint.X;
_drawable.OffsetY += position.Value.Y - _lastPoint.Y;
_lastPoint = position.Value;
MapView.Invalidate();
return;
}
if (_pressNode is not null)
{
if (!_draggingNode && Distance(position.Value, _pressPoint) > 4)
@@ -218,20 +346,44 @@ public partial class MapEditorPage : ContentPage
if (_draggingNode)
{
var (worldX, worldY) = ToWorld(position.Value);
_pressNode.X = Math.Clamp(worldX, 0, StarSystemDocStore.WorldSize);
_pressNode.Y = Math.Clamp(worldY, 0, StarSystemDocStore.WorldSize);
_pressNode.Doc.IsDirty = true;
var (currentWorldX, currentWorldY) = ToWorld(position.Value);
var deltaX = currentWorldX - _dragStartWorldPoint.X;
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
foreach (var node in _selectedNodes)
{
if (_dragStartWorldPositions.TryGetValue(node, out var startPos))
{
node.X = startPos.X + deltaX;
node.Y = startPos.Y + deltaY;
node.Doc.IsDirty = true;
}
}
UpdateDirtyState();
SyncPositionEntries();
MapView.Invalidate();
}
}
else if (_panning)
else if (_marqueeSelecting)
{
_drawable.OffsetX += position.Value.X - _lastPoint.X;
_drawable.OffsetY += position.Value.Y - _lastPoint.Y;
_lastPoint = position.Value;
var minX = (float)Math.Min(_pressPoint.X, position.Value.X);
var minY = (float)Math.Min(_pressPoint.Y, position.Value.Y);
var width = (float)Math.Abs(_pressPoint.X - position.Value.X);
var height = (float)Math.Abs(_pressPoint.Y - position.Value.Y);
var rect = new RectF(minX, minY, width, height);
_drawable.SelectionBox = rect;
foreach (var node in _nodes)
{
var screenX = (float)(node.X * _drawable.Scale + _drawable.OffsetX);
var screenY = (float)(node.Y * _drawable.Scale + _drawable.OffsetY);
var inBox = screenX >= minX && screenX <= minX + width &&
screenY >= minY && screenY <= minY + height;
node.IsSelected = inBox || _preMarqueeSelected.Contains(node);
}
MapView.Invalidate();
}
}
@@ -240,35 +392,73 @@ public partial class MapEditorPage : ContentPage
{
var position = e.GetPosition(MapView);
if (_pressNode is not null && !_draggingNode)
if (_panning)
{
if (_panModeEnabled && position is not null && Distance(position.Value, _pressPoint) <= 4)
{
var hit = HitTest(position.Value);
Select(hit);
}
_panning = false;
}
else if (_pressNode is not null)
{
var wasDragging = _draggingNode;
if (!_draggingNode && _wasAlreadySelectedInGroup)
{
Select(_pressNode);
}
else if (_pressNode is null && _panning &&
position is not null && Distance(position.Value, _pressPoint) <= 4)
_draggingNode = false;
_pressNode = null;
if (wasDragging)
{
_ = AutoSaveAsync();
}
}
else if (_marqueeSelecting)
{
var isDrag = position is not null && Distance(position.Value, _pressPoint) > 4;
_drawable.SelectionBox = null;
_marqueeSelecting = false;
if (isDrag)
{
var selectedInMarquee = _nodes.Where(static n => n.IsSelected).ToList();
SetSelection(selectedInMarquee);
}
else
{
Select(null);
}
}
else
{
Select(null);
}
_pressNode = null;
_draggingNode = false;
_panning = false;
_drawable.SelectionBox = null;
MapView.Invalidate();
}
// ----- selection + inspector -----
private void Select(MapNode? node)
{
if (_selected is not null)
foreach (var n in _nodes)
{
_selected.IsSelected = false;
n.IsSelected = false;
}
_selectedNodes.Clear();
_selected = node;
if (_selected is not null)
{
_selected.IsSelected = true;
_selectedNodes.Add(_selected);
}
InspectorScroll.IsVisible = node is not null;
@@ -280,6 +470,62 @@ public partial class MapEditorPage : ContentPage
MapView.Invalidate();
}
private void SetSelection(IEnumerable<MapNode> nodes, MapNode? primary = null)
{
foreach (var n in _nodes)
{
n.IsSelected = false;
}
_selectedNodes.Clear();
foreach (var n in nodes)
{
n.IsSelected = true;
_selectedNodes.Add(n);
}
if (_selectedNodes.Count == 0)
{
_selected = null;
InspectorScroll.IsVisible = false;
}
else
{
_selected = primary ?? (_selected is not null && _selectedNodes.Contains(_selected)
? _selected
: _selectedNodes.First());
InspectorScroll.IsVisible = true;
PopulateInspector(_selected);
if (_selectedNodes.Count > 1)
{
StatusLabel.Text = $"{_selectedNodes.Count} systems selected.";
}
}
MapView.Invalidate();
}
private void OnConnectionSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_updatingInspector)
{
return;
}
if (e.CurrentSelection.FirstOrDefault() is string name)
{
SelectConnectedSystem(name);
}
}
private void SelectConnectedSystem(string name)
{
if (_byName.TryGetValue(name, out var targetNode))
{
Select(targetNode);
}
}
private void PopulateInspector(MapNode node)
{
_updatingInspector = true;
@@ -293,6 +539,7 @@ public partial class MapEditorPage : ContentPage
MasteryLabel.Text = doc.SystemMastery ?? "—";
XEntry.Text = doc.X.ToString("F1", CultureInfo.InvariantCulture);
YEntry.Text = doc.Y.ToString("F1", CultureInfo.InvariantCulture);
ConnectionsView.SelectedItem = null;
ConnectionsView.ItemsSource = doc.ConnectedSystems.ToList();
}
finally
@@ -330,13 +577,41 @@ public partial class MapEditorPage : ContentPage
if (double.TryParse(XEntry.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) &&
double.TryParse(YEntry.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
{
_selected.X = Math.Clamp(x, 0, StarSystemDocStore.WorldSize);
_selected.Y = Math.Clamp(y, 0, StarSystemDocStore.WorldSize);
if (_selected.X != x || _selected.Y != y)
{
_selected.X = x;
_selected.Y = y;
_selected.Doc.IsDirty = true;
UpdateDirtyState();
MapView.Invalidate();
ScheduleAutoSave();
}
}
}
private void ScheduleAutoSave(int delayMs = 400)
{
_autoSaveCts?.Cancel();
_autoSaveCts?.Dispose();
_autoSaveCts = new CancellationTokenSource();
var token = _autoSaveCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(delayMs, token);
if (!token.IsCancellationRequested)
{
await MainThread.InvokeOnMainThreadAsync(() => AutoSaveAsync());
}
}
catch (OperationCanceledException)
{
// Debounce cancelled by a newer edit or immediate save
}
}, token);
}
private static string YieldText(StarSystemDoc doc)
{
@@ -398,7 +673,6 @@ public partial class MapEditorPage : ContentPage
_viewFitted = false;
FitView();
Preferences.Set("MapEditorFolder", folder);
UpdateDirtyState();
if (!string.IsNullOrWhiteSpace(SearchControl.Text))
{
@@ -408,6 +682,16 @@ public partial class MapEditorPage : ContentPage
{
StatusLabel.Text = $"{_nodes.Count} systems loaded from {folder}.";
}
var dirtyCount = _nodes.Count(static node => node.Doc.IsDirty);
if (dirtyCount > 0)
{
_ = AutoSaveAsync();
}
else
{
UpdateDirtyState();
}
}
private async void OnOpenFolderClicked(object? sender, EventArgs e)
@@ -428,25 +712,82 @@ public partial class MapEditorPage : ContentPage
private void OnSaveClicked(object? sender, EventArgs e)
{
_ = AutoSaveAsync();
}
private async Task AutoSaveAsync()
{
_autoSaveCts?.Cancel();
_autoSaveCts?.Dispose();
_autoSaveCts = null;
var dirtyNodes = _nodes.Where(static n => n.Doc.IsDirty).ToList();
if (dirtyNodes.Count == 0)
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
return;
}
SaveButton.Text = "Saving...";
SaveButton.IsEnabled = false;
await _saveLock.WaitAsync();
try
{
StarSystemDocStore.SavePositions(_nodes.Select(static node => node.Doc));
UpdateDirtyState();
StatusLabel.Text = $"Saved positions to {_folder}.";
var docsToSave = _nodes.Where(static n => n.Doc.IsDirty).Select(static n => n.Doc).ToList();
if (docsToSave.Count == 0)
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
return;
}
var savedCount = await Task.Run(() => StarSystemDocStore.SavePositions(docsToSave));
var remainingDirty = _nodes.Count(static n => n.Doc.IsDirty);
if (remainingDirty > 0)
{
SaveButton.Text = "Save";
SaveButton.IsEnabled = true;
StatusLabel.Text =
$"{remainingDirty} unsaved position change{(remainingDirty == 1 ? string.Empty : "s")}.";
}
else
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
StatusLabel.Text = savedCount == 1
? "Auto-saved 1 system."
: $"Auto-saved {savedCount} systems.";
}
}
catch (Exception ex)
{
StatusLabel.Text = $"Save failed: {ex.Message}";
var remainingDirty = _nodes.Count(static n => n.Doc.IsDirty);
SaveButton.Text = "Save";
SaveButton.IsEnabled = remainingDirty > 0;
StatusLabel.Text = $"Auto-save failed: {ex.Message}";
}
finally
{
_saveLock.Release();
}
}
private void UpdateDirtyState()
{
var dirty = _nodes.Count(static node => node.Doc.IsDirty);
SaveButton.IsEnabled = dirty > 0;
if (dirty > 0)
{
SaveButton.Text = "Save";
SaveButton.IsEnabled = true;
StatusLabel.Text = $"{dirty} unsaved position change{(dirty == 1 ? string.Empty : "s")}.";
}
else
{
SaveButton.Text = "Saved";
SaveButton.IsEnabled = false;
}
}
}
+2 -2
View File
@@ -7,6 +7,6 @@ connectedSystems:
- "[[Endless Wastes]]"
- "[[Beta Clarity]]"
- "[[Stronghold Nova Centauri]]"
x: 597.9
y: 437.7
x: 161.4
y: -30.2
---
+2 -2
View File
@@ -8,6 +8,6 @@ connectedSystems:
- "[[Aurora Vale]]"
- "[[Solaris Reach]]"
- "[[Nebulon Drift]]"
x: 467.0
y: 622.8
x: 268.8
y: 87.0
---
+2 -2
View File
@@ -9,6 +9,6 @@ connectedSystems:
- "[[Gamma Swarm]]"
- "[[Nebulon Drift]]"
- "[[Aurora Vale]]"
x: 286.2
y: 508.8
x: 146.7
y: 66.6
---
+2 -2
View File
@@ -8,6 +8,6 @@ connectedSystems:
- "[[Glittering Hope]]"
- "[[Neera's Gift]]"
- "[[Pleiades Cluster]]"
x: 330.4
y: 128.2
x: 392.8
y: 76.8
---
@@ -9,6 +9,6 @@ connectedSystems:
- "[[Solaris Reach]]"
- "[[Yellow Dawn]]"
- "[[Proxima Prime]]"
x: 879.2
y: 660.9
x: 334.2
y: -14.3
---
+2 -2
View File
@@ -7,6 +7,6 @@ connectedSystems:
- "[[Stronghold Nova Centauri]]"
- "[[Proxima Prime]]"
- "[[Eden's Heart]]"
x: 65.5
y: 336.5
x: 430.6
y: -2.9
---