Rework the depot, warehouse and vehicle base

A consist added to the warehouse gets a name there. The crew type stays through
loading a consist. The vehicle base window replaces the texture base. The depot
follows consists changed on the scenarios page, also with the warehouse tab
left open.
This commit is contained in:
maj00r
2026-11-01 16:06:16 +01:00
parent f152a020e3
commit 82429010d4
40 changed files with 3041 additions and 843 deletions

View File

@@ -0,0 +1,149 @@
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class CargoTests
{
private readonly Consist _consist;
private readonly VehicleCatalog _catalog;
private readonly VehicleInfo _info;
public CargoTests()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"), "!=b\nen57-a.mat=en57,EN57")
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "en57.fiz"),
"Load: LoadAccepted=Passengers,Pantstate MaxLoad=50")
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "textures.txt"), "!=b\ntram-a.mat=tram,TRAM")
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "tram.fiz"),
"Load: LoadAccepted=pantstate,Passengers MaxLoad=52")
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "textures.txt"), "!=b\ns1-a.mat=s1,S1")
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "s1.fiz"), "Load: LoadAccepted=Coal,Sand"));
installation.Vehicles.Load(installation.Library.Vehicles);
_catalog = installation.Library.Vehicles;
_info = new VehicleInfo(_catalog, installation.Physics);
_consist = new Consist(_catalog, _info);
}
private (Dynamic Car, List<ConsistItem> Consist) Wagon(string skin, string? type = null, int count = 0)
{
var car = _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
car.LoadType = type;
car.LoadCount = count;
return (car, new List<ConsistItem> { new() { Cars = new List<Dynamic> { car } } });
}
private Cargo Picking(int index) => new(_info, new Pick(index));
private sealed class Pick : Random
{
private readonly int _index;
public Pick(int index) => _index = index;
public override int Next(int maxValue) => _index;
public override int Next(int minValue, int maxValue) => maxValue - 1;
}
[Fact]
public void A_random_type_takes_no_more_than_it_holds()
{
var (car, consist) = Wagon("en57-a", "Passengers", 50);
Picking(1).RandomTypes(consist);
Assert.Equal("Pantstate", car.LoadType);
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
Assert.Contains(" 3 Pantstate ", car.ToTrainsetNode());
}
[Fact]
public void A_random_type_leaves_a_count_it_can_hold()
{
var (car, consist) = Wagon("en57-a", "Pantstate", 2);
Picking(0).RandomTypes(consist);
Assert.Equal("Passengers", car.LoadType);
Assert.Equal(2, car.LoadCount);
}
[Fact]
public void A_random_type_with_no_limit_declared_keeps_the_count()
{
// MaxLoad unset: nothing to hold it to.
var (car, consist) = Wagon("s1-a", "Coal", 40);
Picking(1).RandomTypes(consist);
Assert.Equal("Sand", car.LoadType);
Assert.Equal(40, car.LoadCount);
}
[Fact]
public void Filling_an_empty_car_goes_by_the_type_it_is_given()
{
// Its first accepted type is pantographs: the 52 of the .fiz is not theirs.
var (car, _) = Wagon("tram-a");
Picking(0).FillToMax(car);
Assert.Equal("pantstate", car.LoadType);
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
}
[Fact]
public void A_random_amount_for_an_empty_car_goes_by_the_type_it_is_given()
{
var (car, consist) = Wagon("tram-a");
Picking(0).RandomAmounts(consist);
Assert.Equal("pantstate", car.LoadType);
Assert.InRange(car.LoadCount, 1, Dynamic.PantStateMax);
}
[Fact]
public void A_count_passed_on_keeps_where_the_next_car_declares_no_limit()
{
// MaxLoad unset: nothing to hold it to, as when taken from the car before.
var (_, consist) = Wagon("s1-a", "Coal", 40);
var (next, following) = Wagon("s1-a");
consist.AddRange(following);
Picking(0).CopyToFollowing(consist, consist[0]);
Assert.Equal("Coal", next.LoadType);
Assert.Equal(40, next.LoadCount);
}
[Fact]
public void A_count_passed_on_is_held_to_what_the_next_car_declares()
{
var (_, consist) = Wagon("tram-a", "Passengers", 52);
var (next, following) = Wagon("en57-a");
consist.AddRange(following);
Picking(0).CopyToFollowing(consist, consist[0]);
Assert.Equal("Passengers", next.LoadType);
Assert.Equal(50, next.LoadCount);
}
[Fact]
public void A_car_with_nothing_declared_stays_untouched()
{
var (car, _) = Wagon("en57-a");
car.MaxLoad = 0;
Picking(0).FillToMax(car);
Assert.Null(car.LoadType);
Assert.Equal(0, car.LoadCount);
}
}

View File

@@ -9,6 +9,7 @@ public class ConsistCrewTests
{
private readonly Consist _consist;
private readonly VehicleCatalog _catalog;
private readonly VehicleInfo _info;
public ConsistCrewTests()
{
@@ -20,7 +21,129 @@ public class ConsistCrewTests
installation.Vehicles.Load(installation.Library.Vehicles);
_catalog = installation.Library.Vehicles;
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
_info = new VehicleInfo(_catalog, installation.Physics);
_consist = new Consist(_catalog, _info);
}
private List<Dynamic> Cars(params string[] skins) =>
skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList();
[Theory]
[InlineData(eDriverType.Reardriver)]
[InlineData(eDriverType.Passenger)]
public void A_loaded_consist_keeps_the_kind_of_crew(eDriverType crew)
{
var before = Cars("ep07-001", "b111-01");
before[0].DriverType = crew;
var loaded = Cars("ep07-002", "b111-01");
loaded[0].DriverType = eDriverType.Headdriver;
var cars = Consist.LoadedOver(before, loaded, _catalog, _info);
Assert.Equal(crew, cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
}
[Fact]
public void A_consist_with_nobody_in_it_stays_so_after_loading()
{
var loaded = Cars("ep07-002");
loaded[0].DriverType = eDriverType.Headdriver;
var cars = Consist.LoadedOver(Cars("ep07-001"), loaded, _catalog, _info);
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
}
[Fact]
public void A_driver_does_not_go_into_loaded_wagons()
{
var before = Cars("ep07-001");
before[0].DriverType = eDriverType.Headdriver;
var cars = Consist.LoadedOver(before, Cars("b111-01"), _catalog, _info);
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
}
[Fact]
public void Loaded_over_an_empty_consist_the_vehicles_keep_their_own_crew()
{
var loaded = Cars("ep07-002");
loaded[0].DriverType = eDriverType.Reardriver;
Assert.Equal(eDriverType.Reardriver,
Consist.LoadedOver(new List<Dynamic>(), loaded, _catalog, _info)[0].DriverType);
}
[Fact]
public void Vehicles_joining_a_consist_come_without_their_own_crew()
{
var before = Cars("ep07-001");
before[0].DriverType = eDriverType.Headdriver;
var added = Cars("ep07-002");
added[0].DriverType = eDriverType.Headdriver;
var cars = Consist.Joined(before, added);
Assert.Equal(eDriverType.Headdriver, cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
}
[Fact]
public void A_locomotive_moved_to_the_head_takes_the_driver_in_the_same_cab()
{
var first = Add("ep07-001", 0);
var second = Add("ep07-002", 1);
first.Driver = eDriverType.Reardriver;
first.Cars[0].DriverType = eDriverType.Reardriver;
_consist.Move(1, 0);
Assert.Equal(eDriverType.Reardriver, second.Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
}
[Fact]
public void Vehicles_joining_an_empty_consist_keep_their_crew()
{
var added = Cars("ep07-002");
added[0].DriverType = eDriverType.Headdriver;
Assert.Equal(eDriverType.Headdriver, Consist.Joined(new List<Dynamic>(), added)[0].DriverType);
}
[Fact]
public void Selecting_a_wagon_keeps_the_locomotive_as_the_starting_vehicle()
{
var loco = Add("ep07-001", 0);
var wagon = Add("b111-01", 1);
loco.Cars[0].Name = "loco";
wagon.Cars[0].Name = "wagon";
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "loco"));
}
[Fact]
public void A_wagon_selected_after_a_stale_pick_falls_back_to_the_driver()
{
var loco = Add("ep07-001", 0);
var wagon = Add("b111-01", 1);
loco.Cars[0].Name = "loco";
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "gone"));
}
[Fact]
public void Selecting_a_crewed_unit_makes_it_the_starting_vehicle()
{
var first = Add("ep07-001", 0);
var wagon = Add("b111-01", 1);
first.Cars[0].Name = "loco";
wagon.Cars[0].Name = "wagon";
wagon.Cars[0].DriverType = eDriverType.Passenger;
Assert.Equal("wagon", Consist.StartingVehicle(_consist, wagon, "loco"));
}
[Fact]

View File

@@ -0,0 +1,98 @@
using StarterNG.Classes;
using StarterNG.Domain;
namespace StarterNG.Tests;
public class ConsistHistoryTests
{
private static Dynamic Car(string name) => new()
{
Name = name, DataFolder = "pkp", SkinFile = name, MmdFile = name, PathName = ""
};
private static Trainset With(params string[] names) =>
new("trainset none none 0 0\nendtrainset") { Vehicles = names.Select(Car).ToList() };
private static string[] Names(IEnumerable<Dynamic> vehicles) => vehicles.Select(v => v.Name).ToArray();
[Fact]
public void A_redraw_without_a_change_leaves_nothing_to_undo()
{
var history = new ConsistHistory();
var trainset = With("a", "b");
history.Record(trainset, 0);
history.Record(trainset, 1);
Assert.False(history.CanUndo(trainset));
}
[Fact]
public void Undo_goes_back_one_change_and_redo_forward_again()
{
var history = new ConsistHistory();
var trainset = With("a");
history.Record(trainset, 0);
trainset.Vehicles.Add(Car("b"));
history.Record(trainset, 1);
var back = history.Undo(trainset, 1);
Assert.Equal(new[] { "a" }, Names(back!.Vehicles));
Assert.True(history.CanRedo(trainset));
trainset.Vehicles = back.Vehicles.ToList();
history.Record(trainset, 0, rebase: true);
var forward = history.Redo(trainset, 0);
Assert.Equal(new[] { "a", "b" }, Names(forward!.Vehicles));
Assert.Equal(1, forward.Selected);
}
[Fact]
public void An_edit_made_in_place_is_still_undone()
{
// The vehicles are shared with the editor, so a change to one of them must
// not reach the state kept for undo.
var history = new ConsistHistory();
var trainset = With("a");
history.Record(trainset, 0);
trainset.Vehicles[0].DriverType = eDriverType.Passenger;
var back = history.Undo(trainset, 0);
Assert.NotEqual(eDriverType.Passenger, back!.Vehicles[0].DriverType);
}
[Fact]
public void A_new_change_drops_what_could_be_redone()
{
var history = new ConsistHistory();
var trainset = With("a");
history.Record(trainset, 0);
trainset.Vehicles.Add(Car("b"));
history.Record(trainset, 1);
trainset.Vehicles = history.Undo(trainset, 1)!.Vehicles.ToList();
history.Record(trainset, 0, rebase: true);
trainset.Vehicles.Add(Car("c"));
history.Record(trainset, 1);
Assert.False(history.CanRedo(trainset));
}
[Fact]
public void Each_trainset_has_its_own_history()
{
var history = new ConsistHistory();
var first = With("a");
var second = With("x");
history.Record(first, 0);
first.Vehicles.Add(Car("b"));
history.Record(first, 1);
history.Record(second, 0);
Assert.True(history.CanUndo(first));
Assert.False(history.CanUndo(second));
}
}

View File

@@ -1,3 +1,4 @@
using StarterNG.Application;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
@@ -87,6 +88,25 @@ public class ConsistOrientationTests
Assert.All(consist, card => Assert.Equal(Consist.IsTurned(card.Cars[0]), card.Flipped));
}
[Fact]
public void A_turned_unit_comes_back_as_it_was_from_every_load()
{
AppServices.Initialize(_installation.Paths, _installation.Files, _installation.Clock, _installation.Environment);
var consist = NewConsist(out var unit);
consist.Flip(unit);
string[] turned = Describe(consist);
var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
for (int load = 0; load < 3; load++)
{
trainset.Vehicles = consist.Flatten();
consist.LoadFrom(trainset);
Assert.True(Assert.Single(consist).Flipped);
Assert.Equal(turned, Describe(consist));
}
}
private Consist NewConsist(out ConsistItem unit)
{
var consist = new Consist(_catalog, new VehicleInfo(_catalog, _installation.Physics));

View File

@@ -21,6 +21,21 @@ public class ConsistTailTests
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
}
[Fact]
public void A_unit_can_join_the_one_it_is_held_to_from_either_side()
{
var first = Append("b111-01");
var second = Append("b111-02");
Assert.False(_consist.CanJoin(first, 0));
Assert.False(_consist.CanJoin(second, 1));
first.Cars[0].Coupling.Set(Coupling.WorkshopLock, true);
Assert.True(_consist.CanJoin(first, 0));
Assert.True(_consist.CanJoin(second, 1));
}
[Fact]
public void The_vehicle_count_counts_cars_not_units()
{

View File

@@ -74,6 +74,70 @@ public class PhysicsAndAssetsTests
Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07"));
}
[Theory]
[InlineData("pkp/21xm_v1/")]
[InlineData("pkp/21xm_v1")]
[InlineData("PKP\\21XM_V1")]
[InlineData("dynamic/pkp/21xm_v1/")]
public void Two_fiz_files_of_one_name_are_told_apart_by_their_folder(string folder)
{
// As in the game: dynamic/**/21xm_v1/214m.fiz and sa134_v1/214m.fiz.
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "21xm_v1", "214m"), "Param: M=51000\nDimensions: L=24.38")
.WithFile(Fiz("pkp", "sa134_v1", "214m"), "Param: M=48000\nDimensions: L=24.5"));
var physics = installation.Physics.For(folder, "214m.e3d")!;
Assert.Equal(51000, physics.Mass);
Assert.Equal(24.38, physics.Length);
Assert.Equal(48000, installation.Physics.For("pkp/sa134_v1/", "214m")!.Mass);
// Asked again, the first answer has not stuck to the name.
Assert.Equal(51000, installation.Physics.For(folder, "214m")!.Mass);
}
[Fact]
public void A_folder_without_the_fiz_falls_back_to_the_name_alone()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "ep07", "ep07"), "Param: M=80000"));
Assert.Equal(80000, installation.Physics.For("pkp/other/", "ep07")!.Mass);
Assert.Equal(80000, installation.Physics.For(null, "ep07")!.Mass);
}
[Fact]
public void The_fallback_to_a_name_two_folders_share_is_the_same_on_every_machine()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("zz", "b", "214m"), "Param: M=2")
.WithFile(Fiz("aa", "a", "214m"), "Param: M=1"));
Assert.Equal(1, installation.Physics.For("elsewhere/", "214m")!.Mass);
}
[Fact]
public void A_dumb_variant_in_the_vehicles_own_folder_goes_before_a_namesake_elsewhere()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000")
.WithFile(Fiz("pkp", "other", "ep07"), "Param: M=2000"));
Assert.Equal(1000, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
}
[Fact]
public void Two_folders_sharing_a_name_count_once_among_what_is_indexed()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("pkp", "a", "214m"), "Param: M=1")
.WithFile(Fiz("pkp", "b", "214m"), "Param: M=2")
.WithFile(Fiz("pkp", "c", "ep07"), "Param: M=3"));
installation.Physics.Preload();
Assert.Equal(2, installation.Physics.IndexedCount);
}
[Fact]
public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing()
{

View File

@@ -0,0 +1,47 @@
using StarterNG.Domain.Vehicles;
namespace StarterNG.Tests;
public class RevisionSpanTests
{
[Theory]
[InlineData("14.01.2005", "2005-01-14", "2005-01-14")]
[InlineData("06.12.2016", "2016-12-06", "2016-12-06")]
[InlineData("xx.06.2008", "2008-06-01", "2008-06-30")]
[InlineData("?.06.2008", "2008-06-01", "2008-06-30")]
[InlineData("xx.xx.2011", "2011-01-01", "2011-12-31")]
[InlineData("?.?.2011", "2011-01-01", "2011-12-31")]
[InlineData("02.2016", "2016-02-01", "2016-02-29")]
public void A_credit_line_date_is_as_exact_as_it_says(string text, string from, string to)
{
var span = RevisionSpan.Parse(text);
Assert.Equal(DateTime.Parse(from), span?.From);
Assert.Equal(DateTime.Parse(to), span?.To);
}
[Theory]
[InlineData("2016", "2016-01-01", "2016-12-31")]
[InlineData("2016-12", "2016-12-01", "2016-12-31")]
[InlineData("2016-12-06", "2016-12-06", "2016-12-06")]
[InlineData(" 2016-12- ", "2016-12-01", "2016-12-31")]
public void What_is_typed_to_filter_by_reads_the_same_way(string text, string from, string to)
{
var span = RevisionSpan.Parse(text);
Assert.Equal(DateTime.Parse(from), span?.From);
Assert.Equal(DateTime.Parse(to), span?.To);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("?")]
[InlineData("xx.xx.xxxx")]
[InlineData("31.02.2015")]
[InlineData("01.13.2015")]
[InlineData("VRZ-99_Kolowag_Septemwri")]
[InlineData("201")]
public void Anything_else_is_no_date(string? text) =>
Assert.Null(RevisionSpan.Parse(text));
}

View File

@@ -0,0 +1,197 @@
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class TexRandomizerTests
{
private readonly Consist _consist;
private readonly VehicleCatalog _catalog;
public TexRandomizerTests()
{
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
"!=e\nep07-001.mat=ep07,EP07\nep07-002.mat=ep07,EP07")
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"),
"!=z\n^2\nen57-a.mat=en57a,EN57\nen57-b.mat=en57b,EN57\nen57-c.mat=en57a,EN57\nen57-d.mat=en57b,EN57")
.WithFile(TestInstallation.At("dynamic", "pkp", "b111", "textures.txt"),
"!=b\nb111-01.mat=b111,B111\nb111-02.mat=b111,B111"));
installation.Vehicles.Load(installation.Library.Vehicles);
_catalog = installation.Library.Vehicles;
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
}
private Dynamic Car(string skin) => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
private ConsistItem Put(params Dynamic[] cars)
{
var item = new ConsistItem
{
Cars = cars.ToList(),
Grouped = cars.Length > 1,
Driver = Consist.UnitDriver(cars)
};
_consist.Insert(_consist.Count, item);
return item;
}
/// <summary>The randomizer with the next pick in line always the other livery of two.</summary>
private int Randomize() => new TexRandomizer(_catalog, new SecondPick()).Apply(_consist, t => _consist.MakeDynamic(t, null));
private sealed class SecondPick : Random
{
public override int Next(int maxValue) => 1;
}
[Theory]
[InlineData(eDriverType.Headdriver)]
[InlineData(eDriverType.Reardriver)]
[InlineData(eDriverType.Passenger)]
public void The_crew_stays_on_the_vehicle_that_takes_over(eDriverType crew)
{
var loco = Car("ep07-001");
loco.DriverType = crew;
Put(loco);
Assert.Equal(1, Randomize());
var car = Assert.Single(_consist[0].Cars);
Assert.Equal("ep07-002", car.SkinFile);
Assert.Equal(crew, car.DriverType);
Assert.Equal(crew, _consist[0].Driver);
}
[Fact]
public void A_driver_kept_is_what_the_export_writes()
{
var loco = Car("ep07-001");
loco.DriverType = eDriverType.Headdriver;
Put(loco);
Randomize();
Assert.Contains(" headdriver ", _consist[0].Cars[0].ToTrainsetNode());
}
[Fact]
public void An_empty_locomotive_stays_empty()
{
// Consist.Replace would staff it; a driver of its own is not this one's to give.
Put(Car("ep07-001"));
Randomize();
Assert.Equal(eDriverType.Nobody, _consist[0].Cars[0].DriverType);
Assert.Equal(eDriverType.Nobody, _consist[0].Driver);
}
[Fact]
public void A_vehicle_keeps_facing_the_way_it_did()
{
var loco = Car("ep07-001");
loco.Offset = -1f;
var item = Put(loco);
item.Flipped = true;
Randomize();
Assert.True(_consist[0].Flipped);
Assert.True(Consist.IsTurned(_consist[0].Cars[0]));
}
[Fact]
public void A_vehicle_keeps_its_load()
{
var wagon = Car("b111-01");
wagon.LoadType = "Passengers";
wagon.LoadCount = 40;
wagon.MaxLoad = 60;
Put(wagon);
Assert.Equal(1, Randomize());
var car = _consist[0].Cars[0];
Assert.Equal("b111-02", car.SkinFile);
Assert.Equal("Passengers", car.LoadType);
Assert.Equal(40, car.LoadCount);
Assert.Equal(60, car.MaxLoad);
}
[Fact]
public void A_unit_of_the_same_length_keeps_all_of_it_car_by_car()
{
var first = Car("en57-a");
var second = Car("en57-b");
second.DriverType = eDriverType.Reardriver;
first.LoadType = "Passengers";
first.LoadCount = 10;
second.LoadType = "Passengers";
second.LoadCount = 20;
first.Offset = -1f;
second.Offset = -1f;
var item = Put(first, second);
item.Flipped = true;
Assert.Equal(1, Randomize());
var unit = _consist[0];
Assert.Equal(new[] { "en57-c", "en57-d" }, unit.Cars.Select(c => c.SkinFile));
Assert.True(unit.Grouped);
Assert.True(unit.Flipped);
Assert.Equal(new[] { eDriverType.Nobody, eDriverType.Reardriver }, unit.Cars.Select(c => c.DriverType));
Assert.Equal(eDriverType.Reardriver, unit.Driver);
Assert.Equal(new[] { 10, 20 }, unit.Cars.Select(c => c.LoadCount));
Assert.All(unit.Cars, c => Assert.True(Consist.IsTurned(c)));
}
[Theory]
[InlineData(eDriverType.Headdriver, 0)]
[InlineData(eDriverType.Reardriver, 1)]
public void A_crew_moving_into_a_longer_unit_sits_in_the_car_with_its_cab(eDriverType crew, int car)
{
var loco = Car("en57-c");
loco.DriverType = crew;
Put(loco);
Assert.Equal(1, new TexRandomizer(_catalog, new FirstPick()).Apply(_consist, t => _consist.MakeDynamic(t, null)));
var unit = _consist[0];
Assert.Equal(2, unit.Cars.Count);
Assert.Equal(crew, unit.Cars[car].DriverType);
Assert.Equal(eDriverType.Nobody, unit.Cars[1 - car].DriverType);
Assert.Equal(crew, unit.Driver);
}
private sealed class FirstPick : Random
{
public override int Next(int maxValue) => 0;
}
[Fact]
public void A_selected_vehicle_stays_selected_as_the_one_that_took_over()
{
var item = Put(Car("ep07-001"));
_consist.Selected = item;
Randomize();
Assert.Same(_consist[0], _consist.Selected);
}
[Fact]
public void A_vehicle_not_selected_does_not_take_the_selection()
{
// ep07-001's turn picks ep07-002; ep07-002's turn picks itself and passes.
Put(Car("ep07-001"));
var selected = Put(Car("ep07-002"));
_consist.Selected = selected;
Assert.Equal(1, Randomize());
Assert.Same(selected, _consist.Selected);
}
}

View File

@@ -0,0 +1,77 @@
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Views;
namespace StarterNG.Tests;
public class TrainsetDisplayTests
{
private static Trainset Consist(string driver = "headdriver", string? description = null) =>
new(string.Concat(
description is null ? "" : $"//$o {description}\n",
$"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 {driver} 3 0 enddynamic\nendtrainset"));
[Theory]
[InlineData("-ai", true)]
[InlineData(" - ai", true)]
[InlineData("a - b", false)]
[InlineData("rozkladowy", false)]
public void A_dash_opening_the_description_says_the_consist_is_ai(string description, bool ai) =>
Assert.Equal(ai, TrainsetDisplay.IsAi(Consist(description: description)));
[Fact]
public void No_description_is_no_ai() =>
Assert.False(TrainsetDisplay.IsAi(Consist()));
[Fact]
public void An_ai_consist_is_listed_only_when_they_are_shown()
{
var ai = Consist(description: "-ai");
Assert.False(TrainsetDisplay.ListedIn(ai, showAi: false, drivableOnly: false, current: null));
Assert.True(TrainsetDisplay.ListedIn(ai, showAi: true, drivableOnly: false, current: null));
}
[Fact]
public void Drivable_only_leaves_out_a_consist_nobody_sits_in()
{
var empty = Consist(driver: "nobody");
Assert.False(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: true, current: null));
Assert.True(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: false, current: null));
Assert.True(TrainsetDisplay.ListedIn(Consist(), showAi: true, drivableOnly: true, current: null));
}
[Fact]
public void Drivable_only_keeps_the_one_picked_whatever_its_crew()
{
var picked = Consist(driver: "nobody");
Assert.True(TrainsetDisplay.ListedIn(picked, showAi: true, drivableOnly: true, current: picked));
}
[Fact]
public void Being_picked_does_not_show_an_ai_consist_the_filter_hides()
{
var picked = Consist(description: "-ai");
Assert.False(TrainsetDisplay.ListedIn(picked, showAi: false, drivableOnly: true, current: picked));
}
[Theory]
[InlineData(eDriverType.Headdriver, 0)]
[InlineData(eDriverType.Reardriver, 1)]
[InlineData(eDriverType.Passenger, 2)]
[InlineData(eDriverType.Nobody, 3)]
public void A_crew_keeps_its_place_in_the_list(eDriverType crew, int place)
{
Assert.Equal(place, CrewChoices.IndexOf(crew));
Assert.Equal(crew, CrewChoices.At(place));
}
[Theory]
[InlineData(-1)]
[InlineData(4)]
public void A_list_with_nothing_selected_means_nobody(int place) =>
Assert.Equal(eDriverType.Nobody, CrewChoices.At(place));
}

View File

@@ -0,0 +1,162 @@
using StarterNG.Domain.Vehicles;
using StarterNG.Tests.Fakes;
using StarterNG.Views;
namespace StarterNG.Tests;
public class VehicleBaseQueryTests
{
private static VehicleBaseRow Row(string skin, string? operatorName = null, string? revision = null,
string? author = null, string? depot = null, string? photo = null,
string directory = "dynamic/pkp/", string? model = "model")
{
return new VehicleBaseRow(new VehicleTexture
{
Directory = directory,
Skinfile = skin,
Model = model,
Meta = new VehicleMeta
{
Operator = operatorName,
RevisionDate = revision,
TextureAuthor = author,
Depot = depot,
PhotoAuthor = photo
}
});
}
private static IReadOnlyList<VehicleTexture> Catalogued(params string[] textures)
{
var files = new InMemoryFileSystem();
foreach (string text in textures)
files.WithFile(TestInstallation.At("dynamic", "pkp", text[..text.IndexOf('=')], "textures.txt"),
text[(text.IndexOf('=') + 1)..]);
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
return installation.Library.Vehicles.Textures;
}
private static string[] Skins(IEnumerable<VehicleBaseRow> rows) => rows.Select(r => r.Skin).ToArray();
private static string[] Selected(VehicleBaseFilters filters, params VehicleBaseRow[] rows) =>
Skins(VehicleBaseQuery.Select(rows, filters, r => r.Skin, descending: false));
[Fact]
public void A_question_mark_in_the_credit_line_is_what_its_author_did_not_know()
{
var row = Row("a", operatorName: "?", revision: "?", author: "Jan");
Assert.Equal("", row.Operator);
Assert.Equal("", row.Revision);
Assert.Null(row.Date);
Assert.Equal("", row.DateKey);
Assert.Equal("Jan", row.Author);
}
[Fact]
public void The_date_key_sorts_as_the_dates_do() =>
Assert.Equal("20081201", Row("a", revision: "xx.12.2008").DateKey);
[Fact]
public void Each_filter_looks_in_its_own_field_ignoring_case_and_spaces_around()
{
var a = Row("alfa", operatorName: "PKP", author: "Jan", depot: "Lublin", photo: "Ewa", directory: "dynamic/one/");
var b = Row("beta", operatorName: "CD", author: "Olaf", depot: "Praha", photo: "Karel", directory: "dynamic/two/");
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Name = " ALF " }, a, b));
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Operator = "cd" }, a, b));
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Author = "jan" }, a, b));
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Station = "prah" }, a, b));
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Photo = "EW" }, a, b));
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Model = "TWO/" }, a, b));
Assert.Empty(Selected(new VehicleBaseFilters { Name = "alf", Operator = "cd" }, a, b));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_filter_with_nothing_typed_lets_everything_through(string? typed) =>
Assert.Equal(new[] { "a", "b" }, Selected(new VehicleBaseFilters { Name = typed }, Row("a"), Row("b")));
[Theory]
[InlineData("2016", null, true)]
[InlineData("2016-12", null, true)]
[InlineData("2017", null, false)]
[InlineData(null, "2016", true)]
[InlineData(null, "2015", false)]
[InlineData("2010", "2020", true)]
public void A_revision_counts_while_its_span_overlaps_what_is_asked(string? from, string? to, bool shown)
{
var row = Row("a", revision: "xx.xx.2016");
var found = Selected(new VehicleBaseFilters { RevisionFrom = from, RevisionTo = to }, row);
Assert.Equal(shown, found.Length == 1);
}
[Fact]
public void A_month_only_date_overlaps_at_both_of_its_ends()
{
var row = Row("a", revision: "xx.06.2008");
Assert.Single(Selected(new VehicleBaseFilters { RevisionFrom = "30.06.2008" }, row));
Assert.Single(Selected(new VehicleBaseFilters { RevisionTo = "01.06.2008" }, row));
Assert.Empty(Selected(new VehicleBaseFilters { RevisionFrom = "01.07.2008" }, row));
Assert.Empty(Selected(new VehicleBaseFilters { RevisionTo = "31.05.2008" }, row));
}
[Fact]
public void A_revision_asked_for_leaves_out_what_has_no_date_and_what_cannot_be_read_asks_nothing()
{
var dated = Row("dated", revision: "2016");
var undated = Row("undated");
Assert.Equal(new[] { "dated" }, Selected(new VehicleBaseFilters { RevisionFrom = "2000" }, dated, undated));
Assert.Equal(new[] { "dated", "undated" },
Selected(new VehicleBaseFilters { RevisionFrom = "soon" }, dated, undated));
}
[Theory]
[InlineData(false, new[] { "b", "c", "a", "d" })]
[InlineData(true, new[] { "c", "b", "a", "d" })]
public void Blanks_stay_at_the_bottom_whichever_way_a_column_is_sorted_and_the_name_settles_a_tie(
bool descending, string[] order)
{
var rows = new[] { Row("d"), Row("c", operatorName: "Z"), Row("a"), Row("b", operatorName: "M") };
Assert.Equal(order, Skins(VehicleBaseQuery.Select(rows, new VehicleBaseFilters(), r => r.Operator, descending)));
}
[Fact]
public void The_class_list_counts_a_class_under_one_name_whatever_its_case_in_order_of_the_names()
{
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111\nb111-02.mat=b222,b111",
"ep07=!=e\nep07-01.mat=ep07,AP07");
var counts = VehicleBaseQuery.ClassCounts(textures.Select(t => new VehicleBaseRow(t)));
Assert.Equal(new[] { ("AP07", 1), ("B111", 2) },
counts.Select(c => (c.Name.ToUpperInvariant(), c.Count)));
}
[Fact]
public void A_row_with_no_class_is_not_counted() =>
Assert.Empty(VehicleBaseQuery.ClassCounts(new[] { Row("a") }));
[Fact]
public void The_class_picked_has_to_match_whole_while_the_typed_one_only_has_to_appear()
{
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111");
var row = new VehicleBaseRow(textures[0]);
Assert.NotEqual("", row.Class);
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class.ToUpperInvariant() },
r => r.Skin, descending: false));
Assert.Empty(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class[..1] + "?" },
r => r.Skin, descending: false));
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { Class = row.Class[..1] },
r => r.Skin, descending: false));
}
}

View File

@@ -0,0 +1,139 @@
using StarterNG.Application;
using StarterNG.Classes;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
// PresetStore keeps the disk of the first test that reaches it, a fake one as every
// composition root in the tests is built on fakes. So each test has a name to itself.
public class WarehouseEditTests
{
private const string Text =
"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\nendtrainset\n";
private static readonly string Entry = ConsistText.Serialize(new Trainset(Text));
private readonly WarehouseEdit _edit = new();
public WarehouseEditTests()
{
var installation = new TestInstallation(new InMemoryFileSystem());
AppServices.Initialize(installation.Paths, installation.Files, installation.Clock, installation.Environment);
}
private static TrainsetPreset Preset(string name, string? entry = null) => new() { Name = name, Entry = entry ?? Entry };
private static TrainsetPreset? Stored(string name) => PresetStore.All().FirstOrDefault(p => p.Name == name);
[Fact]
public void Opening_says_what_is_edited_and_closing_that_nothing_is()
{
var trainset = _edit.Open(Preset("edit-open"));
Assert.Same(trainset, _edit.Trainset);
Assert.Equal("edit-open", _edit.Name);
_edit.Close();
Assert.Null(_edit.Trainset);
Assert.Null(_edit.Name);
}
[Fact]
public void A_consist_opened_again_is_the_same_trainset_so_its_undo_history_outlives_a_switch()
{
var first = _edit.Open(Preset("edit-switch"));
_edit.Open(Preset("edit-switch-other"));
Assert.Same(first, _edit.Open(Preset("edit-switch")));
}
[Fact]
public void An_entry_written_over_from_elsewhere_is_read_afresh()
{
var first = _edit.Open(Preset("edit-over"));
var second = _edit.Open(Preset("edit-over", ConsistText.Serialize(new Trainset(Text.Replace(" v1 ", " v9 ")))));
Assert.NotSame(first, second);
Assert.Equal("v9", second.Vehicles.Single().Name);
}
[Fact]
public void The_name_of_a_consist_goes_with_any_letter_case()
{
var first = _edit.Open(Preset("edit-case"));
Assert.Same(first, _edit.Open(Preset("EDIT-CASE")));
}
[Fact]
public void Nothing_is_written_while_the_consist_stands_as_it_was_read()
{
PresetStore.Save("edit-unchanged", Entry);
_edit.Open(Stored("edit-unchanged")!);
PresetStore.Delete("edit-unchanged");
_edit.Save();
Assert.Null(Stored("edit-unchanged"));
}
[Fact]
public void A_change_is_written_once()
{
PresetStore.Save("edit-changed", Entry);
var trainset = _edit.Open(Stored("edit-changed")!);
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
_edit.Save();
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-changed")!.Entry);
PresetStore.Delete("edit-changed");
_edit.Save();
Assert.Null(Stored("edit-changed"));
}
[Fact]
public void Nothing_is_written_with_nothing_open()
{
var trainset = _edit.Open(Preset("edit-closed"));
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
_edit.Close();
_edit.Save();
Assert.Null(Stored("edit-closed"));
}
[Fact]
public void A_rename_keeps_the_trainset_and_saves_under_the_new_name()
{
var trainset = _edit.Open(Preset("edit-old"));
_edit.Renamed("edit-old", "edit-new");
Assert.Equal("edit-new", _edit.Name);
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
_edit.Save();
Assert.Null(Stored("edit-old"));
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-new")!.Entry);
Assert.Same(trainset, _edit.Open(Preset("edit-new", Stored("edit-new")!.Entry)));
}
[Fact]
public void Renaming_another_consist_leaves_the_one_open_alone()
{
var other = _edit.Open(Preset("edit-other"));
var open = _edit.Open(Preset("edit-mine"));
_edit.Renamed("edit-other", "edit-other-new");
Assert.Same(open, _edit.Trainset);
Assert.Equal("edit-mine", _edit.Name);
Assert.Same(other, _edit.Open(Preset("edit-other-new")));
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using StarterNG.Classes;
namespace StarterNG.Application;
/// <summary>
/// The warehouse consist the strip edits while the warehouse tab is open, under
/// its name there, and the entry as last written - every change saves itself.
/// </summary>
public sealed class WarehouseEdit
{
/// <summary>One trainset per warehouse consist, so its undo history outlives a switch.</summary>
private readonly Dictionary<string, Trainset> _trainsets = new(StringComparer.OrdinalIgnoreCase);
private string? _saved;
public Trainset? Trainset { get; private set; }
public string? Name { get; private set; }
public Trainset Open(TrainsetPreset preset)
{
// Written over from elsewhere - "add to warehouse as" the same name - the
// entry is read afresh; the history of what it was no longer applies.
if (!_trainsets.TryGetValue(preset.Name, out var trainset) ||
ConsistText.Serialize(trainset) != preset.Entry)
_trainsets[preset.Name] = trainset = new Trainset(preset.Entry);
Trainset = trainset;
Name = preset.Name;
_saved = preset.Entry;
return trainset;
}
public void Close()
{
Trainset = null;
Name = null;
}
public void Renamed(string old, string fresh)
{
if (_trainsets.Remove(old, out var trainset))
_trainsets[fresh] = trainset;
if (string.Equals(Name, old, StringComparison.OrdinalIgnoreCase))
Name = fresh;
}
public void Save()
{
if (Trainset is null || Name is null)
return;
string entry = ConsistText.Serialize(Trainset);
if (entry == _saved)
return;
PresetStore.Save(Name, entry);
_saved = entry;
}
}

View File

@@ -61,14 +61,18 @@ public sealed class Cargo
public void FillToMax(Dynamic car)
{
int max = MaxFor(car);
string? type = TypeToLoad(car);
if (string.IsNullOrEmpty(type)) return;
int max = MaxFor(car, type);
if (max <= 0) return;
if (string.IsNullOrEmpty(car.LoadType))
car.LoadType = AcceptedTypes(car).FirstOrDefault();
if (string.IsNullOrEmpty(car.LoadType)) return;
car.LoadType = type;
car.LoadCount = max;
}
// The type first: what it may hold depends on it, pantographs above all.
private string? TypeToLoad(Dynamic car) =>
string.IsNullOrEmpty(car.LoadType) ? AcceptedTypes(car).FirstOrDefault() : car.LoadType;
public void CopyFromPrevious(IReadOnlyList<ConsistItem> consist, ConsistItem item)
{
int idx = IndexOf(consist, item);
@@ -103,7 +107,7 @@ public sealed class Cargo
if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase))
continue;
c.LoadType = type;
c.LoadCount = Math.Min(lead.LoadCount, MaxFor(c));
c.LoadCount = Math.Min(lead.LoadCount, LimitFor(c));
}
}
@@ -113,7 +117,9 @@ public sealed class Cargo
{
var types = AcceptedTypes(c);
if (types.Count == 0) continue;
c.LoadType = types[_rng.Next(types.Count)];
string type = types[_rng.Next(types.Count)];
c.LoadType = type;
c.LoadCount = Math.Min(c.LoadCount, LimitFor(c, type));
}
}
@@ -127,11 +133,11 @@ public sealed class Cargo
{
foreach (var c in consist.SelectMany(u => u.Cars))
{
int max = MaxFor(c);
string? type = TypeToLoad(c);
if (string.IsNullOrEmpty(type)) continue;
int max = MaxFor(c, type);
if (max <= 0) continue;
if (string.IsNullOrEmpty(c.LoadType))
c.LoadType = AcceptedTypes(c).FirstOrDefault();
if (string.IsNullOrEmpty(c.LoadType)) continue;
c.LoadType = type;
int min = Math.Max(1, max / 10);
c.LoadCount = _rng.Next(min, max + 1);
}

View File

@@ -52,12 +52,21 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
public void Raise() => Changed?.Invoke();
public void LoadFrom(Trainset trainset)
/// <param name="select">The unit to select, by position; the first one otherwise.</param>
public void LoadFrom(Trainset trainset, int select = 0)
{
EditingTrainset = trainset;
GroupIntoUnits(trainset.Vehicles);
Selected = _items.Count == 0 ? null : _items[Math.Clamp(select, 0, _items.Count - 1)];
SyncStartingVehicle();
Raise();
}
private void GroupIntoUnits(IEnumerable<Dynamic> vehicles)
{
_items.Clear();
var cars = trainset.Vehicles.Select(v =>
var cars = vehicles.Select(v =>
{
var c = v.Clone();
c.MiniName = _db.MiniFor(c.DataFolder, c.SkinFile, c.MmdFile) ?? c.MiniName;
@@ -74,13 +83,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
while (j < cars.Count - 1 && HoldsNext(cars[j]))
locked.Add(cars[++j]);
_items.Add(new ConsistItem
{
Cars = locked,
Grouped = true,
Flipped = locked[0].Offset < 0,
Driver = UnitDriver(locked)
});
_items.Add(Unit(locked));
i = j + 1;
continue;
}
@@ -106,13 +109,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
if (group.Count >= 2)
{
_items.Add(new ConsistItem
{
Cars = group,
Grouped = true,
Flipped = group[0].Offset < 0,
Driver = UnitDriver(group)
});
_items.Add(Unit(group));
i = j;
continue;
}
@@ -128,13 +125,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
if (group.Count >= 2)
{
_items.Add(new ConsistItem
{
Cars = group,
Grouped = true,
Flipped = group[0].Offset < 0,
Driver = UnitDriver(group)
});
_items.Add(Unit(group));
i = j;
continue;
}
@@ -149,9 +140,34 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
});
i++;
}
}
Selected = _items.FirstOrDefault();
SyncStartingVehicle();
/// <summary>
/// A unit from cars in the order they run. A turned unit keeps its own order
/// reversed, which <see cref="RunningOrder"/> undoes - kept as it runs, its cars
/// would swap ends with every load.
/// </summary>
private static ConsistItem Unit(List<Dynamic> running)
{
bool flipped = running[0].Offset < 0;
if (flipped)
running.Reverse();
return new ConsistItem
{
Cars = running,
Grouped = true,
Flipped = flipped,
Driver = UnitDriver(running)
};
}
/// <summary>Nothing to edit: the strip goes empty until a consist is loaded again.</summary>
public void Unload()
{
EditingTrainset = null;
_items.Clear();
Selected = null;
SelectedCar = null;
Raise();
}
@@ -161,16 +177,49 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
/// <summary>
/// The vehicle the simulator puts the player into follows the selection - but
/// only onto a unit someone sits in. Selecting a wagon to edit it, or adding
/// one, which selects it, keeps the pick made before; otherwise Start would
/// read "no crew" and the simulator would open in an empty wagon.
/// </summary>
public void SyncStartingVehicle()
{
if (Selected is not { Cars.Count: > 0 } item)
// A consist from the warehouse, say, is edited here but not the one started.
var state = AppServices.Current.State;
if (EditingTrainset is null || !ReferenceEquals(EditingTrainset, state.CurrentTrainset))
return;
var driven = item.Cars.FirstOrDefault(c =>
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
if (StartingVehicle(_items, Selected, state.StartingVehicleName) is { } pick)
state.StartingVehicleName = pick;
}
/// <summary>
/// The selected unit's driver, or its passenger; with no one in it, the pick
/// made before while it still can be started from, else the first crew there is.
/// </summary>
public static string? StartingVehicle(IEnumerable<ConsistItem> items, ConsistItem? selected, string? current)
{
var cars = items.SelectMany(i => i.Cars).ToList();
string? pick =
selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
?? selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Passenger)?.Name;
if (pick != null)
return pick;
if (cars.Any(c => IsNamed(c, current) && Crewed(c)))
return current;
return cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
?? cars.FirstOrDefault(Crewed)?.Name;
}
private static bool Crewed(Dynamic car) =>
car.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
private static bool IsNamed(Dynamic car, string? name) =>
!string.IsNullOrEmpty(name) && string.Equals(car.Name, name, StringComparison.OrdinalIgnoreCase);
public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery)
{
string skin = Base(texture.Skinfile);
@@ -340,8 +389,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
/// <summary>
/// A locomotive that comes to the head of the train takes over the driver of
/// the one it put behind, which is left unstaffed: the train is driven from its
/// front, from cab A.
/// the one it put behind, in the same cab, and that one is left unstaffed.
/// </summary>
private void HandOverLead(ConsistItem previousHead)
{
@@ -350,11 +398,55 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver))
return;
var crew = previousHead.Driver;
previousHead.Driver = eDriverType.Nobody;
foreach (var car in previousHead.Cars)
car.DriverType = eDriverType.Nobody;
Crew(head, eDriverType.Headdriver);
Crew(head, crew);
}
/// <summary>
/// Vehicles loaded in place of a consist keep its kind of crew: in the unit the
/// loaded ones have crew in, else in the first powered one. A driver goes only
/// into something powered, and with nobody in the old consist nobody is in the new.
/// </summary>
public static List<Dynamic> LoadedOver(IReadOnlyList<Dynamic> before, List<Dynamic> loaded,
VehicleCatalog db, VehicleInfo info)
{
if (before.Count == 0 || loaded.Count == 0)
return loaded;
var consist = new Consist(db, info);
consist.GroupIntoUnits(loaded);
var crew = UnitDriver(before);
if (crew == eDriverType.Nobody)
{
foreach (var item in consist._items)
Crew(item, eDriverType.Nobody);
return consist.Flatten();
}
var seat = consist._items.FirstOrDefault(item => item.Driver != eDriverType.Nobody)
?? consist._items.FirstOrDefault(consist.IsPowered);
if (seat is not null && (crew == eDriverType.Passenger || consist.IsPowered(seat)))
Crew(seat, crew);
return consist.Flatten();
}
/// <summary>Vehicles joining a consist that has its crew come without their own.</summary>
public static List<Dynamic> Joined(IEnumerable<Dynamic> before, IEnumerable<Dynamic> added)
{
var joined = before.ToList();
bool crewed = joined.Count > 0;
foreach (var car in added)
{
if (crewed)
car.DriverType = eDriverType.Nobody;
joined.Add(car);
}
return joined;
}
public void Remove(ConsistItem item)
@@ -673,6 +765,11 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
public static bool HoldsTail(ConsistItem item) =>
item.Cars.Count > 0 && HoldsNext(TailCar(item));
/// <summary>Whether the unit at that place can join a neighbour: one it holds or may form a unit with, or one holding it.</summary>
public bool CanJoin(ConsistItem item, int index) =>
(index + 1 < Count && (HoldsTail(item) || CanFormUnit(item, this[index + 1]))) ||
(index > 0 && HoldsTail(this[index - 1]));
public static bool HoldsNext(Dynamic car) =>
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using StarterNG.Classes;
namespace StarterNG.Domain;
/// <summary>
/// Undo and redo for the consist editor, one history per trainset. It is fed the
/// vehicles as written back after every redraw; a state equal to the last one -
/// a redraw for a new selection - changes nothing, so feeding it is cheap to do
/// everywhere instead of remembering which of the many edits went through it.
/// </summary>
public sealed class ConsistHistory
{
private const int Depth = 100;
public sealed record State(IReadOnlyList<Dynamic> Vehicles, string Signature, int Selected);
private sealed class Track
{
public State? Last;
public readonly List<State> Undo = new();
public readonly Stack<State> Redo = new();
}
private readonly ConditionalWeakTable<Trainset, Track> _tracks = new();
/// <summary>
/// Notes what the trainset holds now. With <paramref name="rebase"/> it only
/// becomes the state undo returns to - for the redraw after an undo itself.
/// </summary>
public void Record(Trainset trainset, int selected, bool rebase = false)
{
var track = _tracks.GetOrCreateValue(trainset);
string signature = Dynamic.ToTrainsetNodes(trainset.Vehicles);
if (track.Last is { } last && last.Signature == signature)
{
track.Last = last with { Selected = selected };
return;
}
var now = new State(trainset.Vehicles.Select(v => v.Clone()).ToList(), signature, selected);
if (track.Last is { } previous && !rebase)
{
// Undone, the selection goes back to where the change was made.
track.Undo.Add(previous with { Selected = selected });
if (track.Undo.Count > Depth)
track.Undo.RemoveAt(0);
track.Redo.Clear();
}
track.Last = now;
}
public bool CanUndo(Trainset trainset) =>
_tracks.TryGetValue(trainset, out var track) && track.Undo.Count > 0;
public bool CanRedo(Trainset trainset) =>
_tracks.TryGetValue(trainset, out var track) && track.Redo.Count > 0;
/// <summary>The state before the last change, as fresh copies to put into the trainset.</summary>
public State? Undo(Trainset trainset, int selected)
{
// An edit that never asked for a redraw is still in the trainset; noting it
// first makes it the change that gets undone instead of being lost.
Record(trainset, selected);
var track = _tracks.GetOrCreateValue(trainset);
if (track.Undo.Count == 0 || track.Last is not { } last)
return null;
track.Redo.Push(last);
track.Last = track.Undo[^1];
track.Undo.RemoveAt(track.Undo.Count - 1);
return Copy(track.Last);
}
public State? Redo(Trainset trainset, int selected)
{
Record(trainset, selected);
var track = _tracks.GetOrCreateValue(trainset);
if (track.Redo.Count == 0 || track.Last is not { } last)
return null;
track.Undo.Add(last);
track.Last = track.Redo.Pop();
return Copy(track.Last);
}
private static State Copy(State state) =>
state with { Vehicles = state.Vehicles.Select(v => v.Clone()).ToList() };
}

View File

@@ -99,18 +99,49 @@ public sealed class TexRandomizer
var set = _db.ResolveSet(pick);
var unit = set ?? new List<VehicleTexture> { pick };
consist[i] = new ConsistItem
var replacement = new ConsistItem
{
Cars = unit.Select(makeCar).ToList(),
Grouped = unit.Count > 1,
Driver = item.Driver,
Flipped = item.Flipped
};
CarryOver(item, replacement);
consist[i] = replacement;
if (ReferenceEquals(consist.Selected, item))
consist.Selected = replacement;
changed++;
}
return changed;
}
// Only the livery changes, but a new car has no crew, faces forward and is empty.
// The cars are what the export writes, so they are what is taken from; not through
// Consist.Replace, which would hand a driver to a locomotive left without one.
private static void CarryOver(ConsistItem from, ConsistItem to)
{
for (int k = 0; k < to.Cars.Count; k++)
{
var before = from.Cars[Math.Min(k, from.Cars.Count - 1)];
var car = to.Cars[k];
car.Offset = before.Offset;
car.LoadType = before.LoadType;
car.LoadCount = before.LoadCount;
car.MaxLoad = before.MaxLoad;
}
var crew = Consist.UnitDriver(from.Cars);
if (to.Cars.Count == from.Cars.Count)
{
for (int k = 0; k < to.Cars.Count; k++)
to.Cars[k].DriverType = from.Cars[k].DriverType;
}
else if (crew != eDriverType.Nobody)
{
Consist.CrewCar(to, crew).DriverType = crew;
}
to.Driver = crew;
}
private string? ResolveFilterMini(ConsistItem item, int index, Consist consist, VehicleTexture? current)
{
if (current != null)

View File

@@ -17,6 +17,27 @@ public static class TrainsetDisplay
trainset.Vehicles.Count == 0 ||
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
/// <summary>
/// Whether "drivable only" lists the consist. The one picked and one with changes
/// not saved stay whatever their crew: emptying a consist must not take away the
/// row it is filled again or saved from.
/// </summary>
public static bool ListedAsDrivable(Trainset trainset, Trainset? current) =>
UData.StaffedTrain(trainset) || ReferenceEquals(trainset, current) || SceneryTrainsetText.IsModified(trainset);
/// <summary>A consist the simulator drives itself: its description starts with a dash.</summary>
public static bool IsAi(Trainset trainset) =>
!string.IsNullOrEmpty(trainset.Description) &&
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
/// <summary>Whether a list of the scenery's consists shows it, under the two filters those lists have.</summary>
public static bool ListedIn(Trainset trainset, bool showAi, bool drivableOnly, Trainset? current) =>
(showAi || !IsAi(trainset)) && (!drivableOnly || ListedAsDrivable(trainset, current));
/// <summary>The consist's row in those lists: what it is made of, or the track of one with nothing on it.</summary>
public static string ListLabel(Trainset trainset) =>
CompositionText(trainset) ?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
public static string? CompositionText(Trainset trainset)
{
if (IsEmpty(trainset))

View File

@@ -106,13 +106,7 @@ public static class UDepot
d.SkinFile = Dynamic.StripSkinExtension(t[p++]);
d.MmdFile = t[p++];
d.Offset = float.Parse(t[p++], inv);
d.DriverType = t[p++].ToLowerInvariant() switch
{
"headdriver" => eDriverType.Headdriver,
"reardriver" => eDriverType.Reardriver,
"passenger" => eDriverType.Passenger,
_ => eDriverType.Nobody
};
d.DriverType = Dynamic.ParseDriver(t[p++]);
d.Coupling = Coupling.Parse(t[p++]);
var trailing = new List<string>();

View File

@@ -0,0 +1,50 @@
using System;
using System.Globalization;
namespace StarterNG.Domain.Vehicles;
/// <summary>
/// A revision date from a livery's credit line, as far as it is known: 14.01.2005,
/// xx.06.2008, xx.xx.2011. A span, so a month or a year is not taken for its first day.
/// </summary>
public readonly record struct RevisionSpan(DateTime From, DateTime To)
{
/// <summary>Also reads what is typed to filter by: 2016, 2016-12, 2016-12-06, 12.2016.</summary>
public static RevisionSpan? Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
string trimmed = text.Trim();
string[] parts = trimmed.Split('.', '-');
bool yearFirst = trimmed.Contains('-');
return parts.Length switch
{
1 => Of(parts[0], null, null),
2 => yearFirst ? Of(parts[0], parts[1], null) : Of(parts[1], parts[0], null),
3 => yearFirst ? Of(parts[0], parts[1], parts[2]) : Of(parts[2], parts[1], parts[0]),
_ => null
};
}
private static RevisionSpan? Of(string year, string? month, string? day)
{
if (year.Length != 4 || !Number(year, out int y) || y is < 1800 or > 2200)
return null;
if (!Number(month, out int m))
return new RevisionSpan(new DateTime(y, 1, 1), new DateTime(y, 12, 31));
if (m is < 1 or > 12)
return null;
int days = DateTime.DaysInMonth(y, m);
if (!Number(day, out int d))
return new RevisionSpan(new DateTime(y, m, 1), new DateTime(y, m, days));
if (d < 1 || d > days)
return null;
return new RevisionSpan(new DateTime(y, m, d), new DateTime(y, m, d));
}
private static bool Number(string? text, out int value) =>
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
}

View File

@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Input;
@@ -40,6 +39,9 @@ public static class Shortcuts
public const string ClearConsist = "consist.clear";
public const string SetNumber = "vehicle.number";
public const string FocusSearch = "search";
public const string Undo = "consist.undo";
public const string Redo = "consist.redo";
public const string SaveToScenery = "consist.savescenery";
public static readonly IReadOnlyList<Shortcut> All = new[]
{
@@ -64,6 +66,12 @@ public static class Shortcuts
Key.Delete, KeyModifiers.Control),
new Shortcut(SetNumber, "Ctrl+N", "ShortcutSetNumber", ShortcutScope.Depot,
Key.N, KeyModifiers.Control),
new Shortcut(Undo, "Ctrl+Z", "ShortcutUndo", ShortcutScope.Depot,
Key.Z, KeyModifiers.Control),
new Shortcut(Redo, "Ctrl+Y", "ShortcutRedo", ShortcutScope.Depot,
Key.Y, KeyModifiers.Control),
new Shortcut(SaveToScenery, "Ctrl+S", "SaveToScenery", ShortcutScope.Depot,
Key.S, KeyModifiers.Control),
new Shortcut("consist.prevnext", "← →", "ShortcutPrevNext", ShortcutScope.Consist),
new Shortcut("consist.firstlast", "Home / End", "ShortcutFirstLast", ShortcutScope.Consist),

View File

@@ -29,8 +29,11 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
private readonly ConcurrentDictionary<string, VehiclePhysics?> _cache =
new(StringComparer.OrdinalIgnoreCase);
// Names repeat between folders: 214m.fiz is one vehicle in 21xm_v1, another in sa134_v1.
private sealed record FizIndex(Dictionary<string, string> ByFolderAndName, Dictionary<string, string> ByName);
private readonly object _indexGate = new();
private Dictionary<string, string>? _index;
private FizIndex? _index;
public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
{
@@ -41,7 +44,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
public void Preload() => Index();
public int IndexedCount => _index?.Count ?? 0;
public int IndexedCount => _index?.ByName.Count ?? 0;
public VehiclePhysics? For(string? dataFolder, string? model)
{
@@ -52,20 +55,34 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
if (name.Length == 0)
return null;
if (_cache.TryGetValue(name, out var cached))
string folder = FolderKey(dataFolder);
string key = FizKey(folder, name);
if (_cache.TryGetValue(key, out var cached))
return cached;
var physics = Read(name);
_cache[name] = physics;
var physics = Read(folder, name);
_cache[key] = physics;
return physics;
}
private VehiclePhysics? Read(string name)
private static string FizKey(string folder, string name) => $"{folder}/{name}";
/// <summary>A vehicle's folder under dynamic, however the caller spells it.</summary>
private static string FolderKey(string? folder)
{
const string dynamicPrefix = "dynamic/";
string key = (folder ?? "").Replace('\\', '/').Trim('/');
return key.StartsWith(dynamicPrefix, StringComparison.OrdinalIgnoreCase) ? key[dynamicPrefix.Length..] : key;
}
private VehiclePhysics? Read(string folder, string name)
{
var index = Index();
if (!index.TryGetValue(name, out string? path) &&
!index.TryGetValue(name + "dumb", out path))
if (!index.ByFolderAndName.TryGetValue(FizKey(folder, name), out string? path) &&
!index.ByFolderAndName.TryGetValue(FizKey(folder, name + "dumb"), out path) &&
!index.ByName.TryGetValue(name, out path) &&
!index.ByName.TryGetValue(name + "dumb", out path))
return null;
var physics = new VehiclePhysics { AllowedFlagB = UndeclaredCoupler };
@@ -88,26 +105,33 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
return physics;
}
private Dictionary<string, string> Index()
private FizIndex Index()
{
lock (_indexGate)
{
if (_index is not null)
return _index;
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var byFolder = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz"))
index[Path.GetFileNameWithoutExtension(file)] = file;
// Sorted, so which of two namesakes a name alone finds is the same on every machine.
foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz").Order(StringComparer.OrdinalIgnoreCase))
{
string name = Path.GetFileNameWithoutExtension(file);
string folder = FolderKey(Path.GetDirectoryName(Path.GetRelativePath(_paths.Dynamic, file)));
byFolder[FizKey(folder, name)] = file;
byName.TryAdd(name, file);
}
}
catch (Exception ex)
{
_log.Log($"indexing {_paths.Dynamic}", ex);
}
_index = index;
return index;
_index = new FizIndex(byFolder, byName);
return _index;
}
}

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Adapters;
@@ -53,8 +52,8 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
/// </summary>
public IEnumerable<string> Sources()
{
foreach (string maker in Sorted(_files.GetDirectories(_paths.Dynamic)))
foreach (string vehicle in Sorted(_files.GetDirectories(maker)))
foreach (string maker in Subfolders(_paths.Dynamic))
foreach (string vehicle in Subfolders(maker))
{
string path = Path.Combine(vehicle, TexturesFileName);
if (_files.FileExists(path))
@@ -62,8 +61,19 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
}
}
private static IEnumerable<string> Sorted(IEnumerable<string> directories) =>
directories.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase);
// A folder that cannot be listed costs what is in it, not all that comes after it.
private IReadOnlyList<string> Subfolders(string path)
{
try
{
return _files.GetDirectories(path).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase).ToList();
}
catch (Exception ex)
{
_log.Log($"dynamic: skipping {path}", ex);
return Array.Empty<string>();
}
}
/// <summary>Reads one textures.txt; a file that will not parse is logged and skipped.</summary>
public VehicleEntry? Read(string path)

View File

@@ -43,21 +43,10 @@ public sealed class VehicleCacheRepository : IVehicleRepository
_directory = directory;
}
/// <summary>What the last load did, for the developer tab to report.</summary>
public VehicleCacheStatus Status { get; private set; } = VehicleCacheStatus.None;
public int Load(VehicleCatalog catalog) => Load(catalog, useCopy: true);
/// <summary>Throws the copy away and reads the installation again.</summary>
public int Rebuild(VehicleCatalog catalog) => Load(catalog, useCopy: false);
private int Load(VehicleCatalog catalog, bool useCopy)
public int Load(VehicleCatalog catalog)
{
var (stamp, sources) = Stamp();
var cached = useCopy ? Cached(stamp) : null;
var entries = cached ?? Migrate(stamp);
Status = new VehicleCacheStatus(cached is not null, sources, WrittenAt(), _directory);
string stamp = Stamp();
var entries = Cached(stamp) ?? Migrate(stamp);
catalog.BeginLoad();
@@ -77,15 +66,13 @@ public sealed class VehicleCacheRepository : IVehicleRepository
/// installation can move on - a file edited, added, removed, or the whole
/// installation swapped for another one - shows up as a different text.
/// </summary>
private (string Text, int Sources) Stamp()
private string Stamp()
{
var stamp = new StringBuilder().Append("root|").Append(Path.GetFullPath(_paths.Root)).Append('\n')
.Append("parser|").Append(TexturesTxtParser.Version).Append('\n');
int sources = 0;
foreach (string path in _legacy.Sources())
{
sources++;
long ticks = 0;
long size = 0;
try
@@ -102,20 +89,7 @@ public sealed class VehicleCacheRepository : IVehicleRepository
.Append(path.Replace('\\', '/')).Append('\n');
}
return (stamp.ToString(), sources);
}
private DateTime? WrittenAt()
{
try
{
return _files.FileExists(DatabasePath) ? _files.GetLastWriteTimeUtc(DatabasePath) : null;
}
catch (Exception ex)
{
_log.Log($"{DatabaseFileName}: nie znam daty kopii", ex);
return null;
}
return stamp.ToString();
}
private List<VehicleEntry>? Cached(string stamp)
@@ -179,12 +153,3 @@ public sealed class VehicleCacheRepository : IVehicleRepository
private string SourcesPath => Path.Combine(_directory, SourcesFileName);
}
/// <summary>
/// How the catalogue was last filled: from the starter's copy or by reading the
/// installation, over how many textures.txt, and when the copy was last written.
/// </summary>
public sealed record VehicleCacheStatus(bool ServedFromCopy, int Sources, DateTime? Written, string Directory)
{
public static readonly VehicleCacheStatus None = new(false, 0, null, "");
}

View File

@@ -1,18 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using StarterNG.Application;
using StarterNG.Classes;
using StarterNG.Domain;
namespace StarterNG.Views;
public static class ConsistContextMenu
{
/// <summary>A scenery consist's own file: written for good, or read back.</summary>
public sealed record SceneryFileActions(Func<bool> IsModified, Action Save, Action Revert);
public static ContextMenu Build(
Control owner,
@@ -22,52 +28,60 @@ public static class ConsistContextMenu
Func<Dynamic?>? getVehicle = null,
Action<Dynamic>? removeVehicle = null,
Action? randomOrder = null,
Action? randomTurn = null)
Action? randomTurn = null,
Action? randomTextures = null,
SceneryFileActions? sceneryFile = null,
KeyGesture? removeAllGesture = null)
{
var menu = new ContextMenu();
menu.Opening += (_, _) =>
{
Populate(menu, owner, getTarget, applyVehicles, removeAll);
Populate(menu, owner, getTarget, applyVehicles, removeAll, removeAllGesture);
if (randomOrder != null || randomTurn != null)
// Changes not in the scenery file yet come first: the reason to open the menu.
if (sceneryFile != null)
{
var schedule = new MenuItem { Header = App.Loc["ConsistSchedule"] };
if (randomOrder != null)
{
var mo = new MenuItem { Header = App.Loc["RandomizeOrder"] };
mo.Click += (_, _) => randomOrder();
schedule.Items.Add(mo);
}
if (randomTurn != null)
{
var mt = new MenuItem { Header = App.Loc["RandomizeOrientation"] };
mt.Click += (_, _) => randomTurn();
schedule.Items.Add(mt);
}
menu.Items.Add(schedule);
bool modified = sceneryFile.IsModified();
menu.Items.Insert(0, MenuItems.Item(App.Loc["SaveToScenery"], sceneryFile.Save, modified,
new KeyGesture(Key.S, KeyModifiers.Control)));
menu.Items.Insert(1, MenuItems.Item(App.Loc["RevertFromScenery"], sceneryFile.Revert, modified));
menu.Items.Insert(2, new Separator());
}
if (removeVehicle == null || getVehicle?.Invoke() is not { } v) return;
// Opened on a vehicle, the menu starts with what concerns that vehicle.
if (removeVehicle != null && getVehicle?.Invoke() is { } v)
{
menu.Items.Insert(0, MenuItems.Item(App.Loc["RemoveVehicle"], () => removeVehicle(v)));
menu.Items.Insert(1, new Separator());
}
menu.Items.Add(new Separator());
var mi = new MenuItem { Header = App.Loc["RemoveVehicle"] };
mi.Click += (_, _) => removeVehicle(v);
menu.Items.Add(mi);
// Straight in the menu rather than under a submenu named like the list.
bool hasVehicles = getTarget() is { Vehicles.Count: > 0 };
if (randomOrder != null || randomTurn != null || randomTextures != null)
menu.Items.Add(new Separator());
AddAction(menu, "RandomizeOrder", randomOrder, hasVehicles);
AddAction(menu, "RandomizeOrientation", randomTurn, hasVehicles);
AddAction(menu, "RandomizeTextures", randomTextures, hasVehicles, ellipsis: true);
};
return menu;
}
private static void AddAction(ContextMenu menu, string key, Action? run, bool enabled, bool ellipsis = false)
{
if (run is not null)
menu.AddItem(App.Loc[key] + (ellipsis ? "…" : ""), run, enabled);
}
private static void Populate(ContextMenu menu, Control owner,
Func<Trainset?> getTarget, Action<List<Dynamic>> applyVehicles, Action? removeAll)
Func<Trainset?> getTarget, Action<List<Dynamic>> applyVehicles, Action? removeAll,
KeyGesture? removeAllGesture)
{
menu.Items.Clear();
var target = getTarget();
bool hasVehicles = target is { Vehicles.Count: > 0 };
var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles };
save.Click += (_, _) => ShowSaveFlyout(owner, target);
menu.Items.Add(save);
menu.AddItem(App.Loc["AddToWarehouseNamed"], () => ShowSaveFlyout(owner, target), hasVehicles);
var load = new MenuItem { Header = App.Loc["PresetLoad"] };
var presets = PresetStore.All();
@@ -78,7 +92,7 @@ public static class ConsistContextMenu
else
{
foreach (var preset in presets)
load.Items.Add(BuildPresetItem(menu, preset, applyVehicles));
load.Items.Add(BuildPresetItem(preset, target, applyVehicles));
}
menu.Items.Add(load);
@@ -92,116 +106,96 @@ public static class ConsistContextMenu
foreach (var preset in presets)
{
var entry = preset;
var mi = new MenuItem { Header = entry.Name };
mi.Click += (_, _) =>
append.AddItem(entry.Name, () =>
{
var added = ConsistText.VehiclesFrom(entry.Entry);
if (added is null || added.Count == 0) return;
var merged = target is null
? added
: new List<Dynamic>(target.Vehicles).Concat(added).ToList();
applyVehicles(merged);
};
append.Items.Add(mi);
applyVehicles(target is null ? added : Consist.Joined(target.Vehicles, added));
});
}
}
menu.Items.Add(append);
var sort = new MenuItem { Header = App.Loc["PresetSort"] };
var sortName = new MenuItem
{
Header = (PresetStore.SortMode == PresetSort.Name ? "● " : "○ ") + App.Loc["PresetSortName"]
};
sortName.Click += (_, _) => PresetStore.SortMode = PresetSort.Name;
var sortTrack = new MenuItem
{
Header = (PresetStore.SortMode == PresetSort.Track ? "● " : "○ ") + App.Loc["PresetSortTrack"]
};
sortTrack.Click += (_, _) => PresetStore.SortMode = PresetSort.Track;
sort.Items.Add(sortName);
sort.Items.Add(sortTrack);
menu.Items.Add(sort);
var import = new MenuItem { Header = App.Loc["PresetImportMagazyn"] };
import.Click += (_, _) => PresetStore.ImportMagazyn();
menu.Items.Add(import);
// Sorting and importing the warehouse are the warehouse list's business.
menu.Items.Add(new Separator());
var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles };
copy.Click += async (_, _) =>
menu.AddItem(App.Loc["ConsistCopy"], async () =>
{
if (target != null && TopLevel.GetTopLevel(owner)?.Clipboard is { } cb)
await cb.SetTextAsync(ConsistText.Serialize(target));
};
menu.Items.Add(copy);
}, hasVehicles);
var paste = new MenuItem { Header = App.Loc["ConsistPaste"] };
paste.Click += async (_, _) =>
menu.AddItem(App.Loc["ConsistPaste"], async () =>
{
if (TopLevel.GetTopLevel(owner)?.Clipboard is not { } cb)
return;
string? text = await cb.TryGetTextAsync();
var vehicles = ConsistText.VehiclesFrom(text);
if (vehicles != null)
applyVehicles(vehicles);
};
menu.Items.Add(paste);
applyVehicles(KeepCrew(target, vehicles));
});
if (removeAll != null)
{
menu.Items.Add(new Separator());
var clear = new MenuItem
{
Header = App.Loc["ConsistRemoveAll"],
InputGesture = new KeyGesture(Key.Delete, KeyModifiers.Shift),
IsEnabled = hasVehicles
};
clear.Click += (_, _) => removeAll();
menu.Items.Add(clear);
menu.AddItem(App.Loc["ConsistRemoveAll"], removeAll, hasVehicles, removeAllGesture);
}
}
private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset,
Action<List<Dynamic>> applyVehicles)
/// <summary>Whether a consist may go into the warehouse under name, written over one it has - asked only then.</summary>
public static async Task<bool> MayOverwritePresetAsync(Control owner, string? name)
{
var del = new Button
{
Content = "✕",
Padding = new Thickness(6, 0),
MinWidth = 0,
Foreground = new SolidColorBrush(Color.Parse("#D05050"))
};
del.Classes.Add("Basic");
ToolTip.SetTip(del, App.Loc["PresetDelete"]);
del.Click += (_, e) =>
{
e.Handled = true;
PresetStore.Delete(preset.Name);
menu.Close();
};
string wanted = name?.Trim() ?? string.Empty;
if (!PresetStore.All().Any(p => string.Equals(p.Name, wanted, StringComparison.OrdinalIgnoreCase)) ||
TopLevel.GetTopLevel(owner) is not Window window)
return true;
var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center };
return await MessageBox.Show(window, string.Format(App.Loc["WarehouseOverwriteConfirm"], wanted),
App.Loc["AddToWarehouseNamed"], MessageBoxButtons.YesNo);
}
var dock = new DockPanel { MinWidth = 180 };
DockPanel.SetDock(del, Dock.Right);
dock.Children.Add(del);
dock.Children.Add(label);
/// <summary>Whether a warehouse consist may go for good, which this asks first.</summary>
public static async Task<bool> MayRemovePresetAsync(Control owner, string name) =>
TopLevel.GetTopLevel(owner) is not Window window ||
await MessageBox.Show(window, string.Format(App.Loc["WarehouseRemoveConfirm"], name),
App.Loc["RemoveFromWarehouse"], MessageBoxButtons.YesNo);
var item = new MenuItem { Header = dock };
item.Click += (_, _) =>
/// <summary>Vehicles about to replace those of target, with the kind of crew target has.</summary>
public static List<Dynamic> KeepCrew(Trainset? target, List<Dynamic> vehicles)
{
var library = AppServices.Current.Library;
return target is null
? vehicles
: Consist.LoadedOver(target.Vehicles, vehicles, library.Vehicles,
new VehicleInfo(library.Vehicles, AppServices.Current.Physics));
}
private static MenuItem BuildPresetItem(TrainsetPreset preset, Trainset? target, Action<List<Dynamic>> applyVehicles)
{
return MenuItems.Item(preset.Name, () =>
{
var vehicles = ConsistText.VehiclesFrom(preset.Entry);
if (vehicles != null)
applyVehicles(vehicles);
};
return item;
applyVehicles(KeepCrew(target, vehicles));
});
}
private static void ShowSaveFlyout(Control owner, Trainset? target)
/// <summary>A word next to the control, gone on its own after a moment.</summary>
private static void ShowNote(Control owner, string text, bool atPointer)
{
var note = new Flyout { Content = new TextBlock { Text = text, Margin = new Thickness(8, 4) } };
note.ShowAt(owner, showAtPointer: atPointer);
DispatcherTimer.RunOnce(note.Hide, TimeSpan.FromSeconds(2.5));
}
/// <summary>Asks for a name and puts the consist into the warehouse; <paramref name="saved"/> gets the name it went in under.</summary>
public static void ShowSaveFlyout(Control owner, Trainset? target, bool atPointer = true, Action<string>? saved = null)
{
if (target is null || target.Vehicles.Count == 0)
{
ShowNote(owner, App.Loc["WarehouseNothingToAdd"], atPointer);
return;
}
var nameBox = new TextBox
{
@@ -224,10 +218,27 @@ public static class ConsistContextMenu
var flyout = new Flyout { Content = panel };
void Commit()
async void Commit()
{
PresetStore.Save(nameBox.Text, ConsistText.Serialize(target));
string name = nameBox.Text?.Trim() ?? "";
if (name.Length == 0)
return;
flyout.Hide();
if (!await MayOverwritePresetAsync(owner, name))
return;
try
{
PresetStore.Save(name, ConsistText.Serialize(target));
}
catch (Exception ex)
{
await Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex);
return;
}
saved?.Invoke(name);
ShowNote(owner, string.Format(App.Loc["WarehouseSaved"], name), atPointer);
}
ok.Click += (_, _) => Commit();
@@ -240,13 +251,16 @@ public static class ConsistContextMenu
}
};
flyout.ShowAt(owner, showAtPointer: true);
flyout.ShowAt(owner, showAtPointer: atPointer);
nameBox.Focus();
nameBox.SelectAll();
}
private static string DefaultName(Trainset trainset)
{
if (!string.IsNullOrWhiteSpace(trainset.Name))
return trainset.Name;
string joined = string.Join(" + ", trainset.Vehicles.Select(v => v.Name));
return joined.Length > 40 ? joined[..40] : joined;
}

View File

@@ -8,6 +8,25 @@
x:CompileBindings="True"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
x:Class="StarterNG.Views.Depot">
<UserControl.Styles>
<Style Selector="Button.ConsistTool">
<Setter Property="Width" Value="26" />
<Setter Property="Height" Value="22" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.ConsistTool:disabled">
<Setter Property="Opacity" Value="0.35" />
</Style>
<Style Selector="Button.ConsistTool materialIcons|MaterialIcon">
<Setter Property="Width" Value="16" />
<Setter Property="Height" Value="16" />
</Style>
</UserControl.Styles>
<!-- TabIndex follows the workflow - pick a consist, browse, build the train,
then the editors - not the column order the grid happens to have. -->
<Grid Name="depotGrid" Margin="0" ColumnSpacing="3" RowSpacing="3">
@@ -20,10 +39,13 @@
but shrinks with everything else towards the 666 px floor instead of
being clipped. The consist row measures itself, so a whole card fits
whether the thumbnails are the small ones or the large ones. Row 1's
cap fits the seven lines of the texture tab. -->
cap fits the seven lines of the texture tab and the coupling tab with
its buttons, and no more: what it leaves goes to the vehicle list.
Row 0's floor is what the left column needs with a usable list; below
it the list ran over the search box. Row 1's tabs scroll instead. -->
<Grid.RowDefinitions>
<RowDefinition Height="*" MinHeight="150" />
<RowDefinition Height="1.57*" MinHeight="120" MaxHeight="240" />
<RowDefinition Height="*" MinHeight="264" />
<RowDefinition Height="1.57*" MinHeight="120" MaxHeight="172" />
<RowDefinition Height="Auto" MinHeight="140" MaxHeight="230" />
</Grid.RowDefinitions>
@@ -68,9 +90,10 @@
DoubleTapped="VehicleListBox_OnDoubleTapped"
PointerPressed="VehicleListBox_OnPointerPressed" />
<!-- The thumbnail - drag it onto the consist - and Add share a line, so
the list keeps the height. -->
<Grid Grid.Row="5" ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,6,0,0">
<!-- The thumbnail - drag it onto the consist - Replace and Add share a
line, so the list keeps the height. Replace is what a double-click
on the list does, as in the old starter, put where it is seen. -->
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="6" Margin="0,6,0,0">
<Border Name="miniPreviewPanel" Height="34"
Background="{StaticResource CarbonInputBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
@@ -78,7 +101,14 @@
PointerPressed="MiniPreview_OnPointerPressed">
<Image Name="miniPreview" Stretch="Uniform" Margin="3" />
</Border>
<Button Name="addVehicleButton" Grid.Column="1" Classes="Flat Accent"
<Button Name="replaceVehicleButton" Grid.Column="1" Classes="Flat"
Cursor="Hand" IsEnabled="{Binding #addVehicleButton.IsEnabled}"
VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Click="ReplaceVehicleButton_OnClick"
ToolTip.Tip="{Binding [TipReplaceVehicle], Source={x:Static starterNg:App.Loc}}"
Content="{Binding [ReplaceVehicle], Source={x:Static starterNg:App.Loc}}" />
<Button Name="addVehicleButton" Grid.Column="2" Classes="Flat Accent"
MinWidth="80" Cursor="Hand" IsEnabled="False"
VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
@@ -94,7 +124,9 @@
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="6">
<TabControl>
<!-- The tab picks what the consist strip edits: a consist of the scenery,
or a consist kept in the warehouse. -->
<TabControl Name="consistSourceTabs" SelectionChanged="ConsistSourceTabs_OnSelectionChanged">
<TabItem Name="sceneryConsistsTab"
Header="{Binding [SceneryConsists], Source={x:Static starterNg:App.Loc}}">
<DockPanel LastChildFill="True">
@@ -117,15 +149,18 @@
KeyDown="SceneryConsistList_OnKeyDown" />
</DockPanel>
</TabItem>
<TabItem Header="{Binding [WarehouseConsists], Source={x:Static starterNg:App.Loc}}">
<TabItem Name="warehouseTab"
Header="{Binding [WarehouseConsists], Source={x:Static starterNg:App.Loc}}">
<DockPanel LastChildFill="True">
<Button Name="removeFromWarehouseButton" DockPanel.Dock="Bottom" Classes="Flat"
Margin="0,6,0,0" HorizontalAlignment="Stretch" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="RemoveFromWarehouseButton_OnClick"
Content="{Binding [RemoveFromWarehouse], Source={x:Static starterNg:App.Loc}}" />
<ListBox Name="warehouseList" Classes="Compact"
DoubleTapped="WarehouseList_OnDoubleTapped" />
<TextBlock DockPanel.Dock="Bottom" Margin="2,6,0,0" FontSize="12" Opacity="0.6"
TextWrapping="Wrap"
Text="{Binding [WarehouseEditHint], Source={x:Static starterNg:App.Loc}}" />
<ListBox Name="warehouseList" Classes="Compact" />
</DockPanel>
</TabItem>
</TabControl>
@@ -206,13 +241,72 @@
</TabControl>
</Border>
<HeaderedContentControl Grid.Row="2" Grid.ColumnSpan="3" TabIndex="3" Theme="{StaticResource GroupBox}"
<HeaderedContentControl Name="consistGroup" Grid.Row="2" Grid.ColumnSpan="3" TabIndex="3"
Theme="{StaticResource GroupBox}"
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
<!-- Mass, length and the rest ride in the title bar instead of taking
a row of their own above the cards. -->
<HeaderedContentControl.Tag>
<StackPanel Name="trainStats" Orientation="Horizontal"
VerticalAlignment="Center" />
<StackPanel Orientation="Horizontal" Spacing="16" VerticalAlignment="Center">
<!-- What acts on the whole consist, where the consist is: undo first,
then the random tools that were only in the list's menu. -->
<StackPanel Name="consistTools" Orientation="Horizontal" Spacing="2"
VerticalAlignment="Center">
<Button Name="undoButton" Classes="Basic ConsistTool" IsEnabled="False"
Click="UndoButton_OnClick"
ToolTip.Tip="{Binding [ConsistUndo], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="Undo" />
</Button>
<Button Name="redoButton" Classes="Basic ConsistTool" IsEnabled="False"
Click="RedoButton_OnClick"
ToolTip.Tip="{Binding [ConsistRedo], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="Redo" />
</Button>
<Border Width="1" Margin="6,3" Background="{StaticResource CarbonBorderLight}" />
<Button Classes="Basic ConsistTool" Click="RandomOrderButton_OnClick"
ToolTip.Tip="{Binding [TipRandomizeOrder], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="ShuffleVariant" />
</Button>
<Button Classes="Basic ConsistTool" Click="RandomOrientButton_OnClick"
ToolTip.Tip="{Binding [TipRandomizeOrientation], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="SwapHorizontalVariant" />
</Button>
<Button Classes="Basic ConsistTool" Click="RandomTexturesButton_OnClick"
ToolTip.Tip="{Binding [TipRandomizeTextures], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="Palette" />
</Button>
<Border Width="1" Margin="6,3" Background="{StaticResource CarbonBorderLight}" />
<!-- A scenery consist only: saved into the scenery file, or put
back as the file has it; a warehouse consist saves itself. -->
<Button Name="saveToSceneryButton" Classes="Basic ConsistTool" IsEnabled="False"
Click="SaveToSceneryButton_OnClick"
ToolTip.Tip="{Binding [SaveToScenery], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="ContentSaveOutline" />
</Button>
<Button Name="revertSceneryButton" Classes="Basic ConsistTool" IsEnabled="False"
Click="RevertSceneryButton_OnClick"
ToolTip.Tip="{Binding [RevertFromScenery], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="FileRestoreOutline" />
</Button>
<Button Name="addToWarehouseToolButton" Classes="Basic ConsistTool"
Click="AddToWarehouseButton_OnClick"
ToolTip.Tip="{Binding [AddToWarehouse], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="ArchiveArrowDownOutline" />
</Button>
<Button Classes="Basic ConsistTool" Click="ClearConsistButton_OnClick"
ToolTip.Tip="{Binding [ConsistRemoveAll], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="DeleteSweepOutline" />
</Button>
</StackPanel>
<!-- Read only: the scenery's author turns a trainset round, and
the starter keeps it that way. -->
<TextBlock Name="reversedBadge" IsVisible="False" VerticalAlignment="Center"
FontSize="12" Foreground="#E0A030"
Text="{Binding [ConsistReversed], Source={x:Static starterNg:App.Loc}}"
ToolTip.Tip="{Binding [ConsistReversedTip], Source={x:Static starterNg:App.Loc}}" />
<StackPanel Name="trainStats" Orientation="Horizontal"
VerticalAlignment="Center" />
</StackPanel>
</HeaderedContentControl.Tag>
<DockPanel LastChildFill="True">
<Panel Name="consistDropTarget">

View File

@@ -1,19 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using StarterNG.Classes;
@@ -54,6 +47,16 @@ public partial class Depot : UserControl
private Scenery? _listScenery;
private readonly ConsistHistory _history = new();
/// <summary>Set while an undo or redo puts a state back, which is not a change of its own.</summary>
private bool _restoring;
private readonly WarehouseEdit _warehouseEdit = new();
private bool EditingWarehouse =>
_warehouseEdit.Trainset is not null && ReferenceEquals(_consist.EditingTrainset, _warehouseEdit.Trainset);
public Depot()
{
_info = new VehicleInfo(_db, AppServices.Current.Physics);
@@ -67,13 +70,19 @@ public partial class Depot : UserControl
return;
_warehouse = new Warehouse(warehouseList, removeFromWarehouseButton, _hand,
() => _consist.EditingTrainset, ApplyConsist);
() => AppServices.Current.State.CurrentTrainset, ApplyFromWarehouse);
_warehouse.SelectionChanged += () =>
{
if (WarehouseTabOpen)
EditWarehouseSelection();
};
_warehouse.Renamed += _warehouseEdit.Renamed;
_browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox,
hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis,
() => TextureBaseButton_OnClick(null, null!))
hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, OpenVehicleBase)
{
AddMany = (texture, count) => AddTexture(texture, count)
AddMany = (texture, count) => AddTexture(texture, count),
Replace = ReplaceSelected
};
_drag = new ConsistDragging(consistStack, consistScroll, consistOverlay,
@@ -83,7 +92,13 @@ public partial class Depot : UserControl
_cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand,
RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag)
{
AddCopies = AddCopiesAfter
AddCopies = AddCopiesAfter,
BrowserSelection = () => _browser.Selected,
ReplaceWith = (item, texture) =>
{
_consist.Selected = item;
ReplaceSelected(texture);
}
};
// Hidden still scrolls - it only drops the bar itself.
@@ -92,8 +107,16 @@ public partial class Depot : UserControl
_details = new VehicleDetails(
generalPanel, consistTexturePanel, couplerPanel, couplerActions,
brakesPanel, loadsPanel, damagePanel, removeVehicleButton,
_db, _info, _consist, _cargo, _hand,
RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo);
_info, _consist, _cargo, _hand,
RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo)
{
// A warehouse consist is kept in its file; a scenery one is the list itself.
Edited = () =>
{
if (EditingWarehouse)
_warehouseEdit.Save();
}
};
_browser.TextureSelected = _details.ShowTexture;
@@ -112,22 +135,101 @@ public partial class Depot : UserControl
AttachedToVisualTree += (_, _) => SyncFromSelection();
PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && IsVisible)
if (e.Property != IsVisibleProperty)
return;
if (!IsVisible)
{
SyncFromSelection();
_warehouse.Refresh();
RememberConsistOnLeaving();
return;
}
if (ConsistMovedSinceLeaving())
ShowSceneryTab();
SyncFromSelection();
_warehouse.Refresh();
};
}
private Trainset? _leftTrainset;
private List<Dynamic> _leftVehicles = new();
private void RememberConsistOnLeaving()
{
_leftTrainset = AppServices.Current.State.CurrentTrainset;
_leftVehicles = _leftTrainset?.Vehicles.ToList() ?? new List<Dynamic>();
}
/// <summary>Another consist was picked or this one changed on another page: the warehouse tab left open would hide it.</summary>
private bool ConsistMovedSinceLeaving() =>
AppServices.Current.State.CurrentTrainset is { } trainset &&
(!ReferenceEquals(trainset, _leftTrainset) || !trainset.Vehicles.SequenceEqual(_leftVehicles));
private void AddToWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
_ = _warehouse.SaveCurrentAsync();
_warehouse.SaveCurrent((Control)sender!);
private void RemoveFromWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
_ = _warehouse.RemoveSelectedAsync();
private void WarehouseList_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) =>
_warehouse.ApplySelected();
private bool WarehouseTabOpen => ReferenceEquals(consistSourceTabs.SelectedItem, warehouseTab);
private void ConsistSourceTabs_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
// The lists inside the tabs raise the same event, and it bubbles up here.
if (!ReferenceEquals(e.Source, consistSourceTabs) || Design.IsDesignMode)
return;
if (WarehouseTabOpen)
{
// A consist saved from a menu elsewhere went in while this list was not shown.
_warehouse.Refresh();
EditWarehouseSelection();
}
else
EditSceneryConsist();
}
/// <summary>The strip takes the warehouse consist selected, or goes empty without one.</summary>
private void EditWarehouseSelection()
{
if (_warehouse.SelectedPreset() is not { } preset)
{
_warehouseEdit.Close();
_consist.Unload();
return;
}
var trainset = _warehouseEdit.Open(preset);
if (ReferenceEquals(_consist.EditingTrainset, trainset))
RebuildConsist();
else
_consist.LoadFrom(trainset);
}
/// <summary>Back on the scenery tab, the strip edits the consist that will be started.</summary>
private void EditSceneryConsist()
{
_warehouseEdit.Close();
if (AppServices.Current.State.CurrentTrainset is { } trainset)
_consist.LoadFrom(trainset);
else
_consist.Unload();
}
private void ShowSceneryTab()
{
if (WarehouseTabOpen)
consistSourceTabs.SelectedItem = sceneryConsistsTab;
}
/// <summary>From the warehouse's menu: shown where it went, on the scenery tab.</summary>
private void ApplyFromWarehouse(Trainset target, List<Dynamic> vehicles)
{
ShowSceneryTab();
ApplyConsist(target, vehicles);
}
private bool IsSceneryTrainset(Trainset trainset) =>
_listScenery?.Trainsets.Contains(trainset) == true;
private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) =>
_browser.CategoryCombo_OnSelectionChanged(sender, e);
@@ -169,6 +271,13 @@ public partial class Depot : UserControl
private Dynamic NewVehicle(VehicleTexture texture) =>
_consist.MakeDynamic(texture, _listScenery ?? AppServices.Current.State.CurrentScenery);
/// <summary>The scenery files changed under the page in view; out of view it catches up on its way back.</summary>
public void FollowSceneryFiles()
{
if (IsVisible)
SyncFromSelection();
}
private void SyncFromSelection()
{
@@ -177,25 +286,48 @@ public partial class Depot : UserControl
PopulateSceneryConsists();
var trainset = AppServices.Current.State.CurrentTrainset;
if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset))
_consist.LoadFrom(trainset);
else if (_consist.EditingTrainset != null)
if (WarehouseTabOpen)
RebuildConsist();
else if (trainset is null)
{
// Another scenery, with no consist picked: the old one's is not edited on.
if (_consist.EditingTrainset != null)
_consist.Unload();
}
else if (!ReferenceEquals(trainset, _consist.EditingTrainset))
_consist.LoadFrom(trainset);
else
{
ReloadIfEditedElsewhere();
RebuildConsist();
}
}
/// <summary>
/// The strip holds the vehicles written back last. A list that no longer holds
/// them was changed on the scenarios page or by a tool, and the next write-back
/// would put the strip's old vehicles over that change.
/// </summary>
private void ReloadIfEditedElsewhere()
{
if (_consist.EditingTrainset is { } trainset && !_consist.Flatten().SequenceEqual(trainset.Vehicles))
_consist.LoadFrom(trainset, _consist.Selected is { } selected ? Math.Max(_consist.IndexOf(selected), 0) : 0);
}
public void RebuildAfterLanguageChange()
{
_browser.PopulateCategoryCombo();
PopulateSceneryConsists();
ReloadIfEditedElsewhere();
RebuildConsist();
_details.Refresh();
}
/// <summary>Redraws the consist cards, e.g. after the thumbnail size changed.</summary>
/// <summary>Redraws what depends on the thumbnail size: the consist and the class list.</summary>
public void RefreshConsistView()
{
_browser.InitClassComboTemplates();
ReloadIfEditedElsewhere();
RebuildConsist();
}
@@ -217,6 +349,9 @@ public partial class Depot : UserControl
}
emptyHint.IsVisible = _consist.Count == 0;
emptyHint.Text = _consist.EditingTrainset is not null
? App.Loc["ConsistEmpty"]
: App.Loc[WarehouseTabOpen ? "WarehousePickHint" : "ConsistPickHint"];
// A selection the consist moved on its own - after a removal, say - is only
// highlighted; activating it runs what a click would have run.
@@ -226,8 +361,10 @@ public partial class Depot : UserControl
// The stats are read off the trainset, so it is written back first.
WriteBackToScenery();
RecordHistory();
_details.Refresh();
UpdateTrainStats();
UpdateConsistChrome();
// The cards are thrown away and rebuilt on every change, so a keyboard-driven
// edit has to be handed its card back or focus escapes the strip.
@@ -240,7 +377,154 @@ public partial class Depot : UserControl
return;
_consist.EditingTrainset.Vehicles = _consist.Flatten();
if (EditingWarehouse)
{
_warehouseEdit.Save();
return;
}
RefreshConsistListLabels();
// The start button reads the crew off the trainset, which was not written
// back yet when the selection named the vehicle to start in - without this
// it went on saying "no crew" until a vehicle was clicked on the scenarios tab.
if (ReferenceEquals(_consist.EditingTrainset, AppServices.Current.State.CurrentTrainset))
AppServices.Current.State.NotifyChanged();
}
/// <summary>
/// The title says which consist the strip edits; the scenery file's tools and
/// "add to warehouse" are for a scenery consist only.
/// </summary>
private void UpdateConsistChrome()
{
bool warehouse = EditingWarehouse;
consistGroup.Header = warehouse
? string.Format(App.Loc["ConsistFromWarehouse"], _warehouseEdit.Name)
: App.Loc["Consist"];
var trainset = _consist.EditingTrainset;
bool scenery = trainset is not null && !warehouse && IsSceneryTrainset(trainset);
bool modified = scenery && SceneryTrainsetText.IsModified(trainset!);
bool sceneryTab = !WarehouseTabOpen;
saveToSceneryButton.IsVisible = sceneryTab;
revertSceneryButton.IsVisible = sceneryTab;
addToWarehouseToolButton.IsVisible = sceneryTab;
saveToSceneryButton.IsEnabled = modified;
revertSceneryButton.IsEnabled = modified;
reversedBadge.IsVisible = trainset?.Reversed == true;
}
private void SaveToSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
_ = SaveToSceneryAsync(_consist.EditingTrainset);
private void RevertSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
RevertFromScenery(_consist.EditingTrainset);
public void KeySaveToScenery()
{
if (!EditingWarehouse)
_ = SaveToSceneryAsync(_consist.EditingTrainset);
}
/// <summary>
/// Writes the consist into the scenery file for good. Asked first only while
/// the file has no backup yet - the first time it is about to change.
/// </summary>
private async Task SaveToSceneryAsync(Trainset? trainset)
{
if (trainset is null || _listScenery is not { } scenery || !IsSceneryTrainset(trainset) ||
!SceneryTrainsetText.IsModified(trainset) || TopLevel.GetTopLevel(this) is not Window owner)
return;
// A consist from an included file is saved there - shared, maybe, by other scenarios.
string path = SaveSceneryTrainset.FileOf(scenery, trainset);
string file = trainset.SourceFile ?? Path.GetFileName(scenery.Path);
if (!AppServices.Current.Files.FileExists(path + SaveSceneryTrainset.BackupSuffix) &&
!await MessageBox.Show(owner,
string.Format(App.Loc["SaveToSceneryConfirm"], file, file + SaveSceneryTrainset.BackupSuffix),
App.Loc["SaveToScenery"], MessageBoxButtons.YesNo))
return;
var (outcome, detail) = AppServices.Current.SaveTrainset.Execute(scenery, trainset);
switch (outcome)
{
case SceneryTrainsetSaveOutcome.NotFound:
await StarterNG.Infrastructure.Diagnostics.ReportAsync(
string.Format(App.Loc["SaveToSceneryNotFound"], file));
break;
case SceneryTrainsetSaveOutcome.Failed:
await StarterNG.Infrastructure.Diagnostics.ReportAsync(
$"{path}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {detail}");
break;
}
RefreshConsistListLabels();
UpdateConsistChrome();
}
/// <summary>The consist as its scenery file has it - one more step to undo.</summary>
private void RevertFromScenery(Trainset? trainset)
{
if (trainset is null || !IsSceneryTrainset(trainset) || !SceneryTrainsetText.IsModified(trainset))
return;
var saved = new Trainset(trainset.RawEntry);
trainset.Vehicles = saved.Vehicles;
trainset.Velocity = saved.Velocity;
if (ReferenceEquals(_consist.EditingTrainset, trainset))
_consist.LoadFrom(trainset);
else
RefreshConsistListLabels();
}
private int SelectedIndex() =>
_consist.Selected is { } item ? _consist.IndexOf(item) : 0;
private void RecordHistory()
{
if (_consist.EditingTrainset is { } trainset)
_history.Record(trainset, SelectedIndex(), rebase: _restoring);
var editing = _consist.EditingTrainset;
undoButton.IsEnabled = editing is not null && _history.CanUndo(editing);
redoButton.IsEnabled = editing is not null && _history.CanRedo(editing);
}
public void Undo() => Restore(_history.Undo);
public void Redo() => Restore(_history.Redo);
private void Restore(Func<Trainset, int, ConsistHistory.State?> step)
{
if (_consist.EditingTrainset is not { } trainset ||
step(trainset, SelectedIndex()) is not { } state)
return;
trainset.Vehicles = state.Vehicles.ToList();
_restoring = true;
try
{
_consist.LoadFrom(trainset, state.Selected);
}
finally
{
_restoring = false;
}
}
private void UndoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Undo();
private void RedoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Redo();
private void ClearConsistButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
KeyClearConsist();
private void ReplaceVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_browser.Selected != null)
ReplaceSelected(_browser.Selected);
}
private void UpdateTrainStats()
@@ -254,15 +538,12 @@ public partial class Depot : UserControl
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
_consist.EditingTrainset,
_info.PhysicsFor,
car => _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile) is { } t ? VehicleInfo.CategoryOf(t) : null,
_info.CategoryOf,
AppServices.Current.LoadWeights.Table.WeightOf));
}
private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_consist.Selected is { } item)
_consist.Remove(item);
}
private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
KeyRemoveVehicle();
private void ConsistFilter_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
@@ -292,8 +573,8 @@ public partial class Depot : UserControl
foreach (var trainset in _listScenery.Trainsets)
{
if (IsAiTrainset(trainset) && !showAi) continue;
if (drivableOnly && !UData.StaffedTrain(trainset)) continue;
if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, AppServices.Current.State.CurrentTrainset))
continue;
var entry = new ListBoxItem
{
@@ -302,13 +583,29 @@ public partial class Depot : UserControl
};
var ts = trainset;
// The random tools work on the consist being edited, which is not
// necessarily the entry the menu was opened on.
void Edit(Action run)
{
if (!ReferenceEquals(_consist.EditingTrainset, ts))
sceneryConsistList.SelectedItem = entry;
run();
}
entry.ContextMenu = ConsistContextMenu.Build(
entry,
() => ts,
vehicles => ApplyConsist(ts, vehicles),
() => RemoveAllVehicles(ts),
randomOrder: ToolRandomOrder,
randomTurn: ToolRandomOrientation);
randomOrder: () => Edit(() => _consist.RandomizeOrder(_rng)),
randomTurn: () => Edit(() => _consist.RandomizeOrientation(_rng)),
randomTextures: () => Edit(() => _ = RunTextureRandomizer()),
removeAllGesture: new KeyGesture(Key.Delete, KeyModifiers.Control),
sceneryFile: new ConsistContextMenu.SceneryFileActions(
() => SceneryTrainsetText.IsModified(ts),
() => _ = SaveToSceneryAsync(ts),
() => RevertFromScenery(ts)));
sceneryConsistList.Items.Add(entry);
if (ReferenceEquals(trainset, AppServices.Current.State.CurrentTrainset))
@@ -330,10 +627,6 @@ public partial class Depot : UserControl
_consist.LoadFrom(trainset);
}
private static bool IsAiTrainset(Trainset trainset) =>
!string.IsNullOrEmpty(trainset.Description) &&
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
@@ -348,35 +641,52 @@ public partial class Depot : UserControl
private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
{
target.Vehicles = vehicles;
AppServices.Current.State.CurrentScenery = _listScenery;
AppServices.Current.State.CurrentTrainset = target;
_consist.LoadFrom(target);
PopulateSceneryConsists();
EditTrainset(target);
}
private void RemoveAllVehicles(Trainset target)
{
target.Vehicles = new List<Dynamic>();
EditTrainset(target);
}
/// <summary>
/// A scenery consist becomes the one started as well; a warehouse consist is
/// only edited.
/// </summary>
private void EditTrainset(Trainset target)
{
if (!IsSceneryTrainset(target))
{
_consist.LoadFrom(target);
return;
}
AppServices.Current.State.CurrentScenery = _listScenery;
AppServices.Current.State.CurrentTrainset = target;
_consist.LoadFrom(target);
PopulateSceneryConsists();
}
private static Control ConsistListContent(Trainset trainset) =>
new TextBlock
{
Text = ConsistLabel(trainset),
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap
};
private static string ConsistLabel(Trainset trainset)
/// <summary>The composition, after a dot for a consist the scenery file does not have yet.</summary>
private static Control ConsistListContent(Trainset trainset)
{
string? text = TrainsetDisplay.CompositionText(trainset);
if (text != null)
return text;
return string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
var label = Scenarios.TrainsetListContent(trainset);
if (!SceneryTrainsetText.IsModified(trainset))
return label;
var dot = new TextBlock
{
Text = "●",
Margin = new Thickness(0, 0, 6, 0),
Foreground = new SolidColorBrush(Color.Parse("#E0A030"))
};
ToolTip.SetTip(dot, App.Loc["ConsistUnsaved"]);
var row = new DockPanel();
DockPanel.SetDock(dot, Dock.Left);
row.Children.Add(dot);
row.Children.Add(label);
return row;
}
private void RefreshConsistListLabels()
@@ -388,22 +698,6 @@ public partial class Depot : UserControl
}
}
public async Task RandomizeCurrentTrainsetAsync()
{
if (AppServices.Current.State.CurrentTrainset is not { } trainset)
return;
if (!ReferenceEquals(trainset, _consist.EditingTrainset))
{
PopulateSceneryConsists();
_consist.LoadFrom(trainset);
}
await RunTextureRandomizer();
}
public void ToolRandomOrder() => RandomOrderButton_OnClick(null, null!);
public void ToolRandomOrientation() => RandomOrientButton_OnClick(null, null!);
private async void RandomTexturesButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
await RunTextureRandomizer();
@@ -464,20 +758,27 @@ public partial class Depot : UserControl
private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
{
if (_browser.Selected != null)
// On a row only: the list's scrollbar takes double clicks too.
if (_browser.Selected != null &&
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true) != null)
ReplaceSelected(_browser.Selected);
}
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
KeyAddVehicle();
/// <summary>Keyboard shortcut entry points. See <see cref="Infrastructure.Shortcuts"/>.</summary>
public void KeyAddVehicle()
{
if (_browser.Selected != null)
AddTexture(_browser.Selected);
}
/// <summary>Keyboard shortcut entry points. See <see cref="Infrastructure.Shortcuts"/>.</summary>
public void KeyAddVehicle() => AddVehicleButton_OnClick(null, null!);
public void KeyRemoveVehicle() => RemoveVehicleButton_OnClick(null, null!);
public void KeyRemoveVehicle()
{
if (_consist.Selected is { } item)
_consist.Remove(item);
}
public void KeyClearConsist()
{
@@ -493,36 +794,36 @@ public partial class Depot : UserControl
searchBox.SelectAll();
}
public void ToolAddAllCategory() => AddAllCategoryButton_OnClick(null, null!);
// The tools are reached from any page, and this view catches up with the
// selection only when shown - so they do that first, or they would work on the
// scenery it showed last. They are about the scenery, so never on the warehouse's.
private void PrepareForTool()
{
ShowSceneryTab();
SyncFromSelection();
}
public void ToolAddUniqueMmd() => AddUniqueMmdButton_OnClick(null, null!);
public void ToolRemoveAllTrains() => RemoveAllTrainsButton_OnClick(null, null!);
private async void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
/// <summary>What the browser lists, for a tool to add; with nothing listed it says what to pick.</summary>
private List<VehicleTexture> TexturesForTool()
{
var all = _browser.CurrentTextures();
if (all.Count == 0)
{
await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]);
return;
}
_ = StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]);
return all;
}
foreach (var t in all)
public void ToolAddAllCategory()
{
PrepareForTool();
foreach (var t in TexturesForTool())
InsertTextureAt(t, _consist.Count);
}
private async void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
public void ToolAddUniqueMmd()
{
var all = _browser.CurrentTextures();
if (all.Count == 0)
{
await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]);
return;
}
PrepareForTool();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var t in all)
foreach (var t in TexturesForTool())
{
foreach (string model in ModelsOf(t))
{
@@ -547,22 +848,34 @@ public partial class Depot : UserControl
yield return alias.Model!;
}
private async void RemoveAllTrainsButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
public async Task ToolRemoveAllTrainsAsync()
{
if (_listScenery is null || TopLevel.GetTopLevel(this) is not Window owner)
PrepareForTool();
if (TopLevel.GetTopLevel(this) is not Window owner)
return;
// Not the first one on the list: a scenery nobody picked is not emptied.
var state = AppServices.Current.State;
if (state.CurrentScenery is not { } scenery)
{
await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["NoSceneryConsists"]);
return;
}
if (!await MessageBox.Show(owner, App.Loc["RemoveAllTrainsConfirm"],
App.Loc["RemoveAllTrains"], MessageBoxButtons.YesNo))
return;
foreach (var ts in _listScenery.Trainsets)
ts.Vehicles = new List<Dynamic>();
foreach (var ts in scenery.Trainsets)
ts.RemoveVehicles();
if (_consist.EditingTrainset != null)
_consist.LoadFrom(_consist.EditingTrainset);
// As in the old starter nothing stays picked, so a start is the free camera.
state.StartingVehicleName = null;
state.CurrentTrainset = null;
_consist.Unload();
PopulateSceneryConsists();
RebuildConsist();
state.NotifyChanged();
}
private void ReplaceSelected(VehicleTexture texture)
@@ -607,7 +920,7 @@ public partial class Depot : UserControl
private void AddCopiesAfter(ConsistItem item, int count)
{
int index = _consist.IndexOf(item);
if (index < 0 || item.Cars.Count == 0 || _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is not { } texture)
if (index < 0 || item.Cars.Count == 0 || _info.TextureFor(item.Cars[0]) is not { } texture)
return;
InsertTexturesAt(texture, index + 1, count);
@@ -618,6 +931,11 @@ public partial class Depot : UserControl
/// <summary>The vehicle count times in a row from at - each staffed as added - and one redraw.</summary>
private void InsertTexturesAt(VehicleTexture texture, int at, int count)
{
// With no consist picked the vehicles would be nowhere but on the strip, and
// gone with its next load. The strip's hint says what to pick.
if (_consist.EditingTrainset is null)
return;
var set = _db.ResolveSet(texture);
var unit = set ?? new List<VehicleTexture> { texture };
@@ -641,12 +959,21 @@ public partial class Depot : UserControl
RebuildConsist();
}
private async void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
private void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
OpenVehicleBase();
private async void OpenVehicleBase()
{
if (TopLevel.GetTopLevel(this) is not Window owner) return;
var win = new TextureBaseWindow();
var win = new VehicleBaseWindow();
await win.ShowDialog(owner);
if (win.Picked != null)
_browser.SyncHideArchival();
if (win.Picked is null)
return;
if (win.PickedToAdd)
AddTexture(win.Picked);
else
ReplaceSelected(win.Picked);
}
}

View File

@@ -39,7 +39,6 @@ public sealed class ConsistDragging
private PointerPressedEventArgs? _pendingDragPress;
private Point _pendingDragOrigin;
private VehicleTexture? _pendingDragTexture;
private int _pendingDragConsistIndex = -1;
private ConsistItem? _pendingBrowserSync;
private bool _dragSessionActive;
@@ -124,14 +123,12 @@ public sealed class ConsistDragging
_pendingDragPress = e;
_pendingDragOrigin = e.GetPosition(relativeTo);
_pendingDragTexture = texture;
_pendingDragConsistIndex = -1;
}
private void ClearPendingDrag()
{
_pendingDragPress = null;
_pendingDragTexture = null;
_pendingDragConsistIndex = -1;
_pendingBrowserSync = null;
_dragSessionActive = false;
}
@@ -152,7 +149,6 @@ public sealed class ConsistDragging
var press = _pendingDragPress;
var texture = _pendingDragTexture;
int consistIndex = _pendingDragConsistIndex;
_pendingBrowserSync = null;
_pendingDragPress = null;

View File

@@ -1,3 +1,6 @@
using System;
using StarterNG.Classes;
namespace StarterNG.Views;
public static class CouplingBits
@@ -6,3 +9,17 @@ public static class CouplingBits
{ "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage",
"CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" };
}
public static class CrewChoices
{
public static readonly (eDriverType Type, string Key)[] All =
{
(eDriverType.Headdriver, "DriverHead"), (eDriverType.Reardriver, "DriverRear"),
(eDriverType.Passenger, "DriverPassenger"), (eDriverType.Nobody, "DriverNobody")
};
public static int IndexOf(eDriverType type) => Array.FindIndex(All, choice => choice.Type == type);
/// <summary>The crew in that place of the list; nobody for no place at all, as a list with nothing selected has.</summary>
public static eDriverType At(int index) => index >= 0 && index < All.Length ? All[index].Type : eDriverType.Nobody;
}

View File

@@ -0,0 +1,29 @@
using System.IO;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Views;
/// <summary>What the lists of liveries offer about a livery itself.</summary>
internal static class TextureMenuItems
{
/// <param name="owner">A control of the window, for its clipboard.</param>
public static void Add(ContextMenu menu, Control owner, VehicleTexture texture)
{
menu.AddItem(App.Loc["CopyTextureName"], async () =>
{
if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard)
await clipboard.SetTextAsync(Consist.Base(texture.Skinfile));
});
menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(FolderOf(texture)));
}
private static string FolderOf(VehicleTexture texture)
{
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
return Directory.Exists(dir) ? dir : Path.Combine(Directory.GetCurrentDirectory(), texture.Directory);
}
}

View File

@@ -80,28 +80,23 @@ public sealed class TextureRandomizerDialog
revDiff.IsCheckedChanged += (_, _) => age.IsEnabled = revDiff.IsChecked == true;
vsYear.IsCheckedChanged += (_, _) => year.IsEnabled = vsYear.IsChecked == true;
var win = new Window
{
Title = App.Loc["RandomizeTexturesTitle"],
Width = 420,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
var win = MessageBox.FixedDialog(App.Loc["RandomizeTexturesTitle"], 420);
var rulesBtn = new Button
{
Content = App.Loc["RandomizeTexturesRules"],
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
Cursor = _hand
};
rulesBtn.Classes.Add("Basic");
rulesBtn.Classes.Add("Flat");
rulesBtn.Click += async (_, _) => await new RulesWindow().ShowDialog(win);
var ok = new Button
{
Content = App.Loc["RandomizeTexturesApply"],
MinWidth = 90,
HorizontalContentAlignment = HorizontalAlignment.Center,
Cursor = _hand
};
ok.Classes.Add("Accent");
@@ -114,7 +109,13 @@ public sealed class TextureRandomizerDialog
win.Close(true);
};
var cancel = new Button { Content = App.Loc["Cancel"], MinWidth = 90, Cursor = _hand };
var cancel = new Button
{
Content = App.Loc["Cancel"],
MinWidth = 90,
HorizontalContentAlignment = HorizontalAlignment.Center,
Cursor = _hand
};
cancel.Classes.Add("Flat");
cancel.Click += (_, _) => win.Close(false);
@@ -152,13 +153,7 @@ public sealed class TextureRandomizerDialog
},
noArch,
rulesBtn,
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { ok, cancel }
}
MessageBox.ButtonRow(ok, cancel)
}
};
return win;

View File

@@ -1,21 +1,17 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Vehicles;
using StarterNG.Application;
using StarterNG.Controls;
@@ -45,7 +41,7 @@ public sealed class VehicleBrowser
_suppress = true;
hideArchivalCheck.IsChecked = hideArchival;
_suppress = false;
Rebuild();
PopulateCategoryCombo();
}
public VehicleTexture? Selected { get; private set; }
@@ -55,6 +51,9 @@ public sealed class VehicleBrowser
/// <summary>Adds the vehicle to the consist the given number of times.</summary>
public Action<VehicleTexture, int>? AddMany { get; set; }
/// <summary>Puts the vehicle in place of the one selected in the consist.</summary>
public Action<VehicleTexture>? Replace { get; set; }
private bool _suppress;
private bool _syncingCombos;
@@ -96,7 +95,7 @@ public sealed class VehicleBrowser
// In the old starter's order: traction first, then the wagons, then the rest.
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
internal static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
{
("CatElectricLoco", c => c == "e"),
("CatDieselLoco", c => c == "s"),
@@ -202,10 +201,10 @@ public sealed class VehicleBrowser
!(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived);
/// <summary>
/// The classes with their thumbnail above the name: in the open list at the size
/// the consist cards use, called again when that size changes; in the closed box
/// at a fixed modest one, so the class picked is seen, as in the old starter,
/// without taking the height the vehicle list needs.
/// The classes with their thumbnail: above the name in the open list, at the size
/// the consist cards use, called again when that size changes; beside it in the
/// closed box at a fixed modest one, so the class picked is seen, as in the old
/// starter, without taking the height the vehicle list needs.
/// </summary>
public void InitClassComboTemplates()
{
@@ -213,7 +212,7 @@ public sealed class VehicleBrowser
classCombo.ItemTemplate = new FuncDataTemplate<string>(
(cls, _) => ClassComboContent(cls, height, stacked: true), false);
classCombo.SelectionBoxItemTemplate = new FuncDataTemplate<string>(
(cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: true), false);
(cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: false), false);
}
private const int PickedClassThumbHeight = 30;
@@ -363,6 +362,7 @@ public sealed class VehicleBrowser
var row = new ListBoxItem
{
Content = BrowserLabel(texture, _db.ResolveSet(texture)),
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Tag = texture
};
vehicleListBox.Items.Add(row);
@@ -415,60 +415,26 @@ public sealed class VehicleBrowser
{
var menu = new ContextMenu();
var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddMany is not null };
addMany.Click += (_, _) =>
AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count));
menu.Items.Add(addMany);
// What the buttons under the list do, for the vehicle the menu was opened on.
menu.AddItem(App.Loc["AddVehicle"], () => AddMany?.Invoke(texture, 1), AddMany is not null,
new KeyGesture(Key.Insert));
menu.AddItem(App.Loc["ReplaceVehicle"], () => Replace?.Invoke(texture), Replace is not null);
menu.AddItem(App.Loc["AddMany"],
() => AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count)),
AddMany is not null);
menu.Items.Add(new Separator());
var copy = new MenuItem { Header = App.Loc["CopyTextureName"] };
copy.Click += async (_, _) =>
{
var top = TopLevel.GetTopLevel(vehicleListBox);
if (top?.Clipboard != null)
await top.Clipboard.SetTextAsync(BrowserName(texture));
};
menu.Items.Add(copy);
var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] };
open.Click += (_, _) =>
{
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
if (!Directory.Exists(dir))
dir = Path.Combine(Directory.GetCurrentDirectory(), texture.Directory);
if (!Directory.Exists(dir))
{
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
string.Format(App.Loc["FaultFileNotFound"], dir));
return;
}
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir)
{
UseShellExecute = true
});
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
$"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}");
}
};
menu.Items.Add(open);
TextureMenuItems.Add(menu, vehicleListBox, texture);
menu.Items.Add(new Separator());
var baseItem = new MenuItem { Header = App.Loc["OpenTextureBase"] };
baseItem.Click += (_, _) => _openTextureBase();
menu.Items.Add(baseItem);
menu.AddItem(App.Loc["OpenTextureBase"], _openTextureBase);
return menu;
}
/// <summary>
/// Fills a detail panel with what is known about a livery: the skin itself,
/// then whatever its credit line carries.
/// Fills a detail panel with what is known about a livery: the model it
/// dresses, then whatever its credit line carries.
/// </summary>
/// <remarks>
/// Built from the same <see cref="DetailRows"/> as the general tab, so the
@@ -491,7 +457,7 @@ public sealed class VehicleBrowser
tooltip.Add($"{label}: {value}");
}
Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile));
// No line for the skin's name: the general tab and the list both carry it.
Reading(App.Loc["Model"], texture.Directory + texture.Model);
if (texture.Meta is { } meta)
@@ -557,20 +523,35 @@ public sealed class VehicleBrowser
// called a vehicle something none of the other panels did.
private static string BrowserName(VehicleTexture texture) => Consist.Base(texture.Skinfile);
// The file name alone, as the old starter listed it - the operator is in the
// texture panel - with a faint count for a set of cars.
// The file name, as the old starter listed it, with a faint count for a set
// of cars and the operator at the far end - liveries of one vehicle differ
// mostly by who runs them.
private static Control BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
{
var name = new TextBlock { Text = BrowserName(texture) };
if (set is not { Count: > 1 })
return name;
// Clear of the scroll bar, which lies over the right edge of the list.
var row = new DockPanel { Margin = new Thickness(0, 0, 10, 0) };
return new StackPanel
string? op = texture.Meta?.Operator;
if (!string.IsNullOrWhiteSpace(op) && op != "?")
{
Orientation = Orientation.Horizontal,
Spacing = 6,
Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } }
};
var operatorText = new TextBlock
{
Text = op,
Opacity = 0.55,
Margin = new Thickness(8, 0, 0, 0),
TextTrimming = TextTrimming.CharacterEllipsis
};
DockPanel.SetDock(operatorText, Dock.Right);
row.Children.Add(operatorText);
}
var name = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
name.Children.Add(new TextBlock { Text = BrowserName(texture) });
if (set is { Count: > 1 })
name.Children.Add(new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 });
row.Children.Add(name);
return row;
}
public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)

View File

@@ -36,6 +36,11 @@ public sealed class VehicleCards
/// <summary>Puts copies of the vehicle on a card right after it, the given number of times.</summary>
public Action<ConsistItem, int>? AddCopies { get; set; }
/// <summary>The livery picked in the vehicle browser, offered as the card's replacement.</summary>
public Func<VehicleTexture?>? BrowserSelection { get; set; }
public Action<ConsistItem, VehicleTexture>? ReplaceWith { get; set; }
private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new();
public Dynamic? PressedCar { get; set; }
@@ -113,9 +118,7 @@ public sealed class VehicleCards
if (item.Grouped && item.Cars.Count > 1)
controls.Children.Add(SmallButton(MaterialIconKind.CallSplit, App.Loc["TipSplit"], () => _consist.Split(item)));
else if ((index + 1 < _consist.Count &&
(Consist.HoldsTail(item) || _consist.CanFormUnit(item, _consist[index + 1]))) ||
(index > 0 && Consist.HoldsTail(_consist[index - 1])))
else if (_consist.CanJoin(item, index))
controls.Children.Add(SmallButton(MaterialIconKind.Link, App.Loc["TipJoin"], () => _consist.Join(item)));
var remove = SmallButton(MaterialIconKind.Close, App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item));
@@ -147,6 +150,14 @@ public sealed class VehicleCards
card.KeyDown += (_, e) => OnCardKey(card, item, e);
card.PointerPressed += (_, e) =>
{
// The menu is about the card it opened on, so that card is selected -
// without moving the browser, whose pick the menu offers as a replacement.
if (e.GetCurrentPoint(card).Properties.IsRightButtonPressed)
{
Select(item, PressedCar, syncBrowser: false);
PressedCar = null;
return;
}
if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed)
return;
@@ -244,7 +255,7 @@ public sealed class VehicleCards
/// </summary>
public void Activate(ConsistItem item) => Select(item, null);
private void Select(ConsistItem item, Dynamic? car)
private void Select(ConsistItem item, Dynamic? car, bool syncBrowser = true)
{
var target = car ?? (ReferenceEquals(_consist.Selected, item)
? _consist.ActiveCar(item)
@@ -258,7 +269,8 @@ public sealed class VehicleCards
_consist.SyncStartingVehicle();
RefreshCardSelectionChrome();
_refreshDetails();
_selectInBrowser(target);
if (syncBrowser)
_selectInBrowser(target);
}
private Border? CardFor(ConsistItem item) =>
@@ -335,8 +347,9 @@ public sealed class VehicleCards
Redraw(keepFocus: true);
break;
// The way a right click does it: the menu fills itself up as it is requested.
case Key.Enter:
card.ContextMenu?.Open(card);
card.RaiseEvent(new ContextRequestedEventArgs());
break;
default:
@@ -612,48 +625,65 @@ public sealed class VehicleCards
btn.Foreground = new SolidColorBrush(Color.Parse(color));
}
/// <summary>
/// Everything that can be done to one card, the keys that do it alongside: the
/// card's own buttons and keys were the only way to most of it before.
/// </summary>
private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
{
var menu = new ContextMenu();
menu.Opening += (_, _) =>
{
menu.Items.Clear();
int index = _consist.IndexOf(item);
var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddCopies is not null };
addMany.Click += (_, _) =>
AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count));
menu.Items.Add(addMany);
menu.Items.Add(new Separator());
var num = new MenuItem { Header = App.Loc["WagonNumber"] };
num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
menu.Items.Add(num);
// Clicking a card shows its own livery in the browser; offering that
// as the replacement would replace the vehicle with itself.
var picked = BrowserSelection?.Invoke();
var active = _consist.ActiveCar(item);
if (picked is not null &&
ReferenceEquals(picked, _db.TextureFor(active.DataFolder, active.SkinFile, active.MmdFile)))
picked = null;
menu.AddItem(picked is null
? App.Loc["ReplaceWithBrowser"]
: $"{App.Loc["ReplaceWithBrowser"]}: {Consist.Base(picked.Skinfile)}",
() => { if (picked is not null) ReplaceWith?.Invoke(item, picked); },
picked is not null && ReplaceWith is not null);
menu.AddItem(App.Loc["AddMany"],
() => AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count)),
AddCopies is not null);
menu.Items.Add(new Separator());
menu.AddItem(App.Loc["TipFlip"], () => { _consist.Flip(item); Redraw(keepFocus: true); },
gesture: new KeyGesture(Key.Space));
menu.AddItem(App.Loc["TipMoveLeft"], () => { _consist.MoveLeft(item); Redraw(keepFocus: true); },
index > 0, new KeyGesture(Key.Left, KeyModifiers.Control));
menu.AddItem(App.Loc["TipMoveRight"], () => { _consist.MoveRight(item); Redraw(keepFocus: true); },
index >= 0 && index < _consist.Count - 1, new KeyGesture(Key.Right, KeyModifiers.Control));
var copyPrev = new MenuItem
{
Header = App.Loc["LoadCopyPrev"],
IsEnabled = _consist.IndexOf(item) > 0
};
copyPrev.Click += (_, _) => { _cargo.CopyFromPrevious(_consist, item); _redraw(); };
menu.Items.Add(copyPrev);
if (item.Grouped && item.Cars.Count > 1)
menu.AddItem(App.Loc["TipSplit"], () => _consist.Split(item));
else if (_consist.CanJoin(item, index))
menu.AddItem(App.Loc["TipJoin"], () => _consist.Join(item));
var copyToAll = new MenuItem
{
Header = App.Loc["LoadCopyToAll"],
IsEnabled = _cargo.IsLoadable(item)
};
copyToAll.Click += (_, _) => { _cargo.CopyToFollowing(_consist, item); _redraw(); };
menu.Items.Add(copyToAll);
var crew = new MenuItem { Header = App.Loc["CrewLabel"] };
foreach (var (type, key) in CrewChoices.All)
crew.AddItem((item.Driver == type ? "● " : "○ ") + App.Loc[key], () => _consist.SetCrew(item, type));
menu.Items.Add(crew);
var max = new MenuItem
{
Header = App.Loc["LoadMax"],
IsEnabled = _cargo.IsLoadable(item)
};
max.Click += (_, _) => { _cargo.FillUnit(item); _redraw(); };
menu.Items.Add(max);
// The member the panels show, not always the first car of a unit.
menu.AddItem(App.Loc["WagonNumber"] + "…",
() => ShowWagonNumberFlyout(anchor, _consist.ActiveCar(item)),
gesture: new KeyGesture(Key.N, KeyModifiers.Control));
menu.Items.Add(new Separator());
menu.AddItem(App.Loc["LoadCopyPrev"], () => { _cargo.CopyFromPrevious(_consist, item); _redraw(); }, index > 0);
menu.AddItem(App.Loc["LoadCopyToAll"], () => { _cargo.CopyToFollowing(_consist, item); _redraw(); },
_cargo.IsLoadable(item));
menu.AddItem(App.Loc["LoadMax"], () => { _cargo.FillUnit(item); _redraw(); }, _cargo.IsLoadable(item));
menu.Items.Add(new Separator());
menu.AddItem(App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item), gesture: new KeyGesture(Key.Delete));
};
return menu;
}

View File

@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
@@ -29,7 +27,6 @@ public sealed class VehicleDetails
private readonly StackPanel damagePanel;
private readonly Button removeVehicleButton;
private readonly VehicleCatalog _db;
private readonly VehicleInfo _info;
private readonly Consist _consist;
private readonly Cargo _cargo;
@@ -39,12 +36,15 @@ public sealed class VehicleDetails
private readonly Action _refreshChrome;
private readonly Action<VehicleTexture, StackPanel> _showTextureInfo;
/// <summary>A setting changed on the cars themselves, which redraws nothing.</summary>
public Action? Edited { get; init; }
public VehicleDetails(
StackPanel generalPanel, StackPanel consistTexturePanel, StackPanel couplerPanel,
Panel couplerActions,
StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel,
Button removeVehicleButton,
VehicleCatalog db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
Action redraw, Action refreshChrome, Action<VehicleTexture, StackPanel> showTextureInfo)
{
this.generalPanel = generalPanel;
@@ -56,7 +56,6 @@ public sealed class VehicleDetails
this.damagePanel = damagePanel;
this.removeVehicleButton = removeVehicleButton;
_db = db;
_info = info;
_consist = consist;
_cargo = cargo;
@@ -97,7 +96,7 @@ public sealed class VehicleDetails
BuildGeneralInfo(_consist.Selected);
if (_db.TextureFor(_consist.ActiveCar(_consist.Selected).DataFolder, _consist.ActiveCar(_consist.Selected).SkinFile, _consist.ActiveCar(_consist.Selected).MmdFile) is { } tex)
if (_info.TextureFor(_consist.ActiveCar(_consist.Selected)) is { } tex)
_showTextureInfo(tex, consistTexturePanel);
else
consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"]));
@@ -269,24 +268,12 @@ public sealed class VehicleDetails
private Control BuildVehicleSection(ConsistItem item)
{
var driver = new ComboBox { FontSize = 12, MinWidth = 0 };
foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" })
foreach (var (_, key) in CrewChoices.All)
driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] });
driver.SelectedIndex = item.Driver switch
{
eDriverType.Headdriver => 0,
eDriverType.Reardriver => 1,
eDriverType.Passenger => 2,
_ => 3
};
driver.SelectedIndex = CrewChoices.IndexOf(item.Driver);
driver.SelectionChanged += (_, _) =>
{
var picked = driver.SelectedIndex switch
{
0 => eDriverType.Headdriver,
1 => eDriverType.Reardriver,
2 => eDriverType.Passenger,
_ => eDriverType.Nobody
};
var picked = CrewChoices.At(driver.SelectedIndex);
if (picked == item.Driver) return;
_consist.SetCrew(item, picked);
@@ -338,7 +325,11 @@ public sealed class VehicleDetails
IsChecked = d.Coupling.ThermoDynamic,
FontSize = 12
};
thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true;
thermo.IsCheckedChanged += (_, _) =>
{
d.Coupling.ThermoDynamic = thermo.IsChecked == true;
Edited?.Invoke();
};
// Label-less row, so the box indents to the control column with everything
// else instead of starting at the panel's left edge on its own.
@@ -436,6 +427,7 @@ public sealed class VehicleDetails
Load = (loadCombo.SelectedItem as ComboBoxItem)?.Tag as string
};
foreach (var c in Consist.UnitTargets(item)) c.Coupling.SetBrake(b);
Edited?.Invoke();
}
modeCombo.SelectionChanged += (_, _) => Apply();
@@ -501,6 +493,7 @@ public sealed class VehicleDetails
};
foreach (var c in _consist.MemberTargets(item))
c.Coupling.SetWheels(ws);
Edited?.Invoke();
}
sway.ValueChanged += (_, _) => Apply();
@@ -739,6 +732,7 @@ public sealed class VehicleDetails
c.LoadType = string.IsNullOrEmpty(type) ? null : type;
c.LoadCount = string.IsNullOrEmpty(type) ? 0 : Math.Min(count, _cargo.LimitFor(c, type));
}
Edited?.Invoke();
}
// Seed the pantograph combo from the model before any handler is listening.

View File

@@ -11,6 +11,10 @@ using StarterNG.Classes;
namespace StarterNG.Views;
/// <summary>
/// The warehouse tab: consists kept for later. The one selected is the one the
/// consist strip edits, and the menu puts it into the scenery's selected consist.
/// </summary>
public sealed class Warehouse
{
private readonly ListBox _list;
@@ -19,6 +23,16 @@ public sealed class Warehouse
private readonly Func<Trainset?> _target;
private readonly Action<Trainset, List<Dynamic>> _apply;
/// <summary>Set while the list is rebuilt, which clears and restores the selection.</summary>
private bool _refreshing;
/// <summary>Another consist is selected, or none - not raised mid-rebuild.</summary>
public event Action? SelectionChanged;
/// <summary>A consist got a new name: the old one, then the new one.</summary>
public event Action<string, string>? Renamed;
/// <param name="target">The scenery consist the menu's actions put a warehouse consist into.</param>
public Warehouse(ListBox list, Button removeButton, Cursor hand,
Func<Trainset?> target, Action<Trainset, List<Dynamic>> apply)
{
@@ -27,11 +41,20 @@ public sealed class Warehouse
_hand = hand;
_target = target;
_apply = apply;
_list.SelectionChanged += (_, _) =>
{
if (!_refreshing)
SelectionChanged?.Invoke();
};
}
public void Refresh()
/// <param name="select">The consist to select; the one selected now otherwise.</param>
public void Refresh(string? select = null)
{
string? current = SelectedName();
string? current = select ?? SelectedName();
string? before = SelectedName();
_refreshing = true;
_list.Items.Clear();
foreach (var preset in PresetStore.All())
@@ -52,10 +75,16 @@ public sealed class Warehouse
_list.ContextMenu ??= BuildMenu();
_removeButton.IsEnabled = _list.Items.Count > 0;
_refreshing = false;
if (!string.Equals(before, SelectedName(), StringComparison.Ordinal))
SelectionChanged?.Invoke();
}
private string? SelectedName() => (_list.SelectedItem as ListBoxItem)?.Tag as string;
public TrainsetPreset? SelectedPreset() => SelectedName() is { } name ? PresetNamed(name) : null;
private static TrainsetPreset? PresetNamed(string name) =>
PresetStore.All().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal));
@@ -68,20 +97,14 @@ public sealed class Warehouse
bool hasPreset = SelectedName() != null;
bool hasTarget = _target() != null;
void Add(string key, Action run, bool enabled = true)
{
var mi = new MenuItem { Header = App.Loc[key], IsEnabled = enabled };
mi.Click += (_, _) => run();
menu.Items.Add(mi);
}
Add("WarehouseApply", ApplySelected, hasPreset && hasTarget);
Add("WarehouseAppend", AppendSelected, hasPreset && hasTarget);
Add("ChangeName", RenameSelected, hasPreset);
menu.AddItem(App.Loc["WarehouseUseInScenery"], ApplySelected, hasPreset && hasTarget);
menu.AddItem(App.Loc["WarehouseAppendToScenery"], AppendSelected, hasPreset && hasTarget);
menu.AddItem(App.Loc["ChangeName"], RenameSelected, hasPreset);
menu.AddItem(App.Loc["RemoveFromWarehouse"], () => _ = RemoveSelectedAsync(), hasPreset);
menu.Items.Add(new Separator());
Add("PresetImportMagazyn", () => _ = ImportMagazynAsync());
Add("WarehouseImportFile", () => _ = ImportFileAsync());
menu.AddItem(App.Loc["PresetImportMagazyn"], () => _ = ImportMagazynAsync());
menu.AddItem(App.Loc["WarehouseImportFile"], () => _ = ImportFileAsync());
menu.Items.Add(new Separator());
var sort = new MenuItem { Header = App.Loc["PresetSort"] };
@@ -92,68 +115,41 @@ public sealed class Warehouse
return menu;
}
private MenuItem SortItem(PresetSort mode, string locKey)
{
var item = new MenuItem
{
Header = (PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey]
};
item.Click += (_, _) => { PresetStore.SortMode = mode; Refresh(); };
return item;
}
private MenuItem SortItem(PresetSort mode, string locKey) =>
MenuItems.Item((PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey],
() => { PresetStore.SortMode = mode; Refresh(); });
public async Task SaveCurrentAsync()
{
if (_target() is not { } trainset)
return;
string name = trainset.Name;
if (string.IsNullOrWhiteSpace(name))
name = App.Loc["PresetEmpty"];
try
{
PresetStore.Save(name, ConsistText.Serialize(trainset));
}
catch (Exception ex)
{
await Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex);
return;
}
Refresh();
}
/// <summary>Asks for a name at the button pressed, and says where the consist went.</summary>
public void SaveCurrent(Control anchor) =>
ConsistContextMenu.ShowSaveFlyout(anchor, _target(), atPointer: false, saved: name => Refresh(name));
public void ApplySelected()
{
if (SelectedName() is not { } name || _target() is not { } target)
return;
if (PresetNamed(name) is not { } preset)
if (_target() is not { } target || SelectedPreset() is not { } preset)
return;
if (ConsistText.VehiclesFrom(preset.Entry) is { } vehicles)
_apply(target, vehicles);
_apply(target, ConsistContextMenu.KeepCrew(target, vehicles));
}
public void AppendSelected()
{
if (SelectedName() is not { } name || _target() is not { } target)
if (_target() is not { } target)
return;
var vehicles = ConsistText.VehiclesFrom(PresetNamed(name)?.Entry);
var vehicles = ConsistText.VehiclesFrom(SelectedPreset()?.Entry);
if (vehicles is null || vehicles.Count == 0)
return;
var merged = new List<Dynamic>(target.Vehicles);
merged.AddRange(vehicles);
_apply(target, merged);
_apply(target, Domain.Consist.Joined(target.Vehicles, vehicles));
}
public void RenameSelected()
{
if (SelectedName() is not { } name || PresetNamed(name) is not { } preset)
if (SelectedPreset() is not { } preset)
return;
string name = preset.Name;
var nameBox = new TextBox { Text = name, MinWidth = 240 };
var ok = new Button { Content = App.Loc["Ok"], Cursor = _hand };
ok.Classes.Add("Accent");
@@ -169,16 +165,23 @@ public sealed class Warehouse
panel.Children.Add(ok);
var flyout = new Flyout { Content = panel };
ok.Click += (_, _) =>
ok.Click += async (_, _) =>
{
string? fresh = nameBox.Text?.Trim();
if (!string.IsNullOrEmpty(fresh) && !string.Equals(fresh, name, StringComparison.Ordinal))
{
PresetStore.Save(fresh, preset.Entry);
PresetStore.Delete(name);
Refresh();
}
flyout.Hide();
if (string.IsNullOrEmpty(fresh) || string.Equals(fresh, name, StringComparison.Ordinal))
return;
// Differing only in case is this very entry, not another one written over.
if (!string.Equals(fresh, name, StringComparison.OrdinalIgnoreCase) &&
!await ConsistContextMenu.MayOverwritePresetAsync(_list, fresh))
return;
// Deleted first: a new name differing only in case is the same entry.
PresetStore.Delete(name);
PresetStore.Save(fresh, preset.Entry);
Renamed?.Invoke(name, fresh);
Refresh(fresh);
};
flyout.ShowAt(_list);
nameBox.Focus();
@@ -214,7 +217,7 @@ public sealed class Warehouse
public async Task RemoveSelectedAsync()
{
if (SelectedName() is not { } name)
if (SelectedName() is not { } name || !await ConsistContextMenu.MayRemovePresetAsync(_list, name))
return;
try

View File

@@ -0,0 +1,26 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
namespace StarterNG.Views;
/// <summary>A menu entry that runs something when clicked, as nearly all of them do.</summary>
internal static class MenuItems
{
/// <param name="header">The text, or a control where a row holds more than that.</param>
public static MenuItem Item(object header, Action run, bool enabled = true, KeyGesture? gesture = null)
{
var item = new MenuItem { Header = header, IsEnabled = enabled, InputGesture = gesture };
item.Click += (_, _) => run();
return item;
}
/// <summary>The entry at the end of a menu or a submenu.</summary>
public static MenuItem AddItem(this ItemsControl menu, object header, Action run, bool enabled = true,
KeyGesture? gesture = null)
{
var item = Item(header, run, enabled, gesture);
menu.Items.Add(item);
return item;
}
}

View File

@@ -1,76 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:starterNg="clr-namespace:StarterNG"
xmlns:views="clr-namespace:StarterNG.Views"
x:Class="StarterNG.Views.TextureBaseWindow"
x:CompileBindings="True"
Title="{Binding [TextureBaseTitle], Source={x:Static starterNg:App.Loc}}"
Width="980" Height="560"
WindowStartupLocation="CenterOwner"
MinWidth="720" MinHeight="400">
<DockPanel Margin="10">
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto"
ColumnSpacing="8" RowSpacing="6" Margin="0,0,0,8">
<CheckBox Name="fNameOn" Grid.Row="0" Grid.Column="0" Content="{Binding [TextureInfo], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fName" Grid.Row="0" Grid.Column="1" PlaceholderText="{Binding [Search], Source={x:Static starterNg:App.Loc}}" />
<CheckBox Name="fOpOn" Grid.Row="0" Grid.Column="2" Content="{Binding [TexOperator], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fOp" Grid.Row="0" Grid.Column="3" />
<CheckBox Name="fMiniOn" Grid.Row="1" Grid.Column="0" Content="{Binding [Class], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fMini" Grid.Row="1" Grid.Column="1" />
<CheckBox Name="fAuthorOn" Grid.Row="1" Grid.Column="2" Content="{Binding [TexAuthor], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fAuthor" Grid.Row="1" Grid.Column="3" />
<CheckBox Name="fStationOn" Grid.Row="2" Grid.Column="0" Content="{Binding [TexStation], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fStation" Grid.Row="2" Grid.Column="1" />
<CheckBox Name="fPhotoOn" Grid.Row="2" Grid.Column="2" Content="{Binding [TexPhoto], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fPhoto" Grid.Row="2" Grid.Column="3" />
<CheckBox Name="fModelOn" Grid.Row="3" Grid.Column="0" Content="{Binding [Model], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fModel" Grid.Row="3" Grid.Column="1" />
<CheckBox Name="fRevFromOn" Grid.Row="4" Grid.Column="0" Content="{Binding [RevisionFrom], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fRevFrom" Grid.Row="4" Grid.Column="1" PlaceholderText="RRRR-MM-DD" />
<CheckBox Name="fRevToOn" Grid.Row="4" Grid.Column="2" Content="{Binding [RevisionTo], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fRevTo" Grid.Row="4" Grid.Column="3" PlaceholderText="RRRR-MM-DD" />
</Grid>
<TextBlock Name="statusText" DockPanel.Dock="Bottom" Margin="0,6,0,0"
FontSize="12" Opacity="0.75" />
<Grid DockPanel.Dock="Left" Width="260" RowDefinitions="*,4,*">
<ListBox Name="typeList" Grid.Row="0" Classes="Compact" />
<GridSplitter Grid.Row="1" Height="4" HorizontalAlignment="Stretch"
Background="Transparent" ResizeDirection="Rows" />
<ListBox Name="modelList" Grid.Row="2" Classes="Compact">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="views:TextureModelRow">
<Grid ColumnDefinitions="28,90,*" ColumnSpacing="4">
<TextBlock Grid.Column="0" Text="{Binding No}" Opacity="0.6" />
<TextBlock Grid.Column="1" Text="{Binding Klasa}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Text="{Binding Sciezka}" Opacity="0.75"
TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<GridSplitter Name="spSplitter" DockPanel.Dock="Left" Width="4"
Background="Transparent" ResizeDirection="Columns" />
<ListBox Name="textureList" DoubleTapped="TextureList_OnDoubleTapped">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="views:TextureRow">
<Grid ColumnDefinitions="1.4*,0.9*,1.2*,90,*" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Skin}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding Mini}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Text="{Binding Operator}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="3" Text="{Binding Revision}" Opacity="0.85" />
<TextBlock Grid.Column="4" Text="{Binding Author}" Opacity="0.75" TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Window>

View File

@@ -1,224 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using StarterNG.Classes;
using StarterNG.Domain.Vehicles;
using StarterNG.Application;
namespace StarterNG.Views;
public partial class TextureBaseWindow : Window
{
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
private readonly List<TextureRow> _all = new();
public VehicleTexture? Picked { get; private set; }
public TextureBaseWindow()
{
InitializeComponent();
foreach (var t in _db.Textures)
{
if (_db.IsSetFollower(t)) continue;
_all.Add(new TextureRow(t, _db));
}
foreach (var box in new Control[] { fName, fOp, fMini, fAuthor, fStation, fPhoto, fModel })
{
if (box is TextBox tb)
tb.TextChanged += (_, _) => Refresh();
}
foreach (var box in new Control[] { fNameOn, fOpOn, fMiniOn, fAuthorOn, fStationOn, fPhotoOn,
fModelOn, fRevFromOn, fRevToOn })
{
if (box is CheckBox cb)
cb.IsCheckedChanged += (_, _) => Refresh();
}
fRevFrom.TextChanged += (_, _) => Refresh();
fRevTo.TextChanged += (_, _) => Refresh();
BuildTypeList();
typeList.SelectionChanged += (_, _) => { BuildModelList(); Refresh(); };
modelList.SelectionChanged += (_, _) => Refresh();
textureList.ContextRequested += TextureList_OnContextRequested;
textureList.AddHandler(PointerReleasedEvent, TextureList_OnPointerReleased, handledEventsToo: true);
Refresh();
}
private void BuildModelList()
{
modelList.ItemsSource = null;
string? cat = (typeList.SelectedItem as ListBoxItem)?.Tag as string;
var rows = _all
.Where(r => cat == null || string.Equals(r.Category, cat, StringComparison.OrdinalIgnoreCase))
.Where(r => r.Mini.Length > 0)
.GroupBy(r => r.Mini, StringComparer.OrdinalIgnoreCase)
.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select((g, i) => new TextureModelRow($"{i + 1}.", g.Key, g.First().Directory))
.ToList();
modelList.ItemsSource = rows;
}
private static DateTime? ParseDate(string? text) =>
DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d) ? d : null;
private void BuildTypeList()
{
typeList.Items.Add(new ListBoxItem { Content = App.Loc["CatAll"], Tag = null });
foreach (string cat in _all.Select(r => r.Category)
.Where(c => c.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase))
typeList.Items.Add(new ListBoxItem { Content = cat, Tag = cat });
typeList.SelectedIndex = 0;
BuildModelList();
}
private void Refresh()
{
IEnumerable<TextureRow> q = _all;
q = Filter(q, fNameOn, fName, r => r.Skin);
q = Filter(q, fOpOn, fOp, r => r.Operator);
q = Filter(q, fMiniOn, fMini, r => r.Mini);
q = Filter(q, fAuthorOn, fAuthor, r => r.Author);
q = Filter(q, fStationOn, fStation, r => r.Station);
q = Filter(q, fPhotoOn, fPhoto, r => r.Photo);
q = Filter(q, fModelOn, fModel, r => r.Model);
if ((typeList.SelectedItem as ListBoxItem)?.Tag is string cat)
q = q.Where(r => string.Equals(r.Category, cat, StringComparison.OrdinalIgnoreCase));
if (modelList.SelectedItem is TextureModelRow mr)
q = q.Where(r => string.Equals(r.Mini, mr.Klasa, StringComparison.OrdinalIgnoreCase));
if (fRevFromOn.IsChecked == true && ParseDate(fRevFrom.Text) is { } from)
q = q.Where(r => r.RevisionDate is { } d && d >= from);
if (fRevToOn.IsChecked == true && ParseDate(fRevTo.Text) is { } to)
q = q.Where(r => r.RevisionDate is { } d && d <= to);
var list = q
.OrderBy(r => r.Mini, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Skin, StringComparer.OrdinalIgnoreCase)
.Take(5000)
.ToList();
textureList.ItemsSource = list;
statusText.Text = $"{list.Count} / {_all.Count}";
}
private static IEnumerable<TextureRow> Filter(
IEnumerable<TextureRow> src, CheckBox? on, TextBox? box, Func<TextureRow, string> field)
{
if (on?.IsChecked != true) return src;
string needle = box?.Text?.Trim() ?? "";
if (needle.Length < 1) return src;
return src.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase));
}
private void TextureList_OnDoubleTapped(object? sender, TappedEventArgs e)
{
if (textureList.SelectedItem is not TextureRow row) return;
Picked = row.Texture;
}
// DoubleTapped is raised from the second press, while the list still holds the
// pointer. Closing there leaves the capture on a dead window and on Windows the
// owner stops taking clicks. The release ends the gesture and drops the capture,
// so that is where the window goes.
private void TextureList_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (Picked != null)
Close();
}
private void TextureList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (textureList.SelectedItem is not TextureRow row) return;
var menu = new ContextMenu();
var copy = new MenuItem { Header = App.Loc["CopyTextureName"] };
copy.Click += async (_, _) =>
{
if (Clipboard != null)
await Clipboard.SetTextAsync(row.Skin);
};
menu.Items.Add(copy);
var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] };
open.Click += (_, _) =>
{
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
row.Texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
if (!Directory.Exists(dir))
dir = Path.Combine(Directory.GetCurrentDirectory(), row.Texture.Directory);
if (!Directory.Exists(dir))
{
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
string.Format(App.Loc["FaultFileNotFound"], dir));
return;
}
try
{
Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true });
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
$"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}");
}
};
menu.Items.Add(open);
menu.Open(textureList);
e.Handled = true;
}
}
internal sealed record TextureModelRow(string No, string Klasa, string Sciezka);
internal sealed class TextureRow
{
public VehicleTexture Texture { get; }
public string Skin { get; }
public string Mini { get; }
public string Operator { get; }
public string Revision { get; }
public string Author { get; }
public string Station { get; }
public string Photo { get; }
public string Model { get; }
public string Category { get; }
public string Directory { get; }
public DateTime? RevisionDate { get; }
public TextureRow(VehicleTexture t, VehicleCatalog db)
{
Texture = t;
Skin = Path.GetFileNameWithoutExtension(t.Skinfile);
Mini = db.ResolveMiniName(t) ?? t.ResolvedClass ?? "";
Operator = t.Meta?.Operator ?? "";
Revision = t.Meta?.RevisionDate ?? "";
Author = t.Meta?.TextureAuthor ?? "";
Station = t.Meta?.Depot ?? "";
Photo = t.Meta?.PhotoAuthor ?? "";
Model = t.Model ?? "";
Category = t.ResolvedCategory ?? "";
Directory = t.Directory ?? "";
RevisionDate = DateTime.TryParse(Revision, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var rev) ? rev : null;
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Views;
public sealed record VehicleClassRow(string Name, int Count);
public sealed class VehicleBaseRow
{
public VehicleTexture Texture { get; }
public string Skin { get; }
public string Class { get; }
public string Operator { get; }
public string Revision { get; }
public string Author { get; }
public string Station { get; }
public string Photo { get; }
public string Model { get; }
public string? Category { get; }
public RevisionSpan? Date { get; }
/// <summary>Sorts as text in the order of the dates; empty for a date not known.</summary>
public string DateKey => Date?.From.ToString("yyyyMMdd", CultureInfo.InvariantCulture) ?? "";
public VehicleBaseRow(VehicleTexture texture)
{
Texture = texture;
Skin = Consist.Base(texture.Skinfile);
Class = VehicleInfo.ClassOf(texture);
Operator = Known(texture.Meta?.Operator);
Revision = Known(texture.Meta?.RevisionDate);
Author = Known(texture.Meta?.TextureAuthor);
Station = Known(texture.Meta?.Depot);
Photo = Known(texture.Meta?.PhotoAuthor);
Model = texture.Directory + texture.Model;
Category = VehicleInfo.CategoryOf(texture);
Date = RevisionSpan.Parse(Revision);
}
// The credit lines write a question mark for what their author did not know.
private static string Known(string? value) => value is null or "?" ? "" : value;
}
/// <summary>What is typed and picked to look through the vehicle base with.</summary>
public sealed record VehicleBaseFilters
{
public string? Name { get; init; }
public string? Class { get; init; }
public string? Operator { get; init; }
public string? Author { get; init; }
public string? Model { get; init; }
public string? Station { get; init; }
public string? Photo { get; init; }
public string? RevisionFrom { get; init; }
public string? RevisionTo { get; init; }
/// <summary>The class picked from the list, which has to match whole.</summary>
public string? PickedClass { get; init; }
}
public static class VehicleBaseQuery
{
private static readonly StringComparer Order = StringComparer.CurrentCultureIgnoreCase;
public static List<VehicleClassRow> ClassCounts(IEnumerable<VehicleBaseRow> rows) => rows
.Where(r => r.Class.Length > 0)
.GroupBy(r => r.Class, StringComparer.OrdinalIgnoreCase)
.Select(g => new VehicleClassRow(g.Key, g.Count()))
.OrderBy(c => c.Name, Order)
.ToList();
public static List<VehicleBaseRow> Select(IEnumerable<VehicleBaseRow> rows, VehicleBaseFilters filters,
Func<VehicleBaseRow, string> sortKey, bool descending)
{
rows = Filter(rows, filters.Name, r => r.Skin);
rows = Filter(rows, filters.Class, r => r.Class);
rows = Filter(rows, filters.Operator, r => r.Operator);
rows = Filter(rows, filters.Author, r => r.Author);
rows = Filter(rows, filters.Model, r => r.Model);
rows = Filter(rows, filters.Station, r => r.Station);
rows = Filter(rows, filters.Photo, r => r.Photo);
if (filters.PickedClass is { } picked)
rows = rows.Where(r => string.Equals(r.Class, picked, StringComparison.OrdinalIgnoreCase));
if (RevisionSpan.Parse(filters.RevisionFrom) is { } from)
rows = rows.Where(r => r.Date is { } date && date.To >= from.From);
if (RevisionSpan.Parse(filters.RevisionTo) is { } to)
rows = rows.Where(r => r.Date is { } date && date.From <= to.To);
// Blanks stay at the bottom whichever way a column is sorted.
var blanksLast = rows.OrderBy(r => sortKey(r).Length == 0);
var sorted = descending ? blanksLast.ThenByDescending(sortKey, Order) : blanksLast.ThenBy(sortKey, Order);
return sorted.ThenBy(r => r.Skin, Order).ToList();
}
private static IEnumerable<VehicleBaseRow> Filter(
IEnumerable<VehicleBaseRow> rows, string? typed, Func<VehicleBaseRow, string> field)
{
string needle = typed?.Trim() ?? "";
return needle.Length == 0
? rows
: rows.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,160 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:starterNg="clr-namespace:StarterNG"
xmlns:views="clr-namespace:StarterNG.Views"
x:Class="StarterNG.Views.VehicleBaseWindow"
x:CompileBindings="True"
Title="{Binding [TextureBaseTitle], Source={x:Static starterNg:App.Loc}}"
Width="980" Height="600"
WindowStartupLocation="CenterOwner"
MinWidth="820" MinHeight="420"
KeyDown="Window_OnKeyDown">
<Window.Styles>
<Style Selector="Border.Panel">
<Setter Property="Background" Value="{StaticResource CarbonPanelBg}" />
<Setter Property="BorderBrush" Value="{StaticResource CarbonBorderLight}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="6" />
</Style>
<Style Selector="TextBlock.FilterLabel">
<Setter Property="Foreground" Value="{StaticResource CarbonFgDim}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.Heading">
<Setter Property="Foreground" Value="{StaticResource CarbonFgDim}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Margin" Value="4,0,0,3" />
</Style>
<Style Selector="TextBlock.SortHead">
<Setter Property="Foreground" Value="{StaticResource CarbonFgDim}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.SortHead:pointerover">
<Setter Property="Foreground" Value="{StaticResource CarbonFg}" />
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*,Auto" RowSpacing="3" Margin="3">
<!-- Whatever is typed filters straight away. -->
<Border Grid.Row="0" Classes="Panel">
<Grid ColumnDefinitions="Auto,*,Auto,*,Auto,*,Auto,*,Auto,*" RowDefinitions="Auto,Auto"
ColumnSpacing="6" RowSpacing="4">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="FilterLabel" Text="{Binding [TextureInfo], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fName" Grid.Row="0" Grid.Column="1" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="FilterLabel" Text="{Binding [Class], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fClass" Grid.Row="0" Grid.Column="3" />
<TextBlock Grid.Row="0" Grid.Column="4" Classes="FilterLabel" Text="{Binding [TexOperator], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fOperator" Grid.Row="0" Grid.Column="5" />
<TextBlock Grid.Row="0" Grid.Column="6" Classes="FilterLabel" Text="{Binding [TexAuthor], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fAuthor" Grid.Row="0" Grid.Column="7" />
<TextBlock Grid.Row="0" Grid.Column="8" Classes="FilterLabel" Text="{Binding [Model], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fModel" Grid.Row="0" Grid.Column="9" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="FilterLabel" Text="{Binding [TexStation], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fStation" Grid.Row="1" Grid.Column="1" />
<TextBlock Grid.Row="1" Grid.Column="2" Classes="FilterLabel" Text="{Binding [TexPhoto], Source={x:Static starterNg:App.Loc}}" />
<TextBox Name="fPhoto" Grid.Row="1" Grid.Column="3" />
<TextBlock Name="revFromLabel" Grid.Row="1" Grid.Column="4" Classes="FilterLabel" />
<TextBox Name="fRevFrom" Grid.Row="1" Grid.Column="5"
PlaceholderText="{Binding [TextureBaseDateHint], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Name="revToLabel" Grid.Row="1" Grid.Column="6" Classes="FilterLabel" />
<TextBox Name="fRevTo" Grid.Row="1" Grid.Column="7"
PlaceholderText="{Binding [TextureBaseDateHint], Source={x:Static starterNg:App.Loc}}" />
<Button Name="clearButton" Grid.Row="1" Grid.Column="8" Grid.ColumnSpan="2" Classes="Flat"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Cursor="Hand" Click="ClearButton_OnClick"
Content="{Binding [ClearFilters], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="230,3,*">
<Border Grid.Column="0" Classes="Panel">
<Grid RowDefinitions="Auto,*,4,Auto,*,Auto,Auto">
<TextBlock Grid.Row="0" Classes="Heading" Text="{Binding [Category], Source={x:Static starterNg:App.Loc}}" />
<ListBox Name="categoryList" Grid.Row="1" Classes="Compact" />
<GridSplitter Grid.Row="2" Height="4" HorizontalAlignment="Stretch"
Background="Transparent" ResizeDirection="Rows" />
<TextBlock Grid.Row="3" Classes="Heading" Text="{Binding [Class], Source={x:Static starterNg:App.Loc}}" />
<ListBox Name="classList" Grid.Row="4" Classes="Compact">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="views:VehicleClassRow">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,0,10,0">
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding Count}" Opacity="0.55" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<CheckBox Name="hideArchivalCheck" Grid.Row="5" Margin="0,6,0,0" FontSize="12"
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Name="statusText" Grid.Row="6" Margin="4,4,0,0" FontSize="11"
Foreground="{StaticResource CarbonFgDim}" />
</Grid>
</Border>
<GridSplitter Grid.Column="1" Width="3" Background="Transparent" ResizeDirection="Columns" />
<Border Grid.Column="2" Classes="Panel">
<Grid RowDefinitions="Auto,*">
<!-- The columns of the rows below; 14 is the rows' padding and their margin
clear of the scroll bar. -->
<Grid ColumnDefinitions="1.4*,0.9*,0.9*,82,1.2*" ColumnSpacing="8" Margin="4,0,14,3">
<TextBlock Name="headSkin" Grid.Column="0" Classes="SortHead" Tapped="Head_OnTapped" />
<TextBlock Name="headClass" Grid.Column="1" Classes="SortHead" Tapped="Head_OnTapped" />
<TextBlock Name="headOperator" Grid.Column="2" Classes="SortHead" Tapped="Head_OnTapped" />
<TextBlock Name="headRevision" Grid.Column="3" Classes="SortHead" Tapped="Head_OnTapped" />
<TextBlock Name="headAuthor" Grid.Column="4" Classes="SortHead" Tapped="Head_OnTapped" />
</Grid>
<ListBox Name="vehicleList" Grid.Row="1" Classes="Compact"
SelectionChanged="VehicleList_OnSelectionChanged"
DoubleTapped="VehicleList_OnDoubleTapped"
KeyDown="VehicleList_OnKeyDown">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="views:VehicleBaseRow">
<Grid ColumnDefinitions="1.4*,0.9*,0.9*,82,1.2*" ColumnSpacing="8" Margin="0,0,10,0">
<TextBlock Grid.Column="0" Text="{Binding Skin}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Text="{Binding Class}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Text="{Binding Operator}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="3" Text="{Binding Revision}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="4" Text="{Binding Author}" Opacity="0.75" TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Name="emptyText" Grid.Row="1" IsVisible="False" Margin="4,6,4,0"
TextWrapping="Wrap" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [TextureBaseNoMatch], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</Border>
</Grid>
<!-- The vehicle picked: what the list has no column for, and what can be done with it. -->
<Border Grid.Row="2" Classes="Panel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="8" Height="46">
<Border Width="170" Background="{StaticResource CarbonInputBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1" CornerRadius="2">
<Image Name="preview" Stretch="Uniform" Margin="2" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<TextBlock Name="detailTitle" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
<TextBlock Name="detailText" FontSize="12" Foreground="{StaticResource CarbonFgDim}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button Name="replaceButton" Grid.Column="2" Classes="Flat" MinWidth="80" Cursor="Hand"
IsEnabled="False" VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center" Click="ReplaceButton_OnClick"
ToolTip.Tip="{Binding [TipReplaceVehicle], Source={x:Static starterNg:App.Loc}}"
Content="{Binding [ReplaceVehicle], Source={x:Static starterNg:App.Loc}}" />
<Button Name="addButton" Grid.Column="3" Classes="Flat Accent" MinWidth="80" Cursor="Hand"
IsEnabled="False" VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
VerticalContentAlignment="Center" Click="AddButton_OnClick"
Content="{Binding [AddVehicle], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using StarterNG.Application;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Views;
/// <summary>
/// Every livery of the installation, to look through by what its credit line says.
/// Whatever is typed filters straight away; a column's head sorts by it.
/// </summary>
public partial class VehicleBaseWindow : Window
{
private const int PreviewHeight = 40;
private sealed record Column(TextBlock Head, string LocKey, Func<VehicleBaseRow, string> Key);
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
private readonly MiniTextures _minis = new();
private readonly List<VehicleBaseRow> _all = new();
private readonly TextBox[] _filters;
private readonly Column[] _columns;
private Column _sortBy;
private bool _descending;
private bool _clearing;
public VehicleTexture? Picked { get; private set; }
/// <summary>The vehicle picked goes behind the selected one instead of taking its place.</summary>
public bool PickedToAdd { get; private set; }
public VehicleBaseWindow()
{
InitializeComponent();
foreach (var texture in _db.Textures)
if (!_db.IsSetFollower(texture))
_all.Add(new VehicleBaseRow(texture));
revFromLabel.Text = App.Loc["RevisionFrom"].TrimEnd(':', ' ');
revToLabel.Text = App.Loc["RevisionTo"].TrimEnd(':', ' ');
_columns = new[]
{
new Column(headSkin, "TextureInfo", r => r.Skin),
new Column(headClass, "Class", r => r.Class),
new Column(headOperator, "TexOperator", r => r.Operator),
new Column(headRevision, "TexRevision", r => r.DateKey),
new Column(headAuthor, "TexAuthor", r => r.Author)
};
_sortBy = _columns[1];
ShowSort();
_filters = new[] { fName, fClass, fOperator, fAuthor, fModel, fStation, fPhoto, fRevFrom, fRevTo };
foreach (var box in _filters)
box.TextChanged += (_, _) => Refresh();
hideArchivalCheck.IsChecked = AppServices.Current.Settings.HideArchivalVehicles;
hideArchivalCheck.IsCheckedChanged += HideArchivalCheck_OnChanged;
FillCategories();
categoryList.SelectionChanged += (_, _) => { FillClasses(); Refresh(); };
classList.SelectionChanged += (_, _) => Refresh();
vehicleList.ContextRequested += VehicleList_OnContextRequested;
vehicleList.AddHandler(PointerReleasedEvent, VehicleList_OnPointerReleased, handledEventsToo: true);
ShowSelected();
Refresh();
Opened += (_, _) => fName.Focus();
}
private void FillCategories()
{
categoryList.Items.Add(new ListBoxItem { Content = App.Loc["CatAll"], Tag = null });
foreach (var (locKey, match) in VehicleBrowser.CategoryDefs)
if (_all.Any(r => match(r.Category)))
categoryList.Items.Add(new ListBoxItem { Content = App.Loc[locKey], Tag = match });
categoryList.SelectedIndex = 0;
FillClasses();
}
private bool Offered(VehicleBaseRow row) =>
hideArchivalCheck.IsChecked != true || !row.Texture.ResolvedArchived;
private bool InCategory(VehicleBaseRow row) =>
(categoryList.SelectedItem as ListBoxItem)?.Tag is not Func<string?, bool> match || match(row.Category);
/// <summary>The classes of the category, keeping the one picked if it is still among them.</summary>
private void FillClasses()
{
string? keep = (classList.SelectedItem as VehicleClassRow)?.Name;
var rows = VehicleBaseQuery.ClassCounts(_all.Where(r => Offered(r) && InCategory(r)));
classList.ItemsSource = rows;
classList.SelectedItem = rows.FirstOrDefault(c => string.Equals(c.Name, keep, StringComparison.OrdinalIgnoreCase));
}
private void Refresh()
{
if (_clearing)
return;
var list = VehicleBaseQuery.Select(
_all.Where(r => Offered(r) && InCategory(r)),
new VehicleBaseFilters
{
Name = fName.Text,
Class = fClass.Text,
Operator = fOperator.Text,
Author = fAuthor.Text,
Model = fModel.Text,
Station = fStation.Text,
Photo = fPhoto.Text,
RevisionFrom = fRevFrom.Text,
RevisionTo = fRevTo.Text,
PickedClass = (classList.SelectedItem as VehicleClassRow)?.Name
},
_sortBy.Key, _descending);
var keep = vehicleList.SelectedItem;
vehicleList.ItemsSource = list;
if (keep is VehicleBaseRow row && list.Contains(row))
vehicleList.SelectedItem = row;
emptyText.IsVisible = list.Count == 0;
statusText.Text = string.Format(App.Loc["TextureBaseCount"], list.Count, _all.Count(Offered));
}
private void Head_OnTapped(object? sender, TappedEventArgs e)
{
var column = _columns.First(c => ReferenceEquals(c.Head, sender));
_descending = ReferenceEquals(column, _sortBy) && !_descending;
_sortBy = column;
ShowSort();
Refresh();
}
private void ShowSort()
{
foreach (var column in _columns)
column.Head.Text = App.Loc[column.LocKey] +
(!ReferenceEquals(column, _sortBy) ? "" : _descending ? " ▼" : " ▲");
}
private void ClearButton_OnClick(object? sender, RoutedEventArgs e)
{
_clearing = true;
foreach (var box in _filters)
box.Text = "";
categoryList.SelectedIndex = 0;
classList.SelectedItem = null;
_clearing = false;
Refresh();
}
private void HideArchivalCheck_OnChanged(object? sender, RoutedEventArgs e)
{
AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
AppServices.Current.SettingsStore.Save();
FillClasses();
Refresh();
}
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => ShowSelected();
private void ShowSelected()
{
var row = vehicleList.SelectedItem as VehicleBaseRow;
replaceButton.IsEnabled = row is not null;
addButton.IsEnabled = row is not null;
if (row is null)
{
preview.Source = null;
detailTitle.Text = "";
detailText.Text = App.Loc["TextureBasePick"];
ToolTip.SetTip(detailText, null);
return;
}
preview.Source = _minis.Get(_db.ResolveMiniName(row.Texture), PreviewHeight);
detailTitle.Text = string.Join(" · ", new[] { row.Skin, row.Class, row.Operator }.Where(s => s.Length > 0));
var lines = new[]
{
(App.Loc["Model"], row.Model), (App.Loc["TexStation"], row.Station),
(App.Loc["TexRevision"], row.Revision), (App.Loc["TexPhoto"], row.Photo)
}
.Where(p => p.Item2.Length > 0)
.Select(p => $"{p.Item1}: {p.Item2}")
.ToList();
detailText.Text = string.Join(" · ", lines);
ToolTip.SetTip(detailText, string.Join("\n", lines));
}
private void Take(VehicleBaseRow row, bool add)
{
Picked = row.Texture;
PickedToAdd = add;
}
/// <returns>Whether there was one selected to take.</returns>
private bool TakeSelected(bool add)
{
if (vehicleList.SelectedItem is not VehicleBaseRow row) return false;
Take(row, add);
Close();
return true;
}
private void ReplaceButton_OnClick(object? sender, RoutedEventArgs e) => TakeSelected(add: false);
private void AddButton_OnClick(object? sender, RoutedEventArgs e) => TakeSelected(add: true);
private void Window_OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Escape) return;
Close();
e.Handled = true;
}
private void VehicleList_OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && TakeSelected(add: false))
e.Handled = true;
}
private void VehicleList_OnDoubleTapped(object? sender, TappedEventArgs e)
{
if (vehicleList.SelectedItem is VehicleBaseRow row)
Take(row, add: false);
}
// DoubleTapped is raised from the second press, while the list still holds the
// pointer. Closing there leaves the capture on a dead window and on Windows the
// owner stops taking clicks. The release ends the gesture and drops the capture,
// so that is where the window goes.
private void VehicleList_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (Picked != null)
Close();
}
private void VehicleList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (vehicleList.SelectedItem is not VehicleBaseRow row) return;
var menu = new ContextMenu();
void Pick(string locKey, bool add) => menu.AddItem(App.Loc[locKey], () =>
{
Take(row, add);
Dispatcher.UIThread.Post(Close);
});
Pick("ReplaceVehicle", add: false);
Pick("AddVehicle", add: true);
menu.Items.Add(new Separator());
TextureMenuItems.Add(menu, vehicleList, row.Texture);
menu.Open(vehicleList);
e.Handled = true;
}
}