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()); }