Vibe improvements to editor tool
This commit is contained in:
@@ -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>
|
||||
@@ -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<string, string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
@@ -33,7 +42,7 @@ public partial class MapEditorPage : ContentPage
|
||||
|
||||
private (double X, double Y) ToWorld(Point screen) =>
|
||||
((screen.X - _drawable.OffsetX) / _drawable.Scale,
|
||||
(screen.Y - _drawable.OffsetY) / _drawable.Scale);
|
||||
(screen.Y - _drawable.OffsetY) / _drawable.Scale);
|
||||
|
||||
private void FitView()
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
Select(_pressNode);
|
||||
if (_panModeEnabled && position is not null && Distance(position.Value, _pressPoint) <= 4)
|
||||
{
|
||||
var hit = HitTest(position.Value);
|
||||
Select(hit);
|
||||
}
|
||||
|
||||
_panning = false;
|
||||
}
|
||||
else if (_pressNode is null && _panning &&
|
||||
position is not null && Distance(position.Value, _pressPoint) <= 4)
|
||||
else if (_pressNode is not null)
|
||||
{
|
||||
var wasDragging = _draggingNode;
|
||||
if (!_draggingNode && _wasAlreadySelectedInGroup)
|
||||
{
|
||||
Select(_pressNode);
|
||||
}
|
||||
|
||||
_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,14 +577,42 @@ 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);
|
||||
_selected.Doc.IsDirty = true;
|
||||
UpdateDirtyState();
|
||||
MapView.Invalidate();
|
||||
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)
|
||||
{
|
||||
var yields = new List<string>();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user