Start the scenery editor from its own button and note launches

With no scenery selected the button starts a new one in the editor. Each launch
notes the scenery, the timetable number and the vehicles for the tapes. Tools
work on the selected scenery, and an emptied consist stays listed.
This commit is contained in:
maj00r
2026-11-01 16:06:16 +01:00
parent 6e45f5de59
commit 2ebff91316
9 changed files with 670 additions and 124 deletions

View File

@@ -20,14 +20,17 @@ public interface IProcessHandle
public interface IProcessLauncher
{
/// <summary>
/// Starts a process. With captureErrors its error stream is kept for the
/// handle - only for a process the starter outlives: a write to a pipe nobody
/// reads any more can kill the writer.
/// Starts a process, with <paramref name="environment"/> added to the starter's
/// own. With captureErrors its error stream is kept for the handle - only for a
/// process the starter outlives: a write to a pipe nobody reads any more can
/// kill the writer.
/// </summary>
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
bool captureErrors, out string? error);
IReadOnlyDictionary<string, string> environment, bool captureErrors, out string? error);
bool OpenInShell(string pathOrUrl);
IProcessHandle? FindRunning(string processName);
bool IsRunning(string processName);
}

View File

@@ -10,6 +10,9 @@ namespace StarterNG.Application;
/// <summary>What a simulator that went down left behind: its log and its crash dumps.</summary>
public sealed class SimulatorAftermath
{
// A little slack: the start is taken just after the launch, not before it.
private static readonly TimeSpan StartSlack = TimeSpan.FromSeconds(5);
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
@@ -42,8 +45,7 @@ public sealed class SimulatorAftermath
if (_files.DirectoryExists(crashpad))
dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
// A little slack: the start is taken just after the launch, not before it.
var since = startedUtc - TimeSpan.FromSeconds(5);
var since = startedUtc - StartSlack;
return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
.OrderByDescending(_files.GetLastWriteTimeUtc)
@@ -56,13 +58,17 @@ public sealed class SimulatorAftermath
}
}
/// <summary>The end of the simulator's own log, for when it died without a word on its error stream.</summary>
public IReadOnlyList<string> SimulatorLogTail(int count)
/// <summary>
/// The end of the simulator's own log, for when it died without a word on its
/// error stream. A log not written since the start is the session before's, the
/// simulator having died before opening its own: it says nothing about this one.
/// </summary>
public IReadOnlyList<string> SimulatorLogTail(int count, DateTime startedUtc)
{
string path = _paths.FromRoot("log.txt");
try
{
if (!_files.FileExists(path))
if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack)
return Array.Empty<string>();
var lines = LegacyText.Decode(_files.ReadAllBytes(path))

View File

@@ -2,12 +2,12 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Settings;
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Settings;
@@ -47,11 +47,21 @@ public sealed class StartSimulation
private readonly IProcessLauncher _processes;
private readonly IRandomSource _random;
private readonly IDiagnosticsLog _log;
private readonly LinuxGpus _gpus;
private readonly IEnvironment _environment;
private readonly WineConfigLink _wineConfig;
private readonly HaslerTapes _tapes;
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
ExecutableLocator executables, IFileSystem files, IGamePaths paths,
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log,
LinuxGpus gpus, IEnvironment environment, WineConfigLink wineConfig,
HaslerTapes tapes)
{
_gpus = gpus;
_wineConfig = wineConfig;
_environment = environment;
_tapes = tapes;
_state = state;
_settings = settings;
_vehicles = vehicles;
@@ -63,11 +73,12 @@ public sealed class StartSimulation
_log = log;
}
public static string? StartableVehicle(Trainset? trainset, string? preferred)
/// <summary>
/// What the start button says of the consist. The vehicle picked answers for it
/// even with nobody in it, where the launch itself goes on to the first crew.
/// </summary>
public static bool HasCrewToStart(Trainset trainset, string? preferred)
{
if (trainset is null || trainset.Vehicles.Count == 0)
return null;
static bool CanStart(Dynamic vehicle) =>
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
@@ -75,39 +86,49 @@ public sealed class StartSimulation
{
var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
if (picked is not null && CanStart(picked))
return picked.Name;
if (picked is not null)
return CanStart(picked);
}
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name;
return trainset.Vehicles.Any(CanStart);
}
public SimulationStartResult Execute(bool freeFly, bool saveSettings)
{
var scenery = _state.CurrentScenery;
var trainset = _state.CurrentTrainset;
if (scenery is null || trainset is null)
if (scenery is null)
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
// The depot checkbox is the consist's own state; this setting is a blanket
// override for people who always want the same thing, and it wins.
switch (_settings.Settings.ReadyOverride)
// A scenery with no consist picked starts with the free camera.
string? vehicle = null;
if (trainset is null)
{
case ConsistReadyOverride.AlwaysCold:
trainset.ReadyToGo = false;
break;
case ConsistReadyOverride.AlwaysReady:
trainset.ReadyToGo = true;
break;
freeFly = true;
}
else
{
// The depot checkbox is the consist's own state; this setting is a blanket
// override for people who always want the same thing, and it wins.
switch (_settings.Settings.ReadyOverride)
{
case ConsistReadyOverride.AlwaysCold:
trainset.ReadyToGo = false;
break;
case ConsistReadyOverride.AlwaysReady:
trainset.ReadyToGo = true;
break;
}
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles)
?? StartableVehicle(trainset, _state.StartingVehicleName);
_state.StartingVehicleName = vehicle;
vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles);
_state.StartingVehicleName = vehicle;
}
scenery.Weather.Dirty = true;
string exportName = ExportPrefix + Path.GetFileName(scenery.Path);
// In lower case: that is the name the simulator looks for, and on Linux
// "$Glinojeck.scn" is not "$glinojeck.scn".
string exportName = ExportPrefix + SimulatorNames.Lower(Path.GetFileName(scenery.Path));
string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
try
{
@@ -125,6 +146,9 @@ public sealed class StartSimulation
if (saveSettings)
_settings.CaptureAndSave();
if (_settings.Settings.PhysicsLog)
_tapes.RecordLaunch(scenery, trainset, freeFly ? null : vehicle);
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
? new[] { "-s", exportName }
: new[] { "-s", exportName, "-v", vehicle };
@@ -134,15 +158,16 @@ public sealed class StartSimulation
/// <summary>
/// Opens the selected scenery in the simulator's editor. The scenery file itself,
/// not an export: what the editor saves has to land in the scenery.
/// not an export: what the editor saves has to land in the scenery. With none
/// selected the simulator opens its wizard of a new scenery.
/// </summary>
public SimulationStartResult ExecuteEditor()
{
if (_state.CurrentScenery is not { } scenery)
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
public SimulationStartResult ExecuteEditor() =>
Launch(_state.CurrentScenery is { } scenery
? new[] { "-edit", Path.GetFileName(scenery.Path) }
: new[] { "-edit" });
return Launch(new[] { "-edit", Path.GetFileName(scenery.Path) });
}
/// <summary>The simulator's wizard of a new scenery, whichever one is selected.</summary>
public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" });
private SimulationStartResult Launch(IReadOnlyList<string> arguments)
{
@@ -157,10 +182,14 @@ public sealed class StartSimulation
// The installation, not the folder of the binary: a simulator picked from a
// build tree lives beside the data, and the data is what it opens.
var (program, programArguments) = _executables.LaunchCommand(executable, arguments);
// Under Wine eu07.exe would read the prefix's own eu07.ini, not the one just saved.
if (_executables.RunsThroughWine(executable))
_wineConfig.Ensure();
// Its error stream is kept for a report on a crash - unless the starter closes
// now, leaving nobody to read it or to report.
bool watched = !_settings.Settings.AutoCloseStarter;
var process = _processes.Start(program, programArguments, _paths.Root, watched, out string? error);
var process = _processes.Start(program, programArguments, _paths.Root, GraphicsCard(), watched,
out string? error);
if (process is null)
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
Detail: error);
@@ -168,4 +197,30 @@ public sealed class StartSimulation
return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
}
/// <summary>
/// What puts the simulator on the card the settings ask for, said in the log
/// either way: a slow simulator on a laptop is the first thing to ask about.
/// </summary>
private IReadOnlyDictionary<string, string> GraphicsCard()
{
var gpus = _gpus.List();
if (gpus.Count < 2)
return new Dictionary<string, string>();
var preference = _settings.Settings.Gpu;
string cards = string.Join(", ", gpus);
if (GpuOffload.Target(gpus, preference, _environment.GetVariable) is not { } target)
{
_log.Log($"GPU: {cards}; left to the system ({(preference == GpuPreference.System
? "chosen in the settings"
: "set by the player, or no card to move to")})");
return new Dictionary<string, string>();
}
var environment = GpuOffload.EnvironmentFor(target);
_log.Log($"GPU: {cards}; simulator on {target} with " +
string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}")));
return environment;
}
}

View File

@@ -18,7 +18,8 @@ public sealed class SystemProcessLauncher : IProcessLauncher
}
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
bool captureErrors, out string? error)
IReadOnlyDictionary<string, string> environment, bool captureErrors,
out string? error)
{
var info = new ProcessStartInfo
{
@@ -29,6 +30,8 @@ public sealed class SystemProcessLauncher : IProcessLauncher
};
foreach (string argument in arguments)
info.ArgumentList.Add(argument);
foreach (var (name, value) in environment)
info.Environment[name] = value;
try
{
@@ -87,6 +90,22 @@ public sealed class SystemProcessLauncher : IProcessLauncher
}
}
public bool IsRunning(string processName)
{
try
{
var found = Process.GetProcessesByName(processName);
foreach (var process in found)
process.Dispose();
return found.Length > 0;
}
catch (Exception ex)
{
_log.Log($"lookup {processName}", ex);
return false;
}
}
private sealed class ProcessHandle : IProcessHandle
{
private const int KeptErrorLines = 20;

View File

@@ -4,9 +4,13 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG"
xmlns:infra="clr-namespace:StarterNG.Infrastructure"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StarterNG.Views.Scenarios"
x:CompileBindings="True">
<UserControl.Resources>
<SolidColorBrush x:Key="SyntaxFaultBrush" Color="#E0A030" />
</UserControl.Resources>
<Grid Name="scenariosGrid" Margin="0" ColumnSpacing="0" RowSpacing="3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="200" MaxWidth="460" />
@@ -72,7 +76,7 @@
<GridSplitter Grid.Row="1" Height="4" HorizontalAlignment="Stretch"
Background="Transparent" ResizeDirection="Rows" />
<TabControl Grid.Row="2">
<TabControl Grid.Row="2" Name="infoTabs">
<TabItem Header="{Binding [TabDescription], Source={x:Static starterNg:App.Loc}}">
<Grid RowDefinitions="2*,6,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="*,3,225">
@@ -98,7 +102,7 @@
<TabItem Header="{Binding [Weather], Source={x:Static starterNg:App.Loc}}">
<StackPanel Name="weatherForm" Margin="8,6" Spacing="8" IsEnabled="False">
<Grid ColumnDefinitions="Auto,12,380,8,70"
RowDefinitions="Auto,6,Auto,14,Auto,Auto,8,Auto,Auto,14,Auto,14,Auto">
RowDefinitions="Auto,6,Auto,14,Auto,Auto,8,Auto,Auto,10,Auto,10,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center"
Foreground="{StaticResource CarbonFgDim}"
@@ -108,7 +112,7 @@
YearVisible="False" MinWidth="0"
VerticalAlignment="Center" HorizontalAlignment="Stretch"
SelectedDateChanged="WeatherDate_OnChanged" />
<ComboBox Grid.Column="2" Name="weatherSeason"
<ComboBox Grid.Column="2" Name="weatherSeason" Classes="Localized"
HorizontalAlignment="Stretch" VerticalAlignment="Center"
SelectionChanged="WeatherSeason_OnChanged">
<ComboBoxItem Tag="0" Content="{Binding [SeasonNow], Source={x:Static starterNg:App.Loc}}" />
@@ -176,7 +180,7 @@
<TextBlock Grid.Row="10" Grid.Column="0" VerticalAlignment="Center"
Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [Overcast], Source={x:Static starterNg:App.Loc}}" />
<ComboBox Grid.Row="10" Grid.Column="2" Name="weatherOvercast"
<ComboBox Grid.Row="10" Grid.Column="2" Name="weatherOvercast" Classes="Localized"
HorizontalAlignment="Stretch"
SelectionChanged="Weather_OnSelectionChanged">
<ComboBoxItem Tag="random" Content="{Binding [OvercastRandom], Source={x:Static starterNg:App.Loc}}" />
@@ -217,6 +221,26 @@
</ScrollViewer>
</DockPanel>
</TabItem>
<!-- Only for a scenery whose files hold mistakes - see PopulateFaults. -->
<TabItem Name="faultsTab" IsVisible="False">
<TabItem.Header>
<StackPanel Orientation="Horizontal" Spacing="5">
<materialIcons:MaterialIcon Kind="AlertOutline" Width="15" Height="15"
Foreground="{StaticResource SyntaxFaultBrush}" />
<TextBlock Name="faultsTabHeader" VerticalAlignment="Center" />
</StackPanel>
</TabItem.Header>
<DockPanel LastChildFill="True" Margin="4">
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Margin="2,0,0,6"
Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [SyntaxIntro], Source={x:Static starterNg:App.Loc}}" />
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<SelectableTextBlock Name="faultsText"
FontFamily="{x:Static infra:MonoFont.Family}"
FontSize="12" TextWrapping="NoWrap" />
</ScrollViewer>
</DockPanel>
</TabItem>
</TabControl>
</Grid>

View File

@@ -5,7 +5,6 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia;
@@ -14,7 +13,6 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.VisualTree;
using StarterNG.Classes;
@@ -30,6 +28,8 @@ public partial class Scenarios : UserControl
{
public List<Scenery> Sceneries;
private readonly VehicleInfo _info = new(AppServices.Current.Library.Vehicles, AppServices.Current.Physics);
private Scenery? _currentScenery;
private bool _loadingWeather;
@@ -62,10 +62,27 @@ public partial class Scenarios : UserControl
private void OnReentered()
{
RefreshVehicleLabels();
RefreshVehicleList();
RefreshSelectedConsist();
}
/// <summary>
/// The list again, as the depot page may have picked another consist or filled
/// one. Selecting its row counts as no pick here: the vehicle to start in stays.
/// </summary>
private void RefreshVehicleList()
{
if (SelectedScenery is not { } scenery)
return;
var state = AppServices.Current.State;
var trainset = state.CurrentTrainset;
string? vehicle = state.StartingVehicleName;
PopulateVehicleList(scenery);
if (ReferenceEquals(state.CurrentTrainset, trainset))
state.StartingVehicleName = vehicle;
}
private void BuildSceneryTree()
{
sceneryList.Items.Clear();
@@ -81,11 +98,11 @@ public partial class Scenarios : UserControl
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
}
private static TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups)
private TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups)
{
var item = new TreeViewItem
{
Header = node.Label,
Header = node.IsGroup ? node.Label : SceneryHeader(node.Label, Sceneries[node.SceneryIndex]),
Tag = node.IsGroup ? null : node.SceneryIndex,
IsExpanded = node.IsGroup && expandGroups
};
@@ -96,6 +113,27 @@ public partial class Scenarios : UserControl
return item;
}
/// <summary>The scenery's name, marked when its files hold mistakes.</summary>
private object SceneryHeader(string label, Scenery scenery)
{
if (scenery.Faults.Count == 0)
return label;
var mark = new Material.Icons.Avalonia.MaterialIcon
{
Kind = Material.Icons.MaterialIconKind.AlertOutline,
Width = 13,
Height = 13,
Margin = new Thickness(5, 0, 0, 0),
Foreground = (Avalonia.Media.IBrush)Resources["SyntaxFaultBrush"]!
};
var header = new StackPanel { Orientation = Orientation.Horizontal };
header.Children.Add(new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center });
header.Children.Add(mark);
ToolTip.SetTip(mark, string.Format(App.Loc["SyntaxTreeTip"], scenery.Faults.Count));
return header;
}
private Scenery? SelectedScenery =>
sceneryList.SelectedItem is TreeViewItem { Tag: int index } && index < Sceneries.Count
? Sceneries[index]
@@ -215,14 +253,11 @@ public partial class Scenarios : UserControl
AppServices.Current.SettingsStore.Save();
}
private static string TrainsetListLabel(Trainset trainset) =>
TrainsetDisplay.CompositionText(trainset)
?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
private static Control TrainsetListContent(Trainset trainset) =>
/// <summary>A consist's label in a list of them, here and on the depot page.</summary>
internal static TextBlock TrainsetListContent(Trainset trainset) =>
new Avalonia.Controls.TextBlock
{
Text = TrainsetListLabel(trainset),
Text = TrainsetDisplay.ListLabel(trainset),
TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis,
TextWrapping = Avalonia.Media.TextWrapping.NoWrap
};
@@ -233,13 +268,14 @@ public partial class Scenarios : UserControl
bool showAi = AppServices.Current.Settings.ShowAiVehicles;
bool drivableOnly = AppServices.Current.Settings.DrivableOnly;
var current = AppServices.Current.State.CurrentTrainset;
ListBoxItem? currentRow = null;
for (int i = 0; i < scn.Trainsets.Count; i++)
{
var trainset = scn.Trainsets[i];
if (IsAiTrainset(trainset) && !showAi) continue;
if (drivableOnly && !UData.StaffedTrain(trainset)) continue;
if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, current)) continue;
var listItem = new ListBoxItem
{
@@ -250,17 +286,27 @@ public partial class Scenarios : UserControl
listItem.ContextMenu = ConsistContextMenu.Build(
listItem,
() => trainset,
vehicles => ApplyConsist(listItem, trainset, vehicles));
vehicles => ApplyConsist(trainset, vehicles, listItem),
removeAll: () => ApplyConsist(trainset, new List<Dynamic>(), listItem));
vehicleList.Items.Add(listItem);
if (ReferenceEquals(trainset, current))
currentRow = listItem;
}
if (vehicleList.Items.Count > 0)
vehicleList.SelectedIndex = 0;
vehicleList.SelectedItem = currentRow ?? vehicleList.Items[0];
else
ClearConsistStrip();
}
private static bool IsAiTrainset(Trainset trainset) =>
!string.IsNullOrEmpty(trainset.Description) &&
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
/// <summary>No consist to show: what the scenery before had listed is not left standing.</summary>
private void ClearConsistStrip()
{
consistStack.Children.Clear();
_stripTrainset = null;
trainStats.Children.Clear();
}
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
{
@@ -273,20 +319,39 @@ public partial class Scenarios : UserControl
RestoreLastScenery();
}
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
/// <param name="row">
/// The list row it was chosen on, which may be of a consist not the picked one.
/// Without one it comes from the strip, which redraws whichever consist it shows.
/// </param>
private void ApplyConsist(Trainset trainset, List<Dynamic> vehicles, ListBoxItem? row = null)
{
trainset.Vehicles = vehicles;
item.Content = TrainsetListContent(trainset);
if (ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
if (row is null)
{
RedrawConsist(trainset);
}
else
{
row.Content = TrainsetListContent(trainset);
if (!ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
return;
ShowConsist(trainset);
}
AppServices.Current.State.NotifyChanged();
}
private void RedrawConsist(Trainset trainset)
{
RefreshVehicleLabels();
ShowConsist(trainset);
}
private void RefreshVehicleLabels()
{
if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count)
if (SelectedScenery is not { } scn)
return;
var scn = Sceneries[tag];
foreach (var obj in vehicleList.Items)
{
if (obj is ListBoxItem { Tag: int vi } lbi && vi < scn.Trainsets.Count)
@@ -313,6 +378,17 @@ public partial class Scenarios : UserControl
}
Scenery selectedScn = Sceneries[tag];
// The scenery is the one started from now on, whether or not it lists a
// consist: left as it was, a scenery with none - or none the filters show -
// started the one picked before.
var state = AppServices.Current.State;
if (!ReferenceEquals(state.CurrentScenery, selectedScn))
{
state.CurrentScenery = selectedScn;
state.CurrentTrainset = null;
state.StartingVehicleName = null;
}
ShowSceneryInfo(selectedScn);
ShowWeather(selectedScn);
PersistLastScenery(selectedScn);
@@ -320,7 +396,7 @@ public partial class Scenarios : UserControl
missionDescription.Text = "";
timetableContent.Text = App.Loc["NoTimetable"];
PopulateVehicleList(selectedScn);
RefreshVehicleList();
}
private void ShowSceneryInfo(Scenery scenery)
@@ -348,18 +424,36 @@ public partial class Scenarios : UserControl
PopulateAttachments(scenery);
PopulateFaults(scenery);
LogUnknownTextures(scenery);
}
/// <summary>Most a scenery's faults the tab lists; the rest are only counted.</summary>
private const int FaultsShown = 500;
private void PopulateFaults(Scenery scenery)
{
var lines = new List<string>();
lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key]));
var faults = scenery.Faults;
faultsTab.IsVisible = faults.Count > 0;
if (faults.Count == 0)
{
if (infoTabs.SelectedItem == faultsTab)
infoTabs.SelectedIndex = 0;
faultsText.Text = "";
}
else
{
faultsTabHeader.Text = string.Format(App.Loc["SyntaxTab"], faults.Count);
faultsText.Text = SceneryFaultText.Format(faults, FaultsShown, key => App.Loc[key]);
}
}
// Textures the vehicle library does not know are no fault of the text, only noted.
private static void LogUnknownTextures(Scenery scenery)
{
var lines = new List<string>();
var db = AppServices.Current.Library.Vehicles;
foreach (var ts in scenery.Trainsets)
{
if (!ts.Parsed)
lines.Add($"# unparsed trainset: {ts.Name}");
foreach (var v in ts.Vehicles)
{
if (string.IsNullOrEmpty(v.SkinFile)) continue;
@@ -393,6 +487,9 @@ public partial class Scenarios : UserControl
};
btn.Classes.Add("Flat");
ToolTip.SetTip(btn, att.FilePath);
// A button that does nothing is no way to say the file is not there.
btn.IsEnabled = File.Exists(Path.GetFullPath(att.FilePath));
ToolTip.SetShowOnDisabled(btn, true);
btn.Click += (_, _) => OpenAttachment(att.FilePath);
attachmentsPanel.Children.Add(btn);
}
@@ -421,8 +518,7 @@ public partial class Scenarios : UserControl
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
AppServices.Current.Settings.LastScenery =
Path.GetFileNameWithoutExtension(selected);
RememberScenery(selected);
RestoreLastScenery();
}
if (AppServices.Current.State.CurrentScenery != null)
@@ -430,6 +526,13 @@ public partial class Scenarios : UserControl
RefreshSelectedConsist();
}
/// <summary>The one the tree opens on next, if there was one.</summary>
private static void RememberScenery(string? path)
{
if (!string.IsNullOrEmpty(path))
AppServices.Current.Settings.LastScenery = Path.GetFileNameWithoutExtension(path);
}
private bool _reloading;
private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
@@ -438,15 +541,18 @@ public partial class Scenarios : UserControl
_reloading = true;
try
{
// Reading the files again drops every change not saved to them.
if (Sceneries.Any(s => s.Trainsets.Any(SceneryTrainsetText.IsModified)) &&
TopLevel.GetTopLevel(this) is Window owner &&
!await MessageBox.Show(owner, App.Loc["ReloadSceneriesConfirm"],
App.Loc["ReloadSceneries"], MessageBoxButtons.YesNo))
return;
string? selected = AppServices.Current.State.CurrentScenery?.Path;
await Task.Run(() => AppServices.Current.Library.ReloadSceneries());
Sceneries = AppServices.Current.Library.Sceneries;
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
AppServices.Current.Settings.LastScenery =
Path.GetFileNameWithoutExtension(selected);
}
RememberScenery(selected);
RestoreLastScenery();
}
finally
@@ -455,6 +561,62 @@ public partial class Scenarios : UserControl
}
}
/// <summary>
/// The scenery files changed outside the starter: the list follows, and only a
/// consist edited here whose text changed in the file too has to ask.
/// </summary>
/// <param name="mayAsk">False while the window is in the background: asked once it is back.</param>
public async Task FollowSceneryFilesAsync(IReadOnlyList<SceneryChange> changes, bool mayAsk)
{
if (_reloading) return;
var watch = AppServices.Current.SceneryFiles;
bool followed = false;
foreach (var change in changes)
{
var judgement = watch.Judge(change);
if (judgement.Verdict == SceneryVerdict.Leave)
continue;
if (judgement.Verdict == SceneryVerdict.AskFirst)
{
if (!mayAsk || TopLevel.GetTopLevel(this) is not Window owner)
continue;
string names = string.Join(", ", judgement.Lost.Select(t => t.Name));
if (!await MessageBox.Show(owner,
string.Format(App.Loc["SceneryFileChangedConfirm"], Path.GetFileName(change.Current!.Path), names),
App.Loc["SceneryFileChangedTitle"], MessageBoxButtons.YesNo))
{
change.Current!.Sources = change.Fresh!.Sources;
continue;
}
}
watch.Apply(change);
followed = true;
}
if (followed)
RebuildKeepingGroupsOpen();
}
private void RebuildKeepingGroupsOpen()
{
var open = TreeGroups(sceneryList.Items).Where(g => g.IsExpanded).Select(g => g.Header).ToHashSet();
string? selected = AppServices.Current.State.CurrentScenery?.Path;
BuildSceneryTree();
foreach (var group in TreeGroups(sceneryList.Items))
group.IsExpanded = open.Contains(group.Header);
RememberScenery(selected);
RestoreLastScenery();
}
private static IEnumerable<TreeViewItem> TreeGroups(ItemCollection items) =>
items.OfType<TreeViewItem>().Where(i => i.Tag is null).SelectMany(i => TreeGroups(i.Items).Prepend(i));
public event Action<Scenery>? RandomizeTexturesRequested;
private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e)
@@ -519,29 +681,14 @@ public partial class Scenarios : UserControl
string dir = scn != null
? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery")
: Path.GetFullPath("scenery");
if (!Directory.Exists(dir))
{
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(string.Format(App.Loc["FaultFileNotFound"], dir));
return;
}
try
{
Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true });
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
$"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}");
}
StarterNG.Infrastructure.Diagnostics.OpenOrReport(dir);
}
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
var tItem = sceneryList.SelectedItem as TreeViewItem;
if (tItem?.Tag is not int tTag)
if (SelectedScenery is not { } selectedScn)
return;
Scenery selectedScn = Sceneries[tTag];
ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem;
if (vItem?.Tag is not int vTag)
@@ -827,14 +974,8 @@ public partial class Scenarios : UserControl
ContextMenu VehicleMenu(Dynamic vehicle) => ConsistContextMenu.Build(
consistStack,
() => trainset,
vehicles =>
{
trainset.Vehicles = vehicles;
RefreshVehicleLabels();
ShowConsist(trainset);
UpdateTrainStats(trainset);
},
removeAll: null,
vehicles => ApplyConsist(trainset, vehicles),
removeAll: () => ApplyConsist(trainset, new List<Dynamic>()),
getVehicle: () => vehicle,
removeVehicle: v =>
{
@@ -851,9 +992,7 @@ public partial class Scenarios : UserControl
AppServices.Current.State.NotifyChanged();
}
RefreshVehicleLabels();
ShowConsist(trainset);
UpdateTrainStats(trainset);
RedrawConsist(trainset);
});
var db = AppServices.Current.Library.Vehicles;
@@ -1127,9 +1266,7 @@ public partial class Scenarios : UserControl
vehicles.InsertRange(target, block);
RefreshVehicleLabels();
ShowConsist(trainset);
UpdateTrainStats(trainset);
RedrawConsist(trainset);
}
private static bool HoldsNext(Dynamic car) =>
@@ -1158,13 +1295,8 @@ public partial class Scenarios : UserControl
return;
}
var db = AppServices.Current.Library.Vehicles;
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
trainset,
car => AppServices.Current.Physics.For(car.DataFolder, db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.Model)
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile),
car => db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.ResolvedCategory));
trainset, _info.PhysicsFor, _info.CategoryOf));
}
private void RefreshSelectedConsist()