From ceab05462a0cc745dfc50e156b44cc11b9fb76da Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 16:06:16 +0100 Subject: [PATCH] Fit the main window to small screens and wire up the new services Pages scroll below the size they are laid out for and Start stays in view. The crash window shows this session's log only. --- StarterNG/App.axaml | 11 + StarterNG/App.axaml.cs | 31 ++- StarterNG/Application/AppServices.cs | 68 +++-- StarterNG/MainWindow.axaml | 74 ++++-- StarterNG/MainWindow.axaml.cs | 384 ++++++++++++++++++--------- StarterNG/Program.cs | 16 +- 6 files changed, 400 insertions(+), 184 deletions(-) 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()); }