diff --git a/StarterNG/App.axaml b/StarterNG/App.axaml
index 99f30bc..7554bf3 100644
--- a/StarterNG/App.axaml
+++ b/StarterNG/App.axaml
@@ -12,6 +12,17 @@
+
+
+
diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs
index 283d5bf..76778b8 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -1,10 +1,7 @@
using System;
using System.Globalization;
-using System.Linq;
using System.Text;
using System.Threading.Tasks;
-using Avalonia;
-using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
@@ -30,6 +27,7 @@ public partial class App : Avalonia.Application
AvaloniaXamlLoader.Load(this);
AppServices.Current.SettingsStore.Load();
+ AppServices.Current.SettingsStore.StartAtHighWithoutOwnSettings();
KeyboardConfig.Instance.Load();
@@ -48,6 +46,10 @@ public partial class App : Avalonia.Application
// actually display.
string requestedCode = AppServices.Current.Settings.LanguageCode;
+ // Subscribed before any view, so the views rebuild in the new culture.
+ Loc.LanguageChanged += () => ApplyCulture(Loc.CurrentLangCode);
+
+ Loc.Keep(requestedCode);
ApplyLanguage(langName);
// The starter ships no translation for this code, so its own interface
@@ -60,15 +62,26 @@ public partial class App : Avalonia.Application
public static void ApplyLanguage(string langNameOrCode)
{
-
var resolvedName = Loc.Load(langNameOrCode);
// The service is the authority on which language actually loaded: the code
// goes to eu07.ini for the simulator, the name to the picker.
AppServices.Current.Settings.Language = resolvedName;
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
+ }
- ApplyCulture(Loc.CurrentLangCode);
+ ///
+ /// A language picked by the user, written to eu07.ini straight away as the old
+ /// starter did - not left waiting for the Save button of a different tab.
+ /// Only what is already in the settings goes with it, not other unsaved edits.
+ ///
+ public static void ChangeLanguage(string code)
+ {
+ if (string.Equals(code, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ ApplyLanguage(code);
+ AppServices.Current.SettingsStore.Save();
}
private static void ApplyCulture(string langCode)
@@ -88,10 +101,11 @@ public partial class App : Avalonia.Application
culture = CultureInfo.InvariantCulture;
}
+ // Only the defaults: a culture set on the thread is an async local, so one set
+ // from the picker's handler was undone as soon as the handler returned, and
+ // the UI kept the first language's decimal comma and month names.
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
- CultureInfo.CurrentCulture = culture;
- CultureInfo.CurrentUICulture = culture;
}
public override void OnFrameworkInitializationCompleted()
@@ -187,6 +201,9 @@ public partial class App : Avalonia.Application
if (check.ExecutableMissing())
await main.OfferExecutablePickAsync();
+
+ if (CpuFeatures.LacksAvx2 && AppServices.Current.Settings.CpuNotice)
+ await main.ShowCpuNoticeAsync();
});
});
}
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
index 744912b..2c91ba6 100644
--- a/StarterNG/Application/AppServices.cs
+++ b/StarterNG/Application/AppServices.cs
@@ -1,6 +1,8 @@
using System;
+using System.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Infrastructure.Settings;
@@ -25,37 +27,53 @@ public sealed class AppServices
var log = new FileDiagnosticsLog(files, clock, paths);
Log = log;
- Random = new SystemRandomSource();
+ var random = new SystemRandomSource();
Processes = new SystemProcessLauncher(log);
- Localization = new LocalizationService(log);
- Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
- SceneryImages = new SceneryImageLocator(files);
- MiniTextures = new MiniTextureIndex(files, paths);
var settingsPaths = new SettingsPaths(environment, paths);
SettingsPaths = settingsPaths;
+ // The player's own first: the game folder may need rights to write to, the user folder never.
+ Localization = new LocalizationService(log, folders: new LanguageFolders(
+ new[]
+ {
+ Path.Combine(settingsPaths.UserConfigDirectory(), "starter", "lang"),
+ paths.FromRoot("starter", "lang"),
+ LocalizationService.LangDirectory,
+ paths.FromRoot("startercfg", "lang")
+ },
+ paths.FromRoot("starter"),
+ paths.FromRoot("lang")));
+ Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, random));
+ SceneryImages = new SceneryImageLocator(files);
+ MiniTextures = new MiniTextureIndex(files, paths);
// textures.txt stays the truth; the starter reads its own JSON copy of it
// and rebuilds that copy whenever the installation has moved on.
- TexturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
- VehicleJson = new VehicleJsonSerializer();
- VehicleCache = new VehicleCacheRepository(files, paths, log, TexturesTxt, VehicleJson,
- settingsPaths.VehicleCacheDirectory());
- Vehicles = VehicleCache;
+ var texturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
+ var vehicleJson = new VehicleJsonSerializer();
+ Vehicles = new VehicleCacheRepository(files, paths, log, texturesTxt, vehicleJson,
+ settingsPaths.VehicleCacheDirectory());
Physics = new FizPhysicsRepository(files, paths, log);
- MissingAssets = new MissingAssetScanner(files, paths);
+ var missingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log);
Timetables = new TimetableLocator(files);
LoadWeights = new LoadWeightsRepository(files, paths, log);
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState();
+ SceneryFiles = new SceneryFileWatch(files, Sceneries, Library, State);
Executables = new ExecutableLocator(files, paths, environment, log);
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
- MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log);
+ MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, missingAssets, Library, log);
Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath());
- StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths,
- Processes, Random, log);
+ Tapes = new HaslerTapes(files, paths, log, clock, Timetables,
+ () => Processes.IsRunning(Path.GetFileNameWithoutExtension(SettingsStore.ResolveExecutable())));
+ Gpus = new LinuxGpus(files, environment);
+ Displays = new Displays(files, environment);
+ StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables,
+ files, paths, Processes, random, log, Gpus, environment,
+ new WineConfigLink(files, environment, settingsPaths, log), Tapes);
Aftermath = new SimulatorAftermath(files, paths, log);
+ SaveTrainset = new SaveSceneryTrainset(files, log);
}
public static AppServices Current =>
@@ -85,28 +103,18 @@ public sealed class AppServices
public IProcessLauncher Processes { get; }
- public IRandomSource Random { get; }
-
public ISceneryRepository Sceneries { get; }
+ public SceneryFileWatch SceneryFiles { get; }
+
public SceneryImageLocator SceneryImages { get; }
public IMiniTextureIndex MiniTextures { get; }
public IVehicleRepository Vehicles { get; }
- /// The same object as , typed for the developer tab.
- public VehicleCacheRepository VehicleCache { get; }
-
- /// The legacy reader behind the cache, for tools that go to the source.
- public TexturesTxtVehicleRepository TexturesTxt { get; }
-
- public VehicleJsonSerializer VehicleJson { get; }
-
public IPhysicsRepository Physics { get; }
- public MissingAssetScanner MissingAssets { get; }
-
public ISceneryTranslations SceneryTexts { get; }
public TimetableLocator Timetables { get; }
@@ -123,6 +131,10 @@ public sealed class AppServices
public ExecutableLocator Executables { get; }
+ public LinuxGpus Gpus { get; }
+
+ public Displays Displays { get; }
+
public SettingsPaths SettingsPaths { get; }
public MissingVehicleLog MissingVehicleLog { get; }
@@ -133,5 +145,9 @@ public sealed class AppServices
public SimulatorAftermath Aftermath { get; }
+ public HaslerTapes Tapes { get; }
+
+ public SaveSceneryTrainset SaveTrainset { get; }
+
public LocalizationService Localization { get; }
}
diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml
index c631bd1..f1b8397 100644
--- a/StarterNG/MainWindow.axaml
+++ b/StarterNG/MainWindow.axaml
@@ -24,7 +24,7 @@
-
+
-
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs
index a827c5a..81c4747 100644
--- a/StarterNG/MainWindow.axaml.cs
+++ b/StarterNG/MainWindow.axaml.cs
@@ -3,22 +3,20 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
-using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Threading;
using Avalonia.VisualTree;
using StarterNG.Classes;
-using StarterNG.Domain;
using StarterNG.Infrastructure;
-using StarterNG.Services;
using StarterNG.Views;
using StarterNG.Application;
using StarterNG.Application.Abstractions;
-using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Settings;
namespace StarterNG;
@@ -28,7 +26,22 @@ public partial class MainWindow : Window
private const int TitleBarHeight = 32;
+ // The smallest window the pages are laid out for, as MainWindow.axaml sets it.
+ private const double DesignMinWidth = 1010;
+ private const double DesignMinHeight = 666;
+
+ // The system frame, which a window's size does not count: at most 15 by 8 on Windows.
+ private const double FrameAllowance = 16;
+
+ // A start is over at once: a double click or a held key arrives after it.
+ private static readonly TimeSpan StartCooldown = TimeSpan.FromSeconds(3);
+
private DateTime _openedUtc = DateTime.UtcNow;
+ private DateTime _startedUtc = DateTime.MinValue;
+ private bool _starting;
+ private bool _simulatorRunning;
+ private bool _lookingAtSceneryFiles;
+ private readonly DispatcherTimer _sceneryFilesTimer = new() { Interval = TimeSpan.FromSeconds(2) };
public MainWindow()
{
@@ -38,6 +51,12 @@ public partial class MainWindow : Window
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
+ FitToScreen();
+ // Ahead of the layout a new size starts: changed during it, the bar keeps the old width.
+ Resized += (_, e) => FitTopBar(e.ClientSize.Width);
+ Opened += (_, _) => FitTopBar(ClientSize.Width);
+ PageScroll.SizeChanged += (_, _) => KeepPagesUsable();
+
if (AppServices.Current.Settings.WindowMaximized || wantsMaximized())
WindowState = WindowState.Maximized;
@@ -56,7 +75,6 @@ public partial class MainWindow : Window
startButton.AddHandler(PointerReleasedEvent, StartButton_OnRightClick,
RoutingStrategies.Tunnel);
- SettingsView.DeveloperToolsChanged += ApplyDeveloperTab;
SettingsView.ThumbnailSizeChanged += () =>
{
@@ -85,12 +103,108 @@ public partial class MainWindow : Window
_openedUtc = DateTime.UtcNow;
UpdateStartButton();
+ _sceneryFilesTimer.Start();
};
Activated += (_, _) => CheckExternalSettings();
+ _sceneryFilesTimer.Tick += async (_, _) => await LookAtSceneryFilesAsync();
+ Closed += (_, _) => _sceneryFilesTimer.Stop();
}
+ ///
+ /// Scenery files changed outside the starter are picked up without a reload. Not
+ /// with the simulator running: it is left in peace, and what it changes comes in after it.
+ ///
+ private async Task LookAtSceneryFilesAsync()
+ {
+ if (_lookingAtSceneryFiles || _simulatorRunning || !IsVisible || WindowState == WindowState.Minimized)
+ return;
+
+ _lookingAtSceneryFiles = true;
+ try
+ {
+ var services = AppServices.Current;
+ var held = services.Library.Sceneries.ToList();
+ var changes = await Task.Run(() => services.SceneryFiles.Look(held));
+ if (changes.Count == 0)
+ return;
+
+ await ScenariosView.FollowSceneryFilesAsync(changes, mayAsk: IsActive);
+ DepotView.FollowSceneryFiles();
+ }
+ catch (Exception ex)
+ {
+ Diagnostics.Log("Following the scenery files", ex);
+ }
+ finally
+ {
+ _lookingAtSceneryFiles = false;
+ }
+ }
+
+ ///
+ /// Holds the window to what the screen shows of it, so a small or heavily scaled
+ /// one (1366x768, or 1080p at 150%) keeps Start above the taskbar. The system lets
+ /// a window open larger than its screen, and centring one then puts its top off it.
+ ///
+ private void FitToScreen()
+ {
+ if ((Screens.ScreenFromPoint(Position) ?? Screens.Primary) is not { } screen)
+ return;
+
+ double width = screen.WorkingArea.Width / screen.Scaling - FrameAllowance;
+ double height = screen.WorkingArea.Height / screen.Scaling - FrameAllowance;
+ MinWidth = Math.Min(MinWidth, width);
+ MinHeight = Math.Min(MinHeight, height);
+ Width = Math.Min(Width, width);
+ Height = Math.Min(Height, height);
+ }
+
+ ///
+ /// Below the size the pages are laid out for they keep it and scroll, rather than
+ /// squeeze their controls over each other. Start is outside this and stays in view.
+ ///
+ private void KeepPagesUsable()
+ {
+ double floorWidth = DesignMinWidth - (ClientSize.Width - PageScroll.Bounds.Width);
+ double floorHeight = DesignMinHeight - (ClientSize.Height - PageScroll.Bounds.Height);
+ bool narrow = PageScroll.Bounds.Width < floorWidth;
+ bool low = PageScroll.Bounds.Height < floorHeight;
+
+ PageHost.Width = narrow ? floorWidth : double.NaN;
+ PageHost.Height = low ? floorHeight : double.NaN;
+ PageScroll.HorizontalScrollBarVisibility = narrow ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled;
+ PageScroll.VerticalScrollBarVisibility = low ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled;
+ }
+
+ // The version is in the About window too, and the logo is one more link. What fits goes
+ // by what is there: the version's length depends on the build, the rest on the language.
+ private void FitTopBar(double width)
+ {
+ static double Needed(Control control)
+ {
+ control.Measure(Size.Infinity);
+ return control.DesiredSize.Width;
+ }
+
+ VersionPanel.IsVisible = true;
+ LogoLink.IsVisible = true;
+ double room = width - Needed(TopBarLeft);
+ if (Needed(TopBarRight) > room)
+ VersionPanel.IsVisible = false;
+ if (Needed(TopBarRight) > room)
+ LogoLink.IsVisible = false;
+ }
+
+ private void FitTopBarAfterLayout() =>
+ Dispatcher.UIThread.Post(() => FitTopBar(ClientSize.Width), DispatcherPriority.Loaded);
+
private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
+ // A combo box opens on the release and leaves the press to pass on; a window
+ // move started here would swallow that release.
+ if (e.Source is Visual source && source.FindAncestorOfType(includeSelf: true) is not null)
+ return;
+
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
@@ -100,18 +214,24 @@ public partial class MainWindow : Window
if (e.InitialPressMouseButton != MouseButton.Right)
return;
- var win = new Views.AdvancedSettingsWindow();
- win.ToolRequested += tool =>
+ OpenLogAndTools();
+ e.Handled = true;
+ }
+
+ public void OpenLogAndTools()
+ {
+ var win = new Views.LogAndToolsWindow();
+ win.ToolRequested += async tool =>
{
switch (tool)
{
case "category": DepotView.ToolAddAllCategory(); break;
case "mmd": DepotView.ToolAddUniqueMmd(); break;
- case "removeall": DepotView.ToolRemoveAllTrains(); break;
+ case "removeall": await DepotView.ToolRemoveAllTrainsAsync(); break;
}
+ ScenariosView.RefreshConsistView();
};
win.ShowDialog(this);
- e.Handled = true;
}
private void OnShortcutKey(object? sender, KeyEventArgs e)
@@ -124,9 +244,12 @@ public partial class MainWindow : Window
if (Shortcuts.Match(e.Key, mods) is not { } id)
return;
- // Bare keys belong to whatever is being typed into; Ctrl/Alt combinations do not
- // collide with text entry, so those stay live everywhere.
- if (mods == KeyModifiers.None && IsTextEntryFocused())
+ // Bare keys belong to whatever is being typed into, and so do undo, redo and
+ // Ctrl+Delete, which there takes a word and not the whole consist; other
+ // Ctrl/Alt combinations do not collide with text entry, so those stay live
+ // everywhere.
+ if ((mods == KeyModifiers.None || id is Shortcuts.Undo or Shortcuts.Redo or Shortcuts.ClearConsist) &&
+ IsTextEntryFocused())
return;
switch (id)
@@ -153,6 +276,9 @@ public partial class MainWindow : Window
case Shortcuts.RemoveVehicle: DepotView.KeyRemoveVehicle(); break;
case Shortcuts.ClearConsist: DepotView.KeyClearConsist(); break;
case Shortcuts.SetNumber: DepotView.KeySetWagonNumber(); break;
+ case Shortcuts.Undo: DepotView.Undo(); break;
+ case Shortcuts.Redo: DepotView.Redo(); break;
+ case Shortcuts.SaveToScenery: DepotView.KeySaveToScenery(); break;
default: return;
}
break;
@@ -174,7 +300,7 @@ public partial class MainWindow : Window
if (sender is not RadioButton { IsChecked: true } rb)
return;
- if (ScenariosView is null || DepotView is null || SettingsView is null || DeveloperView is null)
+ if (ScenariosView is null || DepotView is null || SettingsView is null)
return;
ShowPage(rb.Tag as string);
@@ -186,31 +312,12 @@ public partial class MainWindow : Window
ScenariosView.IsVisible = page == "scenarios";
DepotView.IsVisible = page == "depot";
SettingsView.IsVisible = page == "settings";
- DeveloperView.IsVisible = page == "developer";
-
- if (DeveloperView.IsVisible)
- DeveloperView.Refresh();
- }
-
- ///
- /// Shows or hides the developer tab. Switching it off while it is the open one
- /// hands the window back to the scenarios, so the top bar never ends up with
- /// nothing selected.
- ///
- private void ApplyDeveloperTab()
- {
- bool wanted = AppServices.Current.Settings.DeveloperTools;
- NavDeveloper.IsVisible = wanted;
-
- if (!wanted && NavDeveloper.IsChecked == true)
- NavScenarios.IsChecked = true;
+ PageScroll.Offset = default;
}
private void ApplyInitialNav()
{
- ApplyDeveloperTab();
-
- foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings, NavDeveloper })
+ foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings })
if (rb.IsChecked == true)
{
ShowPage(rb.Tag as string);
@@ -222,7 +329,7 @@ public partial class MainWindow : Window
private void PopulateLangCombo()
{
LangCombo.Items.Clear();
- foreach (var lang in LocalizationService.AvailableLanguages())
+ foreach (var lang in App.Loc.Languages())
LangCombo.Items.Add(new ComboBoxItem { Content = lang.Name, Tag = lang.Code });
if (LangCombo.ItemCount == 0)
LangCombo.Items.Add(new ComboBoxItem { Content = "English", Tag = "en" });
@@ -243,13 +350,8 @@ public partial class MainWindow : Window
private void LangCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
- if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code)
- return;
-
- if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
- return;
-
- App.ApplyLanguage(code);
+ if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is string code)
+ App.ChangeLanguage(code);
}
private void OnLanguageChanged()
@@ -258,33 +360,28 @@ public partial class MainWindow : Window
ScenariosView?.RebuildAfterLanguageChange();
DepotView?.RebuildAfterLanguageChange();
UpdateStartButton();
+ FitTopBarAfterLayout();
+
+ // A page not on the screen keeps what it measured: back in view, a tab strip
+ // in a fixed column went on clipping its new headers at the old words' widths.
+ foreach (var control in PageHost.GetVisualDescendants().OfType())
+ control.InvalidateMeasure();
}
private void HelpButton_OnClick(object? sender, RoutedEventArgs e)
{
var menu = new ContextMenu();
- var keys = new MenuItem
- {
- Header = App.Loc["ShortcutsTitle"],
- InputGesture = new KeyGesture(Key.F1)
- };
- keys.Click += (_, _) => Views.ShortcutsWindow.Create().ShowDialog(this);
- menu.Items.Add(keys);
+ menu.AddItem(App.Loc["ShortcutsTitle"], () => Views.ShortcutsWindow.Create().ShowDialog(this),
+ gesture: new KeyGesture(Key.F1));
menu.Items.Add(new Separator());
- var wiki = new MenuItem { Header = App.Loc["HelpWiki"] };
- wiki.Click += (_, _) => OpenPathOrUrl("https://wiki.eu07.pl/");
- menu.Items.Add(wiki);
+ menu.AddItem(App.Loc["HelpWiki"], () => OpenPathOrUrl("https://wiki.eu07.pl/"));
string readme = string.Equals(App.Loc.CurrentLangCode, "pl", StringComparison.OrdinalIgnoreCase)
? "readme.html" : "en-readme.html";
if (File.Exists(readme) || File.Exists(Path.Combine(Directory.GetCurrentDirectory(), readme)))
- {
- var mi = new MenuItem { Header = App.Loc["HelpReadme"] };
- mi.Click += (_, _) => OpenPathOrUrl(readme);
- menu.Items.Add(mi);
- }
+ menu.AddItem(App.Loc["HelpReadme"], () => OpenPathOrUrl(readme));
string regs = Path.Combine(Directory.GetCurrentDirectory(), "przepisy_kolejowe");
if (Directory.Exists(regs))
@@ -293,12 +390,13 @@ public partial class MainWindow : Window
foreach (string pdf in Directory.GetFiles(regs, "*.pdf").OrderBy(Path.GetFileName))
{
string file = pdf;
- var mi = new MenuItem { Header = Path.GetFileName(file) };
- mi.Click += (_, _) => OpenPathOrUrl(file);
- menu.Items.Add(mi);
+ menu.AddItem(Path.GetFileName(file), () => OpenPathOrUrl(file));
}
}
+ menu.Items.Add(new Separator());
+ menu.AddItem(App.Loc["LogAndTools"], OpenLogAndTools);
+
// "About" belongs with the other informational entries, not next to the
// Save/Reset buttons of the settings panel where it used to sit.
menu.Items.Add(new Separator());
@@ -355,23 +453,14 @@ public partial class MainWindow : Window
}
}
- private async void openLastLogClick(object? sender, RoutedEventArgs e)
- {
- string logPath = Path.GetFullPath("log.txt");
- if (!File.Exists(logPath))
- {
- await Diagnostics.ReportAsync(string.Format(App.Loc["FaultFileNotFound"], logPath));
- return;
- }
+ private void openLastLogClick(object? sender, RoutedEventArgs e) =>
+ Diagnostics.OpenOrReport(Path.GetFullPath("log.txt"));
- try
- {
- Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true });
- }
- catch (Exception ex)
- {
- await Diagnostics.ReportAsync(logPath, ex);
- }
+ private void openHaslerTapesClick(object? sender, RoutedEventArgs e)
+ {
+ var window = new Views.Tapes.HaslerTapeWindow();
+ window.LogEnabled += SettingsView.SyncLogs;
+ window.Show(this);
}
private void UpdateStartButton()
@@ -380,6 +469,9 @@ public partial class MainWindow : Window
var scenery = AppServices.Current.State.CurrentScenery;
var trainset = AppServices.Current.State.CurrentTrainset;
+ startEditorButton.Content = App.Loc[scenery is null ? "StartEditorWizard" : "StartEditorShort"];
+ ToolTip.SetTip(startEditorButton, App.Loc[scenery is null ? "StartEditorWizardTip" : "StartEditor"]);
+ startEditorMoreButton.IsVisible = scenery is not null;
if (scenery is null)
{
@@ -389,8 +481,11 @@ public partial class MainWindow : Window
}
if (trainset is null)
{
- startButton.Content = App.Loc["StartNoTrain"];
- startButton.IsEnabled = false;
+ // Nothing to drive in this scenery - none listed, or none the filters
+ // show: the free camera is what a start gives.
+ startButton.Content = App.Loc["StartFreeFly"];
+ ToolTip.SetTip(startButton, null);
+ startButton.IsEnabled = true;
return;
}
@@ -400,26 +495,34 @@ public partial class MainWindow : Window
startButton.IsEnabled = true;
}
- private static bool HasStartableVehicle(Trainset trainset)
- {
- static bool CanStart(Dynamic v) =>
- v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
-
- string? selected = AppServices.Current.State.StartingVehicleName;
- if (!string.IsNullOrEmpty(selected))
- {
- var pick = trainset.Vehicles.FirstOrDefault(v =>
- string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase));
- if (pick != null)
- return CanStart(pick);
- }
- return trainset.Vehicles.Any(CanStart);
- }
+ private static bool HasStartableVehicle(Trainset trainset) =>
+ StartSimulation.HasCrewToStart(trainset, AppServices.Current.State.StartingVehicleName);
private Task ShowExeProblem(ExeProblem problem, string exe) =>
MessageBox.Show(this, $"{exe}\n\n{App.Loc[ExeProblemText.Key(problem)]}",
App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok);
+ private const string SimulatorBuildGuide = "https://github.com/MaSzyna-EU07/maszyna#getting-started";
+
+ ///
+ /// Tells a user whose processor lacks AVX2 that the published simulator will
+ /// not run, and where the instructions for a build of their own are.
+ ///
+ public async Task ShowCpuNoticeAsync()
+ {
+ var (open, silence) = await MessageBox.ShowNotice(
+ this, string.Format(App.Loc["CpuNoAvx2"], SimulatorBuildGuide), App.Loc["CpuNoAvx2Title"],
+ App.Loc["CpuNoAvx2Open"], App.Loc["DontShowAgain"]);
+
+ if (open)
+ OpenPathOrUrl(SimulatorBuildGuide);
+ if (silence)
+ {
+ AppServices.Current.Settings.CpuNotice = false;
+ AppServices.Current.SettingsStore.Save();
+ }
+ }
+
///
/// Asks for the simulator when the installation holds none, and keeps the pick
/// straight away, so the question is not back on the next start.
@@ -450,26 +553,33 @@ public partial class MainWindow : Window
if (e.InitialPressMouseButton != MouseButton.Right)
return;
+ OpenStartMenu(startButton);
+ }
+
+ private void StartMoreButton_OnClick(object? sender, RoutedEventArgs e) => OpenStartMenu(startMoreButton);
+
+ private async void StartEditorButton_OnClick(object? sender, RoutedEventArgs e) => await LaunchEditorAsync();
+
+ private void StartEditorMoreButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ var create = new MenuItem { Header = App.Loc["StartEditorWizard"] };
+ create.Click += async (_, _) => await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteNewSceneryEditor);
+ var menu = new ContextMenu();
+ menu.Items.Add(create);
+ menu.Open(startEditorMoreButton);
+ }
+
+ private void OpenStartMenu(Control anchor)
+ {
var menu = new ContextMenu();
- var noSave = new MenuItem { Header = App.Loc["StartWithoutSaving"] };
- noSave.Click += async (_, _) => await LaunchAsync(saveSettings: false, freeFly: false);
- menu.Items.Add(noSave);
+ // Both need a consist to start in, as the start button itself does.
+ menu.AddItem(App.Loc["StartWithoutSaving"], async () => await LaunchAsync(saveSettings: false, freeFly: false),
+ startButton.IsEnabled);
+ menu.AddItem(App.Loc["StartFreeFly"], async () => await LaunchAsync(saveSettings: true, freeFly: true),
+ startButton.IsEnabled);
- var freeFly = new MenuItem { Header = App.Loc["StartFreeFly"] };
- freeFly.Click += async (_, _) => await LaunchAsync(saveSettings: true, freeFly: true);
- menu.Items.Add(freeFly);
-
- menu.Items.Add(new Separator());
- var editor = new MenuItem
- {
- Header = App.Loc["StartEditor"],
- IsEnabled = AppServices.Current.State.CurrentScenery is not null
- };
- editor.Click += async (_, _) => await LaunchEditorAsync();
- menu.Items.Add(editor);
-
- menu.Open(startButton);
+ menu.Open(anchor);
}
///
@@ -478,40 +588,56 @@ public partial class MainWindow : Window
///
private async Task LaunchAsync(bool saveSettings, bool freeFly)
{
- var scenery = AppServices.Current.State.CurrentScenery;
- var trainset = AppServices.Current.State.CurrentTrainset;
- if (scenery is null || trainset is null)
+ // A second press while the first is still on its way, or right after it,
+ // would start a second simulator.
+ if (_starting || DateTime.UtcNow - _startedUtc < StartCooldown)
return;
- if (!freeFly && !HasStartableVehicle(trainset) &&
- !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"],
- App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
- return;
+ _starting = true;
+ try
+ {
+ var scenery = AppServices.Current.State.CurrentScenery;
+ var trainset = AppServices.Current.State.CurrentTrainset;
+ if (scenery is null)
+ return;
+ freeFly |= trainset is null;
- LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
+ if (!freeFly && trainset != null && !HasStartableVehicle(trainset) &&
+ !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"],
+ App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
+ return;
- await FollowStartAsync(AppServices.Current.StartSimulation.Execute(freeFly, saveSettings));
+ LoadingScreen.Prepare(trainset?.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
+
+ await FollowStartAsync(() => AppServices.Current.StartSimulation.Execute(freeFly, saveSettings));
+ }
+ finally
+ {
+ _starting = false;
+ }
}
///
/// The scenery editor, which only some simulator builds have - said up front,
- /// since one without it simply does not open it.
+ /// since one without it simply does not open it, and it writes to the scenery
+ /// itself. With none selected there is nothing to write to: the simulator's
+ /// wizard makes a new one.
///
private async Task LaunchEditorAsync()
{
- if (AppServices.Current.State.CurrentScenery is not { } scenery)
- return;
-
- if (!await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)),
+ if (AppServices.Current.State.CurrentScenery is { } scenery &&
+ !await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)),
App.Loc["StartEditor"], MessageBoxButtons.YesNo))
return;
- await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor());
+ await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor);
}
/// Reports a start that failed, or steps aside for the simulator that started.
- private async Task FollowStartAsync(SimulationStartResult result)
+ /// The start, run again once what stopped it is put right.
+ private async Task FollowStartAsync(Func start)
{
+ var result = start();
switch (result.Outcome)
{
case SimulationStartOutcome.NothingSelected:
@@ -533,6 +659,8 @@ public partial class MainWindow : Window
return;
}
+ _startedUtc = DateTime.UtcNow;
+
if (AppServices.Current.Settings.AutoCloseStarter)
{
Close();
@@ -562,8 +690,10 @@ public partial class MainWindow : Window
return;
}
+ _simulatorRunning = true;
_ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() =>
{
+ _simulatorRunning = false;
RestoreFromSimulator();
ReportIfCrashed(simulator, executable, started);
}), TaskScheduler.Default);
@@ -588,7 +718,7 @@ public partial class MainWindow : Window
var words = exit.CleanErrors.Count > 0
? exit.CleanErrors
- : AppServices.Current.Aftermath.SimulatorLogTail(12);
+ : AppServices.Current.Aftermath.SimulatorLogTail(12, startedUtc);
var message = new StringBuilder();
message.AppendLine(string.Format(App.Loc["SimExitCode"], exit.DescribeCode()));
diff --git a/StarterNG/Program.cs b/StarterNG/Program.cs
index 635bec6..2c1beca 100644
--- a/StarterNG/Program.cs
+++ b/StarterNG/Program.cs
@@ -1,4 +1,5 @@
using Avalonia;
+using Avalonia.Threading;
using System;
using System.Threading.Tasks;
using StarterNG.Application;
@@ -50,9 +51,22 @@ static class Program
return builder;
}
+ // After setup: the dispatcher takes its platform's implementation when first asked
+ // for. Not marked handled: a handler that failed half way leaves the state unknown,
+ // and the exception goes on to end the program as before, only no longer unexplained.
+ private static void LogUiExceptions()
+ {
+ Dispatcher.UIThread.UnhandledException += (_, e) =>
+ {
+ Diagnostics.Log("Unhandled UI exception", e.Exception);
+ Diagnostics.Flush();
+ };
+ }
+
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
.WithInterFont()
- .WithDiagnosticsLogging();
+ .WithDiagnosticsLogging()
+ .AfterSetup(_ => LogUiExceptions());
}