From 4aec6890598aa1e4a483a79d9a13ecf7a1ff2211 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 4 Sep 2026 21:26:28 +0200 Subject: [PATCH] Take the scenario presentation logic out of the code-behind Scenery tree, fog scale, season dates and timetable lookup become their own classes. --- StarterNG.Tests/Fakes/InMemoryFileSystem.cs | 21 ++- StarterNG.Tests/ScenarioPresentationTests.cs | 167 ++++++++++++++++++ StarterNG/Application/AppServices.cs | 3 + .../Sceneries/TimetableLocator.cs | 48 +++++ StarterNG/Presentation/Scenarios/FogScale.cs | 45 +++++ .../Presentation/Scenarios/SceneryTree.cs | 84 +++++++++ .../Presentation/Scenarios/SeasonDates.cs | 39 ++++ StarterNG/Views/Scenarios.axaml.cs | 140 ++++----------- 8 files changed, 438 insertions(+), 109 deletions(-) create mode 100644 StarterNG.Tests/ScenarioPresentationTests.cs create mode 100644 StarterNG/Infrastructure/Sceneries/TimetableLocator.cs create mode 100644 StarterNG/Presentation/Scenarios/FogScale.cs create mode 100644 StarterNG/Presentation/Scenarios/SceneryTree.cs create mode 100644 StarterNG/Presentation/Scenarios/SeasonDates.cs diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs index 5269250..2dddd26 100644 --- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -19,15 +19,30 @@ public sealed class InMemoryFileSystem : IFileSystem public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - /// Adds a text file, creating its parent directories. - public InMemoryFileSystem WithFile(string path, string contents) + static InMemoryFileSystem() + { + // The game's own files are code page 1250; a test that writes Polish text + // into one needs the provider registered just as the application does. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + /// Adds a UTF-8 text file, creating its parent directories. + public InMemoryFileSystem WithFile(string path, string contents) => + WithFile(path, contents, Encoding.UTF8); + + /// Adds a text file in a given encoding, as the game's files are stored. + public InMemoryFileSystem WithFile(string path, string contents, Encoding encoding) { string key = Normalize(path); - _files[key] = Encoding.UTF8.GetBytes(contents); + _files[key] = encoding.GetBytes(contents); AddParents(key); return this; } + /// Adds a file in the code page the .scn and .fiz formats use. + public InMemoryFileSystem WithLegacyFile(string path, string contents) => + WithFile(path, contents, Encoding.GetEncoding(1250)); + /// Adds an empty directory. public InMemoryFileSystem WithDirectory(string path) { diff --git a/StarterNG.Tests/ScenarioPresentationTests.cs b/StarterNG.Tests/ScenarioPresentationTests.cs new file mode 100644 index 0000000..b3f9ce5 --- /dev/null +++ b/StarterNG.Tests/ScenarioPresentationTests.cs @@ -0,0 +1,167 @@ +using StarterNG.Domain.Sceneries; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Presentation.Scenarios; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class FogScaleTests +{ + [Fact] + public void The_slider_ends_land_on_the_supported_visibility_range() + { + Assert.Equal(SceneryWeather.FogMin, FogScale.ToMetres(0)); + Assert.Equal(SceneryWeather.FogMax, FogScale.ToMetres(FogScale.SliderRange)); + } + + [Fact] + public void A_distance_survives_a_round_trip_through_the_slider() + { + foreach (int metres in new[] { 50, 200, 1500, 8000 }) + Assert.Equal(metres, FogScale.ToMetres(FogScale.ToSlider(metres))); + } + + [Fact] + public void Distances_snap_to_a_step_that_grows_with_the_range() + { + Assert.Equal(0, FogScale.ToMetres(200) % 5); + Assert.Equal(0, FogScale.ToMetres(700) % 100); + } + + [Fact] + public void Metres_below_a_kilometre_kilometres_above_it() + { + var culture = System.Globalization.CultureInfo.InvariantCulture; + + Assert.Equal("250 m", FogScale.Format(250, culture)); + Assert.Equal("2.5 km", FogScale.Format(2500, culture)); + } +} + +public class SeasonDatesTests +{ + private static SeasonDates On(DateTime now) => new(new FixedClock(now)); + + [Fact] + public void A_stored_day_of_year_becomes_that_day_in_the_current_year() + { + var date = On(new DateTime(2026, 7, 1)).DateOf(100); + + Assert.Equal(2026, date.Year); + Assert.Equal(100, date.DayOfYear); + } + + [Fact] + public void Day_zero_means_today() + { + var now = new DateTime(2026, 5, 4); + + Assert.Equal(now.DayOfYear, On(now).DateOf(0).DayOfYear); + } + + [Fact] + public void The_last_day_of_a_leap_year_stays_selectable() + { + Assert.Equal(366, On(new DateTime(2028, 1, 1)).DateOf(366).DayOfYear); + Assert.Equal(365, On(new DateTime(2026, 1, 1)).DateOf(400).DayOfYear); + } +} + +public class SceneryTreeBuilderTests +{ + private static (SceneryTreeBuilder Builder, List Sceneries) Build(params string[] contents) + { + var files = new InMemoryFileSystem(); + for (int i = 0; i < contents.Length; i++) + files.WithLegacyFile(TestInstallation.At("scenery", $"s{i}.scn"), contents[i]); + + var installation = new TestInstallation(files); + var sceneries = installation.Sceneries.LoadAll().ToList(); + return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries); + } + + [Fact] + public void Scenarios_declaring_a_group_are_gathered_under_it() + { + var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Bez grupy"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en"); + + var group = Assert.Single(nodes, node => node.IsGroup); + Assert.Equal("Śląsk", group.Label); + Assert.Equal(2, group.Children.Count); + Assert.Single(nodes, node => !node.IsGroup); + } + + [Fact] + public void Archival_scenarios_are_left_out_unless_asked_for() + { + var (builder, sceneries) = Build("//$a", "//$n Zwykla"); + + Assert.Single(builder.Build(sceneries, includeArchival: false, "en")); + Assert.Equal(2, builder.Build(sceneries, includeArchival: true, "en").Count); + } + + [Fact] + public void Nodes_carry_the_index_of_the_scenery_they_stand_for() + { + var (builder, sceneries) = Build("//$n Pierwsza", "//$n Druga"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en"); + + foreach (var node in nodes) + Assert.Equal(node.Label, sceneries[node.SceneryIndex].DisplayName); + } + + [Fact] + public void Both_levels_are_sorted_by_name() + { + var (builder, sceneries) = Build("//$l Zet", "//$l Alfa", "//$l Alfa"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en"); + + Assert.Equal(new[] { "Alfa", "Zet" }, nodes.Select(node => node.Label)); + Assert.Equal(nodes[0].Children.Select(child => child.Label).OrderBy(label => label), + nodes[0].Children.Select(child => child.Label)); + } +} + +public class TimetableLocatorTests +{ + [Fact] + public void A_timetable_is_found_in_the_shared_folder_first() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład") + .WithFile(TestInstallation.At("scenery", "r1.txt"), "rozkład obok scenerii"); + var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll()); + + Assert.Equal(TestInstallation.At("timetables", "r1.txt"), + new TimetableLocator(files).Resolve(scenery, "r1")); + } + + [Fact] + public void A_name_already_carrying_its_extension_is_found_too() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład"); + var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll()); + + Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt")); + } + + [Fact] + public void The_formats_none_and_a_missing_file_both_resolve_to_nothing() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice"); + var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll()); + var locator = new TimetableLocator(files); + + Assert.Null(locator.Resolve(scenery, "none")); + Assert.Null(locator.Resolve(scenery, "nieistniejacy")); + Assert.Null(locator.Resolve(scenery, null)); + } +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 74fa09c..d12d0f9 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -48,6 +48,7 @@ public sealed class AppServices Physics = new FizPhysicsRepository(files, paths, log); 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(); @@ -109,6 +110,8 @@ public sealed class AppServices public ISceneryTranslations SceneryTexts { get; } + public TimetableLocator Timetables { get; } + public LoadWeightsRepository LoadWeights { get; } /// Everything read out of the installation: rolling stock and scenarios. diff --git a/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs new file mode 100644 index 0000000..0fe1dcc --- /dev/null +++ b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Infrastructure.Sceneries; + +/// +/// Finds the timetable file a consist names. +/// +/// +/// Timetables live in the shared timetables/ folder, next to the scenery, or in +/// scenario/ depending on the installation's vintage, and the name may or may +/// not carry its .txt extension. "none" is the format's way of saying a consist +/// runs without one. +/// +public sealed class TimetableLocator +{ + private const string NoTimetable = "none"; + + private readonly IFileSystem _files; + + public TimetableLocator(IFileSystem files) + { + _files = files; + } + + public string? Resolve(Scenery scenery, string? name) + { + if (string.IsNullOrWhiteSpace(name) || name.Equals(NoTimetable, StringComparison.OrdinalIgnoreCase)) + return null; + + string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? "."; + string root = Path.GetDirectoryName(sceneryDir) ?? "."; + + string[] candidates = + { + Path.Combine(root, "timetables", name + ".txt"), + Path.Combine(sceneryDir, name + ".txt"), + Path.Combine(root, "scenario", name + ".txt"), + Path.Combine(root, "timetables", name), + Path.Combine(sceneryDir, name) + }; + + return candidates.FirstOrDefault(_files.FileExists); + } +} diff --git a/StarterNG/Presentation/Scenarios/FogScale.cs b/StarterNG/Presentation/Scenarios/FogScale.cs new file mode 100644 index 0000000..b95c82c --- /dev/null +++ b/StarterNG/Presentation/Scenarios/FogScale.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Presentation.Scenarios; + +/// +/// Converts between the weather tab's fog slider and a distance in metres. +/// +/// +/// Visibility runs from 10 m to 10 km, a range no linear slider handles well: +/// the interesting part - thick fog, where a few metres matter - would be a +/// couple of pixels wide. The slider is therefore logarithmic over its 0-1000 +/// travel, and the result snaps to a step that grows with the distance, so the +/// read-out lands on round numbers a driver would recognise. +/// +public static class FogScale +{ + /// Travel of the slider the scale is spread over. + public const double SliderRange = 1000.0; + + public static int ToMetres(double sliderPosition) + { + double metres = SceneryWeather.FogMin * + Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin, + sliderPosition / SliderRange); + + int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100; + return Math.Clamp((int)(Math.Round(metres / step) * step), + SceneryWeather.FogMin, SceneryWeather.FogMax); + } + + public static double ToSlider(int metres) + { + double clamped = Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax); + return SliderRange * Math.Log(clamped / SceneryWeather.FogMin) / + Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin); + } + + /// Metres below a kilometre, kilometres above it. + public static string Format(int metres, CultureInfo culture) => + metres < 1000 + ? $"{metres} m" + : string.Format(culture, "{0:0.#} km", metres / 1000.0); +} diff --git a/StarterNG/Presentation/Scenarios/SceneryTree.cs b/StarterNG/Presentation/Scenarios/SceneryTree.cs new file mode 100644 index 0000000..12569c7 --- /dev/null +++ b/StarterNG/Presentation/Scenarios/SceneryTree.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Presentation.Scenarios; + +/// +/// A node of the scenery list: either a scenario, or a group holding scenarios. +/// +/// Text to show. +/// Index into the scenery list, or -1 for a group. +/// Scenarios under a group; empty for a scenario. +public sealed record SceneryTreeNode(string Label, int SceneryIndex, IReadOnlyList Children) +{ + public bool IsGroup => SceneryIndex < 0; +} + +/// +/// Turns the flat scenery list into the tree the scenario picker shows: +/// scenarios that declare a group sit under it, the rest stay at the top, and +/// everything is sorted by name. +/// +/// +/// Pulled out of the view so the grouping and the archival filter can be tested +/// without a TreeView. The view's job is reduced to turning these nodes into +/// controls. +/// +public sealed class SceneryTreeBuilder +{ + private readonly ISceneryTranslations _translations; + + public SceneryTreeBuilder(ISceneryTranslations translations) + { + _translations = translations; + } + + /// The list the node indices refer to. + /// Whether scenarios marked archival are listed. + /// Language for resolving each scenery's own translations. + public IReadOnlyList Build(IReadOnlyList sceneries, bool includeArchival, + string langCode) + { + var groups = new Dictionary>(StringComparer.Ordinal); + var groupLabels = new Dictionary(StringComparer.Ordinal); + var topLevel = new List(); + + for (int i = 0; i < sceneries.Count; i++) + { + var scenery = sceneries[i]; + if (scenery.Archival && !includeArchival) + continue; + + // The group name may itself be an "@key" reference into the scenery's + // own translation files, so the table has to be loaded per scenery. + _translations.LoadFor(scenery, langCode); + + var node = new SceneryTreeNode(scenery.DisplayName, i, Array.Empty()); + + if (string.IsNullOrEmpty(scenery.Group)) + { + topLevel.Add(node); + continue; + } + + if (!groups.TryGetValue(scenery.Group, out var members)) + { + members = new List(); + groups[scenery.Group] = members; + groupLabels[scenery.Group] = _translations.Translate(scenery.Group); + } + members.Add(node); + } + + foreach (var (group, members) in groups) + topLevel.Add(new SceneryTreeNode(groupLabels[group], -1, Sorted(members))); + + return Sorted(topLevel); + } + + private static List Sorted(IEnumerable nodes) => + nodes.OrderBy(node => node.Label, StringComparer.OrdinalIgnoreCase).ToList(); +} diff --git a/StarterNG/Presentation/Scenarios/SeasonDates.cs b/StarterNG/Presentation/Scenarios/SeasonDates.cs new file mode 100644 index 0000000..5a938a4 --- /dev/null +++ b/StarterNG/Presentation/Scenarios/SeasonDates.cs @@ -0,0 +1,39 @@ +using System; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Presentation.Scenarios; + +/// +/// Translates between the scenery's day-of-year (movelight) and the date the +/// weather tab shows. +/// +/// +/// Day 0 is the scenery saying "whatever today is", which the tab presents as +/// the current date; the season presets are ordinary days of the year. Leap +/// years are honoured so 366 stays selectable in one. +/// +public sealed class SeasonDates +{ + private readonly IClock _clock; + + public SeasonDates(IClock clock) + { + _clock = clock; + } + + /// The day-of-year a scenery means by 0: today. + public int Today => _clock.Now.DayOfYear; + + /// + /// The date to show for a stored day-of-year, in the current year. Day 0 and + /// out-of-range values fall back to today, clamped to the year's length. + /// + public DateTimeOffset DateOf(int dayOfYear) + { + int year = _clock.Now.Year; + int lastDay = DateTime.IsLeapYear(year) ? 366 : 365; + int day = Math.Clamp(dayOfYear <= 0 ? Today : dayOfYear, 1, lastDay); + + return new DateTimeOffset(new DateTime(year, 1, 1).AddDays(day - 1)); + } +} diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index de3e3a7..84bf2ee 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -19,6 +19,7 @@ using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Application; using StarterNG.Domain.Sceneries; +using StarterNG.Presentation.Scenarios; namespace StarterNG.Views; @@ -65,60 +66,29 @@ public partial class Scenarios : UserControl { sceneryList.Items.Clear(); - bool showArchival = archivalSwitch.IsChecked != true; - var groupNodes = new Dictionary(); - var topLevel = new List(); + bool includeArchival = archivalSwitch.IsChecked != true; + bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree; - for (int i = 0; i < Sceneries.Count; i++) + var nodes = new SceneryTreeBuilder(AppServices.Current.SceneryTexts) + .Build(Sceneries, includeArchival, App.Loc.CurrentLangCode); + + foreach (var node in nodes) + sceneryList.Items.Add(ToTreeItem(node, expandGroups)); + } + + private static TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups) + { + var item = new TreeViewItem { - var scenery = Sceneries[i]; - if (scenery.Archival && !showArchival) - continue; + Header = node.Label, + Tag = node.IsGroup ? null : node.SceneryIndex, + IsExpanded = node.IsGroup && expandGroups + }; - bool expand = AppServices.Current.Settings.AutoExpandSceneryTree; - AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode); - string label = scenery.DisplayName; + foreach (var child in node.Children) + item.Items.Add(ToTreeItem(child, expandGroups)); - if (string.IsNullOrEmpty(scenery.Group)) - { - topLevel.Add(new TreeViewItem - { - Header = label, - Tag = i - }); - continue; - } - - if (!groupNodes.TryGetValue(scenery.Group, out var groupNode)) - { - groupNode = new TreeViewItem - { - Header = AppServices.Current.SceneryTexts.Translate(scenery.Group), - IsExpanded = expand - }; - groupNodes[scenery.Group] = groupNode; - topLevel.Add(groupNode); - } - - groupNode.Items.Add(new TreeViewItem - { - Header = label, - Tag = i - }); - } - - foreach (var node in groupNodes.Values) - { - var children = node.Items.Cast() - .OrderBy(c => c.Header as string, StringComparer.OrdinalIgnoreCase) - .ToList(); - node.Items.Clear(); - foreach (var child in children) - node.Items.Add(child); - } - - foreach (var item in topLevel.OrderBy(t => t.Header as string, StringComparer.OrdinalIgnoreCase)) - sceneryList.Items.Add(item); + return item; } private void RestoreLastScenery() @@ -555,9 +525,9 @@ public partial class Scenarios : UserControl weatherForm.IsEnabled = true; var weather = scenery.Weather; weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride); - weatherDate.SelectedDate = DateOfDayOfYear(weather.Day); + weatherDate.SelectedDate = Seasons.DateOf(weather.Day); weatherTemp.Value = weather.Temperature; - weatherFog.Value = SliderFromFog(weather.FogEnd); + weatherFog.Value = FogScale.ToSlider(weather.FogEnd); SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0"); SelectByTag(weatherOvercast, weather.OvercastRandom ? RandomTag @@ -583,7 +553,7 @@ public partial class Scenarios : UserControl weather.Day = _todaySeason ? 0 : SelectedDayOfYear(); weather.Temperature = weatherTemp.Value; - weather.FogEnd = FogFromSlider(weatherFog.Value); + weather.FogEnd = FogScale.ToMetres(weatherFog.Value); if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov) { weather.OvercastRandom = ov == RandomTag; @@ -599,32 +569,12 @@ public partial class Scenarios : UserControl private void UpdateWeatherLabels() { weatherTempLabel.Text = $"{(int)weatherTemp.Value} °C"; - weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value)); + weatherFogLabel.Text = FogScale2(FogScale.ToMetres(weatherFog.Value)); weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear()); weatherRestore.IsEnabled = _currentScenery?.Weather.Changed ?? false; } - private static int FogFromSlider(double position) - { - double metres = SceneryWeather.FogMin * - System.Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin, position / 1000.0); - int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100; - return System.Math.Clamp((int)(System.Math.Round(metres / step) * step), - SceneryWeather.FogMin, SceneryWeather.FogMax); - } - - private static double SliderFromFog(int metres) - { - double clamped = System.Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax); - return 1000.0 * System.Math.Log(clamped / SceneryWeather.FogMin) / - System.Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin); - } - - private static string FormatFog(int metres) => - metres < 1000 - ? $"{metres} m" - : string.Format(LanguageCulture, "{0:0.#} km", metres / 1000.0); private static CultureInfo LanguageCulture { @@ -644,6 +594,12 @@ public partial class Scenarios : UserControl } } + /// Day-of-year arithmetic for the weather tab's date and season pickers. + private static SeasonDates Seasons => new(AppServices.Current.Clock); + + /// Fog read-out in the language the user is reading the rest in. + private static string FogScale2(int metres) => FogScale.Format(metres, LanguageCulture); + private const string RandomTag = "random"; private static void SelectByTag(ComboBox combo, string tag, string? excludeFromNearest = null) @@ -714,7 +670,7 @@ public partial class Scenarios : UserControl { bool prev = _loadingWeather; _loadingWeather = true; - weatherDate.SelectedDate = DateOfDayOfYear(0); + weatherDate.SelectedDate = Seasons.DateOf(0); _loadingWeather = prev; } } @@ -735,16 +691,8 @@ public partial class Scenarios : UserControl CaptureWeather(); } - private static DateTimeOffset DateOfDayOfYear(int day) - { - int year = DateTime.Now.Year; - int last = DateTime.IsLeapYear(year) ? 366 : 365; - int d = System.Math.Clamp(day <= 0 ? DateTime.Now.DayOfYear : day, 1, last); - return new DateTimeOffset(new DateTime(year, 1, 1).AddDays(d - 1)); - } - private int SelectedDayOfYear() => - weatherDate.SelectedDate is { } date ? date.DayOfYear : DateTime.Now.DayOfYear; + weatherDate.SelectedDate is { } date ? date.DayOfYear : Seasons.Today; private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e) { @@ -761,7 +709,7 @@ public partial class Scenarios : UserControl { ApplyTodayPreview(false); _loadingWeather = true; - weatherDate.SelectedDate = DateOfDayOfYear(day); + weatherDate.SelectedDate = Seasons.DateOf(day); _loadingWeather = false; } } @@ -770,7 +718,7 @@ public partial class Scenarios : UserControl private void ShowTimetable(Scenery scenery, Trainset trainset) { - string? path = ResolveTimetablePath(scenery, trainset.Name); + string? path = AppServices.Current.Timetables.Resolve(scenery, trainset.Name); if (path is null) { timetableContent.Text = App.Loc["NoTimetable"]; @@ -801,26 +749,6 @@ public partial class Scenarios : UserControl tabs.SelectedIndex = 0; } - private static string? ResolveTimetablePath(Scenery scenery, string? name) - { - if (string.IsNullOrWhiteSpace(name) || - name.Equals("none", System.StringComparison.OrdinalIgnoreCase)) - return null; - - string scnDir = Path.GetDirectoryName(scenery.Path) ?? "."; - string root = Path.GetDirectoryName(scnDir) ?? "."; - - var candidates = new[] - { - Path.Combine(root, "timetables", name + ".txt"), - Path.Combine(scnDir, name + ".txt"), - Path.Combine(root, "scenario", name + ".txt"), - Path.Combine(root, "timetables", name), - Path.Combine(scnDir, name), - }; - return candidates.FirstOrDefault(File.Exists); - } - private const double ConsistDragThreshold = 6; private Trainset? _stripTrainset;