diff --git a/StarterNG.Tests/FavoriteSceneriesTests.cs b/StarterNG.Tests/FavoriteSceneriesTests.cs new file mode 100644 index 0000000..098fb8c --- /dev/null +++ b/StarterNG.Tests/FavoriteSceneriesTests.cs @@ -0,0 +1,38 @@ +using StarterNG.Application; +using StarterNG.Domain.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class FavoriteSceneriesTests +{ + private const string FavoritesPath = "/home/user/.config/MaSzyna/favorites.txt"; + + [Fact] + public void A_starred_scenery_stays_starred_after_a_restart() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")); + var scenery = Assert.Single(installation.Sceneries.LoadAll()); + + new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath).Toggle(scenery); + var restarted = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath); + + Assert.True(restarted.Contains(scenery)); + Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath)); + } + + [Fact] + public void Starring_again_unstars() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")); + var scenery = Assert.Single(installation.Sceneries.LoadAll()); + var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath); + + favorites.Toggle(scenery); + favorites.Toggle(scenery); + + Assert.False(favorites.Contains(scenery)); + } +} diff --git a/StarterNG.Tests/ScenarioPresentationTests.cs b/StarterNG.Tests/ScenarioPresentationTests.cs index b3f9ce5..70b3c2f 100644 --- a/StarterNG.Tests/ScenarioPresentationTests.cs +++ b/StarterNG.Tests/ScenarioPresentationTests.cs @@ -80,6 +80,36 @@ public class SceneryTreeBuilderTests return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries); } + [Fact] + public void Starred_sceneries_are_gathered_on_top_and_starred_where_they_belong() + { + var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Aaa luzem"); + string starred = SceneryTreeBuilder.Star + sceneries.Single(scenery => scenery.Path.EndsWith("s1.scn")).DisplayName; + string plain = sceneries.Single(scenery => scenery.Path.EndsWith("s0.scn")).DisplayName; + + var nodes = builder.Build(sceneries, includeArchival: true, "en", + scenery => scenery.Path.EndsWith("s1.scn"), "Ulubione"); + + var top = nodes[0]; + Assert.True(top.IsGroup); + Assert.Equal(SceneryTreeBuilder.Star + "Ulubione", top.Label); + Assert.Equal(starred, Assert.Single(top.Children).Label); + + var group = Assert.Single(nodes, node => node.Label == "Śląsk"); + Assert.Contains(group.Children, node => node.Label == starred); + Assert.Contains(group.Children, node => node.Label == plain); + } + + [Fact] + public void Without_favourites_there_is_no_favourites_group() + { + var (builder, sceneries) = Build("//$n Bytom"); + + var nodes = builder.Build(sceneries, includeArchival: true, "en", _ => false, "Ulubione"); + + Assert.DoesNotContain(nodes, node => node.Label.StartsWith(SceneryTreeBuilder.Star)); + } + [Fact] public void Scenarios_declaring_a_group_are_gathered_under_it() { diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 82fb036..77fd3f4 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -53,6 +53,7 @@ public sealed class AppServices 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); + Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath()); var assembly = typeof(AppServices).Assembly; var version = assembly.GetName().Version ?? new Version(0, 0); string? commit = UpdateCheck.CommitOf( @@ -134,6 +135,8 @@ public sealed class AppServices public UpdateCheck Updates { get; } + public FavoriteSceneries Favorites { get; } + public StartSimulation StartSimulation { get; } public LocalizationService Localization { get; } diff --git a/StarterNG/Application/FavoriteSceneries.cs b/StarterNG/Application/FavoriteSceneries.cs new file mode 100644 index 0000000..4a00831 --- /dev/null +++ b/StarterNG/Application/FavoriteSceneries.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; + +namespace StarterNG.Application; + +/// +/// The sceneries starred in the list, by file name, one per line in a file of the +/// starter's own - eu07.ini is the simulator's as much as ours. +/// +public sealed class FavoriteSceneries +{ + private readonly IFileSystem _files; + private readonly IDiagnosticsLog _log; + private readonly string _path; + private readonly HashSet _names = new(StringComparer.OrdinalIgnoreCase); + + public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path) + { + _files = files; + _log = log; + _path = path; + Load(); + } + + public bool Contains(Scenery scenery) => _names.Contains(Key(scenery)); + + public void Toggle(Scenery scenery) + { + string key = Key(scenery); + if (!_names.Remove(key)) + _names.Add(key); + Save(); + } + + private static string Key(Scenery scenery) => Path.GetFileName(scenery.Path); + + private void Load() + { + try + { + if (!_files.FileExists(_path)) + return; + + foreach (string line in _files.ReadAllText(_path).Split('\n')) + if (line.Trim() is { Length: > 0 } name) + _names.Add(name); + } + catch (Exception ex) + { + _log.Log($"reading {_path}", ex); + } + } + + private void Save() + { + try + { + if (Path.GetDirectoryName(_path) is { Length: > 0 } directory) + _files.CreateDirectory(directory); + + _files.WriteAllText(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase))); + } + catch (Exception ex) + { + _log.Log($"writing {_path}", ex); + } + } +} diff --git a/StarterNG/Infrastructure/Settings/SettingsPaths.cs b/StarterNG/Infrastructure/Settings/SettingsPaths.cs index dbf1418..0864b19 100644 --- a/StarterNG/Infrastructure/Settings/SettingsPaths.cs +++ b/StarterNG/Infrastructure/Settings/SettingsPaths.cs @@ -50,6 +50,9 @@ public sealed class SettingsPaths /// public string VehicleCacheDirectory() => Path.Combine(UserConfigDirectory(), "vehicles"); + /// The sceneries starred in the list. + public string FavoritesPath() => Path.Combine(UserConfigDirectory(), "favorites.txt"); + /// The day of the last update check and the release it found. public string UpdateCheckPath() => Path.Combine(UserConfigDirectory(), "updatecheck.txt"); diff --git a/StarterNG/Presentation/Scenarios/SceneryTree.cs b/StarterNG/Presentation/Scenarios/SceneryTree.cs index 507809a..ff3d969 100644 --- a/StarterNG/Presentation/Scenarios/SceneryTree.cs +++ b/StarterNG/Presentation/Scenarios/SceneryTree.cs @@ -20,12 +20,20 @@ public sealed class SceneryTreeBuilder _translations = translations; } + public const string Star = "\u2605 "; + + /// + /// The tree of the list: groups and loose sceneries by name, and the starred ones + /// gathered once more in a group of their own on top, starred wherever they appear. + /// public IReadOnlyList Build(IReadOnlyList sceneries, bool includeArchival, - string langCode) + string langCode, Func? isFavorite = null, + string favoritesLabel = "") { var groups = new Dictionary>(StringComparer.Ordinal); var groupLabels = new Dictionary(StringComparer.Ordinal); var topLevel = new List(); + var favorites = new List(); for (int i = 0; i < sceneries.Count; i++) { @@ -35,7 +43,11 @@ public sealed class SceneryTreeBuilder _translations.LoadFor(scenery, langCode); - var node = new SceneryTreeNode(scenery.DisplayName, i, Array.Empty()); + bool favorite = isFavorite?.Invoke(scenery) == true; + var node = new SceneryTreeNode((favorite ? Star : "") + scenery.DisplayName, i, + Array.Empty()); + if (favorite) + favorites.Add(node); if (string.IsNullOrEmpty(scenery.Group)) { @@ -55,7 +67,10 @@ public sealed class SceneryTreeBuilder foreach (var (group, members) in groups) topLevel.Add(new SceneryTreeNode(groupLabels[group], -1, Sorted(members))); - return Sorted(topLevel); + var tree = Sorted(topLevel); + if (favorites.Count > 0) + tree.Insert(0, new SceneryTreeNode(Star + favoritesLabel, -1, Sorted(favorites))); + return tree; } private static List Sorted(IEnumerable nodes) => diff --git a/StarterNG/Views/Scenarios.axaml b/StarterNG/Views/Scenarios.axaml index 8b809cd..c8580a4 100644 --- a/StarterNG/Views/Scenarios.axaml +++ b/StarterNG/Views/Scenarios.axaml @@ -29,9 +29,12 @@ IsCheckedChanged="ArchivalSwitch_OnChanged" Content="{Binding [HideArchivalSceneries], Source={x:Static starterNg:App.Loc}}" /> + SelectionChanged="SceneryList_OnSelectionChanged" + ContextRequested="SceneryList_OnContextRequested"> - + + + + sceneryList.SelectedItem is TreeViewItem { Tag: int index } && index < Sceneries.Count + ? Sceneries[index] + : null; + + // A right click picks the scenery under it, so the menu speaks of that one. + private void SceneryList_OnContextRequested(object? sender, ContextRequestedEventArgs e) + { + var hit = e.Source as Control; + while (hit is not null and not TreeViewItem) + hit = hit.Parent as Control; + + if (hit is TreeViewItem { Tag: int } item) + sceneryList.SelectedItem = item; + } + + private void SceneryMenu_OnOpening(object? sender, CancelEventArgs e) + { + var scenery = SelectedScenery; + favoriteMenuItem.IsEnabled = scenery is not null; + favoriteMenuItem.Header = App.Loc[scenery is not null && AppServices.Current.Favorites.Contains(scenery) + ? "FavoriteRemove" + : "FavoriteAdd"]; + } + + private void ToggleFavorite_OnClick(object? sender, RoutedEventArgs e) + { + if (SelectedScenery is not { } scenery) + return; + + AppServices.Current.Favorites.Toggle(scenery); + BuildSceneryTree(); + AppServices.Current.Settings.LastScenery = Path.GetFileNameWithoutExtension(scenery.Path); + RestoreLastScenery(); + } + private void RestoreLastScenery() { string last = AppServices.Current.Settings.LastScenery; diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml index f48abdd..ad37c04 100644 --- a/StarterNG/startercfg/lang/cz.xml +++ b/StarterNG/startercfg/lang/cz.xml @@ -66,6 +66,9 @@ Tato souprava nemá osádku ani cestujícího — nebude co řídit. Přesto spustit? Obnovit počasí Načíst znovu + Oblíbené + Přidat do oblíbených + Odebrat z oblíbených Otevřít složku Volitelné prvky Otevřít složku s texturami diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml index 2bd4711..d98656e 100644 --- a/StarterNG/startercfg/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -66,6 +66,9 @@ This consist has no crew and no passenger — there will be nothing to drive. Start anyway? Restore weather Reload + Favourites + Add to favourites + Remove from favourites Open folder Optional elements Open texture folder diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml index 47e1c75..44a4b10 100644 --- a/StarterNG/startercfg/lang/hu.xml +++ b/StarterNG/startercfg/lang/hu.xml @@ -66,6 +66,9 @@ Ebben a szerelvényben nincs személyzet és utas sem — nem lesz mit vezetni. Mégis elindítja? Időjárás visszaállítása Újratöltés + Kedvencek + Hozzáadás a kedvencekhez + Eltávolítás a kedvencek közül Mappa megnyitása Opcionális elemek Textúramappa megnyitása diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml index 5b179bf..4a3d61c 100644 --- a/StarterNG/startercfg/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -66,6 +66,9 @@ Skład nie ma obsady ani pasażera — nie będzie czym kierować. Uruchomić mimo to? Przywróć pogodę Przeładuj + Ulubione + Dodaj do ulubionych + Usuń z ulubionych Otwórz folder Elementy opcjonalne Otwórz folder tekstury diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml index f3edf3c..7250151 100644 --- a/StarterNG/startercfg/lang/ru.xml +++ b/StarterNG/startercfg/lang/ru.xml @@ -66,6 +66,9 @@ В этом составе нет ни бригады, ни пассажира — управлять будет нечем. Всё равно запустить? Восстановить погоду Перезагрузить + Избранное + Добавить в избранное + Удалить из избранного Открыть папку Дополнительные элементы Открыть папку с текстурами