Star favourite sceneries in the list

A right click stars or unstars the scenery under it; the starred ones are
gathered in a group on top and marked where they belong. They are kept in
favorites.txt beside the settings, not in eu07.ini.
This commit is contained in:
maj00r
2026-10-04 14:23:16 +02:00
parent be34d48033
commit e0901ae0ab
13 changed files with 223 additions and 6 deletions

View File

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

View File

@@ -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()
{

View File

@@ -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; }

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class FavoriteSceneries
{
private readonly IFileSystem _files;
private readonly IDiagnosticsLog _log;
private readonly string _path;
private readonly HashSet<string> _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);
}
}
}

View File

@@ -50,6 +50,9 @@ public sealed class SettingsPaths
/// </summary>
public string VehicleCacheDirectory() => Path.Combine(UserConfigDirectory(), "vehicles");
/// <summary>The sceneries starred in the list.</summary>
public string FavoritesPath() => Path.Combine(UserConfigDirectory(), "favorites.txt");
/// <summary>The day of the last update check and the release it found.</summary>
public string UpdateCheckPath() => Path.Combine(UserConfigDirectory(), "updatecheck.txt");

View File

@@ -20,12 +20,20 @@ public sealed class SceneryTreeBuilder
_translations = translations;
}
public const string Star = "\u2605 ";
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<SceneryTreeNode> Build(IReadOnlyList<Scenery> sceneries, bool includeArchival,
string langCode)
string langCode, Func<Scenery, bool>? isFavorite = null,
string favoritesLabel = "")
{
var groups = new Dictionary<string, List<SceneryTreeNode>>(StringComparer.Ordinal);
var groupLabels = new Dictionary<string, string>(StringComparer.Ordinal);
var topLevel = new List<SceneryTreeNode>();
var favorites = new List<SceneryTreeNode>();
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<SceneryTreeNode>());
bool favorite = isFavorite?.Invoke(scenery) == true;
var node = new SceneryTreeNode((favorite ? Star : "") + scenery.DisplayName, i,
Array.Empty<SceneryTreeNode>());
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<SceneryTreeNode> Sorted(IEnumerable<SceneryTreeNode> nodes) =>

View File

@@ -29,9 +29,12 @@
IsCheckedChanged="ArchivalSwitch_OnChanged"
Content="{Binding [HideArchivalSceneries], Source={x:Static starterNg:App.Loc}}" />
<TreeView Name="sceneryList" Classes="Compact"
SelectionChanged="SceneryList_OnSelectionChanged">
SelectionChanged="SceneryList_OnSelectionChanged"
ContextRequested="SceneryList_OnContextRequested">
<TreeView.ContextMenu>
<ContextMenu>
<ContextMenu Opening="SceneryMenu_OnOpening">
<MenuItem Name="favoriteMenuItem" Click="ToggleFavorite_OnClick" />
<Separator />
<MenuItem Click="ReloadSceneries_OnClick"
Header="{Binding [ReloadSceneries], Source={x:Static starterNg:App.Loc}}" />
<MenuItem Click="OpenSceneryDir_OnClick"

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -71,8 +72,9 @@ public partial class Scenarios : UserControl
bool includeArchival = archivalSwitch.IsChecked != true;
bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree;
var favorites = AppServices.Current.Favorites;
var nodes = new SceneryTreeBuilder(AppServices.Current.SceneryTexts)
.Build(Sceneries, includeArchival, App.Loc.CurrentLangCode);
.Build(Sceneries, includeArchival, App.Loc.CurrentLangCode, favorites.Contains, App.Loc["Favorites"]);
foreach (var node in nodes)
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
@@ -93,6 +95,42 @@ public partial class Scenarios : UserControl
return item;
}
private Scenery? SelectedScenery =>
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;

View File

@@ -66,6 +66,9 @@
<String key="StartNoStaffConfirm">Tato souprava nemá osádku ani cestujícího — nebude co řídit. Přesto spustit?</String>
<String key="RestoreWeather">Obnovit počasí</String>
<String key="ReloadSceneries">Načíst znovu</String>
<String key="Favorites">Oblíbené</String>
<String key="FavoriteAdd">Přidat do oblíbených</String>
<String key="FavoriteRemove">Odebrat z oblíbených</String>
<String key="OpenSceneryFolder">Otevřít složku</String>
<String key="OptionalIncludes">Volitelné prvky</String>
<String key="OpenTextureFolder">Otevřít složku s texturami</String>

View File

@@ -66,6 +66,9 @@
<String key="StartNoStaffConfirm">This consist has no crew and no passenger — there will be nothing to drive. Start anyway?</String>
<String key="RestoreWeather">Restore weather</String>
<String key="ReloadSceneries">Reload</String>
<String key="Favorites">Favourites</String>
<String key="FavoriteAdd">Add to favourites</String>
<String key="FavoriteRemove">Remove from favourites</String>
<String key="OpenSceneryFolder">Open folder</String>
<String key="OptionalIncludes">Optional elements</String>
<String key="OpenTextureFolder">Open texture folder</String>

View File

@@ -66,6 +66,9 @@
<String key="StartNoStaffConfirm">Ebben a szerelvényben nincs személyzet és utas sem — nem lesz mit vezetni. Mégis elindítja?</String>
<String key="RestoreWeather">Időjárás visszaállítása</String>
<String key="ReloadSceneries">Újratöltés</String>
<String key="Favorites">Kedvencek</String>
<String key="FavoriteAdd">Hozzáadás a kedvencekhez</String>
<String key="FavoriteRemove">Eltávolítás a kedvencek közül</String>
<String key="OpenSceneryFolder">Mappa megnyitása</String>
<String key="OptionalIncludes">Opcionális elemek</String>
<String key="OpenTextureFolder">Textúramappa megnyitása</String>

View File

@@ -66,6 +66,9 @@
<String key="StartNoStaffConfirm">Skład nie ma obsady ani pasażera — nie będzie czym kierować. Uruchomić mimo to?</String>
<String key="RestoreWeather">Przywróć pogodę</String>
<String key="ReloadSceneries">Przeładuj</String>
<String key="Favorites">Ulubione</String>
<String key="FavoriteAdd">Dodaj do ulubionych</String>
<String key="FavoriteRemove">Usuń z ulubionych</String>
<String key="OpenSceneryFolder">Otwórz folder</String>
<String key="OptionalIncludes">Elementy opcjonalne</String>
<String key="OpenTextureFolder">Otwórz folder tekstury</String>

View File

@@ -66,6 +66,9 @@
<String key="StartNoStaffConfirm">В этом составе нет ни бригады, ни пассажира — управлять будет нечем. Всё равно запустить?</String>
<String key="RestoreWeather">Восстановить погоду</String>
<String key="ReloadSceneries">Перезагрузить</String>
<String key="Favorites">Избранное</String>
<String key="FavoriteAdd">Добавить в избранное</String>
<String key="FavoriteRemove">Удалить из избранного</String>
<String key="OpenSceneryFolder">Открыть папку</String>
<String key="OptionalIncludes">Дополнительные элементы</String>
<String key="OpenTextureFolder">Открыть папку с текстурами</String>