diff --git a/StarterNG.Tests/CargoTests.cs b/StarterNG.Tests/CargoTests.cs new file mode 100644 index 0000000..5d39ef9 --- /dev/null +++ b/StarterNG.Tests/CargoTests.cs @@ -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 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 { new() { Cars = new List { 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); + } +} diff --git a/StarterNG.Tests/ConsistCrewTests.cs b/StarterNG.Tests/ConsistCrewTests.cs index 5e6dcd5..fcd7101 100644 --- a/StarterNG.Tests/ConsistCrewTests.cs +++ b/StarterNG.Tests/ConsistCrewTests.cs @@ -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 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(), 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(), 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] diff --git a/StarterNG.Tests/ConsistHistoryTests.cs b/StarterNG.Tests/ConsistHistoryTests.cs new file mode 100644 index 0000000..29f497a --- /dev/null +++ b/StarterNG.Tests/ConsistHistoryTests.cs @@ -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 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)); + } +} diff --git a/StarterNG.Tests/ConsistOrientationTests.cs b/StarterNG.Tests/ConsistOrientationTests.cs index 42abd82..558cd37 100644 --- a/StarterNG.Tests/ConsistOrientationTests.cs +++ b/StarterNG.Tests/ConsistOrientationTests.cs @@ -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)); diff --git a/StarterNG.Tests/ConsistTailTests.cs b/StarterNG.Tests/ConsistTailTests.cs index 1da3fe2..8924518 100644 --- a/StarterNG.Tests/ConsistTailTests.cs +++ b/StarterNG.Tests/ConsistTailTests.cs @@ -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() { diff --git a/StarterNG.Tests/PhysicsAndAssetsTests.cs b/StarterNG.Tests/PhysicsAndAssetsTests.cs index 1d38874..4661e24 100644 --- a/StarterNG.Tests/PhysicsAndAssetsTests.cs +++ b/StarterNG.Tests/PhysicsAndAssetsTests.cs @@ -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() { diff --git a/StarterNG.Tests/RevisionSpanTests.cs b/StarterNG.Tests/RevisionSpanTests.cs new file mode 100644 index 0000000..c6bf08c --- /dev/null +++ b/StarterNG.Tests/RevisionSpanTests.cs @@ -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)); +} diff --git a/StarterNG.Tests/TexRandomizerTests.cs b/StarterNG.Tests/TexRandomizerTests.cs new file mode 100644 index 0000000..7d01816 --- /dev/null +++ b/StarterNG.Tests/TexRandomizerTests.cs @@ -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; + } + + /// The randomizer with the next pick in line always the other livery of two. + 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); + } +} diff --git a/StarterNG.Tests/TrainsetDisplayTests.cs b/StarterNG.Tests/TrainsetDisplayTests.cs new file mode 100644 index 0000000..fdadd3b --- /dev/null +++ b/StarterNG.Tests/TrainsetDisplayTests.cs @@ -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)); +} diff --git a/StarterNG.Tests/VehicleBaseQueryTests.cs b/StarterNG.Tests/VehicleBaseQueryTests.cs new file mode 100644 index 0000000..3c0f87e --- /dev/null +++ b/StarterNG.Tests/VehicleBaseQueryTests.cs @@ -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 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 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)); + } +} diff --git a/StarterNG.Tests/WarehouseEditTests.cs b/StarterNG.Tests/WarehouseEditTests.cs new file mode 100644 index 0000000..63cdcd5 --- /dev/null +++ b/StarterNG.Tests/WarehouseEditTests.cs @@ -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"))); + } +} diff --git a/StarterNG/Application/WarehouseEdit.cs b/StarterNG/Application/WarehouseEdit.cs new file mode 100644 index 0000000..04d5a34 --- /dev/null +++ b/StarterNG/Application/WarehouseEdit.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using StarterNG.Classes; + +namespace StarterNG.Application; + +/// +/// 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. +/// +public sealed class WarehouseEdit +{ + /// One trainset per warehouse consist, so its undo history outlives a switch. + private readonly Dictionary _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; + } +} diff --git a/StarterNG/Domain/Cargo.cs b/StarterNG/Domain/Cargo.cs index 0b2c76a..7302528 100644 --- a/StarterNG/Domain/Cargo.cs +++ b/StarterNG/Domain/Cargo.cs @@ -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 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); } diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs index 7112afd..b3aa293 100644 --- a/StarterNG/Domain/Consist.cs +++ b/StarterNG/Domain/Consist.cs @@ -52,12 +52,21 @@ public sealed class Consist : IReadOnlyList public void Raise() => Changed?.Invoke(); - public void LoadFrom(Trainset trainset) + /// The unit to select, by position; the first one otherwise. + 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 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 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 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 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 }); i++; } + } - Selected = _items.FirstOrDefault(); - SyncStartingVehicle(); + /// + /// A unit from cars in the order they run. A turned unit keeps its own order + /// reversed, which undoes - kept as it runs, its cars + /// would swap ends with every load. + /// + private static ConsistItem Unit(List running) + { + bool flipped = running[0].Offset < 0; + if (flipped) + running.Reverse(); + return new ConsistItem + { + Cars = running, + Grouped = true, + Flipped = flipped, + Driver = UnitDriver(running) + }; + } + + /// Nothing to edit: the strip goes empty until a consist is loaded again. + public void Unload() + { + EditingTrainset = null; + _items.Clear(); + Selected = null; + SelectedCar = null; Raise(); } @@ -161,16 +177,49 @@ public sealed class Consist : IReadOnlyList public static IEnumerable RunningOrder(ConsistItem item) => item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars; + /// + /// 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. + /// 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; } + /// + /// 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. + /// + public static string? StartingVehicle(IEnumerable 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 /// /// 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. /// private void HandOverLead(ConsistItem previousHead) { @@ -350,11 +398,55 @@ public sealed class Consist : IReadOnlyList 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); + } + + /// + /// 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. + /// + public static List LoadedOver(IReadOnlyList before, List 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(); + } + + /// Vehicles joining a consist that has its crew come without their own. + public static List Joined(IEnumerable before, IEnumerable 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 public static bool HoldsTail(ConsistItem item) => item.Cars.Count > 0 && HoldsNext(TailCar(item)); + /// Whether the unit at that place can join a neighbour: one it holds or may form a unit with, or one holding it. + 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; diff --git a/StarterNG/Domain/ConsistHistory.cs b/StarterNG/Domain/ConsistHistory.cs new file mode 100644 index 0000000..ababe1b --- /dev/null +++ b/StarterNG/Domain/ConsistHistory.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using StarterNG.Classes; + +namespace StarterNG.Domain; + +/// +/// 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. +/// +public sealed class ConsistHistory +{ + private const int Depth = 100; + + public sealed record State(IReadOnlyList Vehicles, string Signature, int Selected); + + private sealed class Track + { + public State? Last; + public readonly List Undo = new(); + public readonly Stack Redo = new(); + } + + private readonly ConditionalWeakTable _tracks = new(); + + /// + /// Notes what the trainset holds now. With it only + /// becomes the state undo returns to - for the redraw after an undo itself. + /// + 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; + + /// The state before the last change, as fresh copies to put into the trainset. + 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() }; +} diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs index 23b4206..c408474 100644 --- a/StarterNG/Domain/TexRandomizer.cs +++ b/StarterNG/Domain/TexRandomizer.cs @@ -99,18 +99,49 @@ public sealed class TexRandomizer var set = _db.ResolveSet(pick); var unit = set ?? new List { 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) diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs index 1877da9..3399d2e 100644 --- a/StarterNG/Domain/TrainsetDisplay.cs +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -17,6 +17,27 @@ public static class TrainsetDisplay trainset.Vehicles.Count == 0 || trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile)); + /// + /// 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. + /// + public static bool ListedAsDrivable(Trainset trainset, Trainset? current) => + UData.StaffedTrain(trainset) || ReferenceEquals(trainset, current) || SceneryTrainsetText.IsModified(trainset); + + /// A consist the simulator drives itself: its description starts with a dash. + public static bool IsAi(Trainset trainset) => + !string.IsNullOrEmpty(trainset.Description) && + trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal); + + /// Whether a list of the scenery's consists shows it, under the two filters those lists have. + public static bool ListedIn(Trainset trainset, bool showAi, bool drivableOnly, Trainset? current) => + (showAi || !IsAi(trainset)) && (!drivableOnly || ListedAsDrivable(trainset, current)); + + /// The consist's row in those lists: what it is made of, or the track of one with nothing on it. + public static string ListLabel(Trainset trainset) => + CompositionText(trainset) ?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? ""); + public static string? CompositionText(Trainset trainset) { if (IsEmpty(trainset)) diff --git a/StarterNG/Domain/UDepot.cs b/StarterNG/Domain/UDepot.cs index 36ee242..bddc7d6 100644 --- a/StarterNG/Domain/UDepot.cs +++ b/StarterNG/Domain/UDepot.cs @@ -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(); diff --git a/StarterNG/Domain/Vehicles/RevisionSpan.cs b/StarterNG/Domain/Vehicles/RevisionSpan.cs new file mode 100644 index 0000000..cc7b856 --- /dev/null +++ b/StarterNG/Domain/Vehicles/RevisionSpan.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; + +namespace StarterNG.Domain.Vehicles; + +/// +/// 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. +/// +public readonly record struct RevisionSpan(DateTime From, DateTime To) +{ + /// Also reads what is typed to filter by: 2016, 2016-12, 2016-12-06, 12.2016. + 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); +} diff --git a/StarterNG/Infrastructure/Shortcuts.cs b/StarterNG/Infrastructure/Shortcuts.cs index 2791347..0dae3f0 100644 --- a/StarterNG/Infrastructure/Shortcuts.cs +++ b/StarterNG/Infrastructure/Shortcuts.cs @@ -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 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), diff --git a/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs index 36a5a2c..bd1ed2b 100644 --- a/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs +++ b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs @@ -29,8 +29,11 @@ public sealed class FizPhysicsRepository : IPhysicsRepository private readonly ConcurrentDictionary _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 ByFolderAndName, Dictionary ByName); + private readonly object _indexGate = new(); - private Dictionary? _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}"; + + /// A vehicle's folder under dynamic, however the caller spells it. + 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 Index() + private FizIndex Index() { lock (_indexGate) { if (_index is not null) return _index; - var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byFolder = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byName = new Dictionary(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; } } diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs index 986a55c..1957258 100644 --- a/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs @@ -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 /// public IEnumerable 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 Sorted(IEnumerable 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 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(); + } + } /// Reads one textures.txt; a file that will not parse is logged and skipped. public VehicleEntry? Read(string path) diff --git a/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs b/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs index 3e83ece..c7f6993 100644 --- a/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs +++ b/StarterNG/Infrastructure/Vehicles/VehicleCacheRepository.cs @@ -43,21 +43,10 @@ public sealed class VehicleCacheRepository : IVehicleRepository _directory = directory; } - /// What the last load did, for the developer tab to report. - public VehicleCacheStatus Status { get; private set; } = VehicleCacheStatus.None; - - public int Load(VehicleCatalog catalog) => Load(catalog, useCopy: true); - - /// Throws the copy away and reads the installation again. - 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. /// - 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? Cached(string stamp) @@ -179,12 +153,3 @@ public sealed class VehicleCacheRepository : IVehicleRepository private string SourcesPath => Path.Combine(_directory, SourcesFileName); } - -/// -/// 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. -/// -public sealed record VehicleCacheStatus(bool ServedFromCopy, int Sources, DateTime? Written, string Directory) -{ - public static readonly VehicleCacheStatus None = new(false, 0, null, ""); -} diff --git a/StarterNG/Views/ConsistContextMenu.cs b/StarterNG/Views/ConsistContextMenu.cs index bb98eda..471e016 100644 --- a/StarterNG/Views/ConsistContextMenu.cs +++ b/StarterNG/Views/ConsistContextMenu.cs @@ -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 { + /// A scenery consist's own file: written for good, or read back. + public sealed record SceneryFileActions(Func IsModified, Action Save, Action Revert); public static ContextMenu Build( Control owner, @@ -22,52 +28,60 @@ public static class ConsistContextMenu Func? getVehicle = null, Action? 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 getTarget, Action> applyVehicles, Action? removeAll) + Func getTarget, Action> 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(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> applyVehicles) + /// Whether a consist may go into the warehouse under name, written over one it has - asked only then. + public static async Task 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); + /// Whether a warehouse consist may go for good, which this asks first. + public static async Task 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 += (_, _) => + /// Vehicles about to replace those of target, with the kind of crew target has. + public static List KeepCrew(Trainset? target, List 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> 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) + /// A word next to the control, gone on its own after a moment. + 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)); + } + + /// Asks for a name and puts the consist into the warehouse; gets the name it went in under. + public static void ShowSaveFlyout(Control owner, Trainset? target, bool atPointer = true, Action? 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; } diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml index 0c92ce4..ea8afe1 100644 --- a/StarterNG/Views/Depot.axaml +++ b/StarterNG/Views/Depot.axaml @@ -8,6 +8,25 @@ x:CompileBindings="True" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700" x:Class="StarterNG.Views.Depot"> + + + + + @@ -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. --> - - + + @@ -68,9 +90,10 @@ DoubleTapped="VehicleListBox_OnDoubleTapped" PointerPressed="VehicleListBox_OnPointerPressed" /> - - + + - + + + + + + + + + + + + + + + + diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index af9f5fe..5d3efe9 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -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(); + + /// Set while an undo or redo puts a state back, which is not a change of its own. + 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 _leftVehicles = new(); + + private void RememberConsistOnLeaving() + { + _leftTrainset = AppServices.Current.State.CurrentTrainset; + _leftVehicles = _leftTrainset?.Vehicles.ToList() ?? new List(); + } + + /// Another consist was picked or this one changed on another page: the warehouse tab left open would hide it. + 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(); + } + + /// The strip takes the warehouse consist selected, or goes empty without one. + 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); + } + + /// Back on the scenery tab, the strip edits the consist that will be started. + 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; + } + + /// From the warehouse's menu: shown where it went, on the scenery tab. + private void ApplyFromWarehouse(Trainset target, List 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); + /// The scenery files changed under the page in view; out of view it catches up on its way back. + 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(); + } + } + + /// + /// 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. + /// + 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(); } - /// Redraws the consist cards, e.g. after the thumbnail size changed. /// Redraws what depends on the thumbnail size: the consist and the class list. 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(); + } + + /// + /// The title says which consist the strip edits; the scenery file's tools and + /// "add to warehouse" are for a scenery consist only. + /// + 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); + } + + /// + /// 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. + /// + 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(); + } + + /// The consist as its scenery file has it - one more step to undo. + 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 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 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(); + EditTrainset(target); + } + + /// + /// A scenery consist becomes the one started as well; a warehouse consist is + /// only edited. + /// + 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) + /// The composition, after a dot for a consist the scenery file does not have yet. + 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(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(); + + /// Keyboard shortcut entry points. See . + public void KeyAddVehicle() { if (_browser.Selected != null) AddTexture(_browser.Selected); } - /// Keyboard shortcut entry points. See . - 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) + /// What the browser lists, for a tool to add; with nothing listed it says what to pick. + private List 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(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(); + 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 /// The vehicle count times in a row from at - each staffed as added - and one redraw. 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 { 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); } } diff --git a/StarterNG/Views/Depot/ConsistDragging.cs b/StarterNG/Views/Depot/ConsistDragging.cs index 04711f2..181e29d 100644 --- a/StarterNG/Views/Depot/ConsistDragging.cs +++ b/StarterNG/Views/Depot/ConsistDragging.cs @@ -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; diff --git a/StarterNG/Views/Depot/CouplingBits.cs b/StarterNG/Views/Depot/CouplingBits.cs index 3876c7d..253163f 100644 --- a/StarterNG/Views/Depot/CouplingBits.cs +++ b/StarterNG/Views/Depot/CouplingBits.cs @@ -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); + + /// The crew in that place of the list; nobody for no place at all, as a list with nothing selected has. + public static eDriverType At(int index) => index >= 0 && index < All.Length ? All[index].Type : eDriverType.Nobody; +} diff --git a/StarterNG/Views/Depot/TextureMenuItems.cs b/StarterNG/Views/Depot/TextureMenuItems.cs new file mode 100644 index 0000000..b54810e --- /dev/null +++ b/StarterNG/Views/Depot/TextureMenuItems.cs @@ -0,0 +1,29 @@ +using System.IO; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using StarterNG.Domain; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Views; + +/// What the lists of liveries offer about a livery itself. +internal static class TextureMenuItems +{ + /// A control of the window, for its clipboard. + 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); + } +} diff --git a/StarterNG/Views/Depot/TextureRandomizerDialog.cs b/StarterNG/Views/Depot/TextureRandomizerDialog.cs index 4550ecb..ec9efd8 100644 --- a/StarterNG/Views/Depot/TextureRandomizerDialog.cs +++ b/StarterNG/Views/Depot/TextureRandomizerDialog.cs @@ -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; diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs index b597cfd..31e4ae6 100644 --- a/StarterNG/Views/Depot/VehicleBrowser.cs +++ b/StarterNG/Views/Depot/VehicleBrowser.cs @@ -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 /// Adds the vehicle to the consist the given number of times. public Action? AddMany { get; set; } + /// Puts the vehicle in place of the one selected in the consist. + public Action? 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 Match)[] CategoryDefs = + internal static readonly (string LocKey, Func Match)[] CategoryDefs = { ("CatElectricLoco", c => c == "e"), ("CatDieselLoco", c => c == "s"), @@ -202,10 +201,10 @@ public sealed class VehicleBrowser !(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived); /// - /// 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. /// public void InitClassComboTemplates() { @@ -213,7 +212,7 @@ public sealed class VehicleBrowser classCombo.ItemTemplate = new FuncDataTemplate( (cls, _) => ClassComboContent(cls, height, stacked: true), false); classCombo.SelectionBoxItemTemplate = new FuncDataTemplate( - (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; } /// - /// 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. /// /// /// Built from the same 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? 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) diff --git a/StarterNG/Views/Depot/VehicleCards.cs b/StarterNG/Views/Depot/VehicleCards.cs index 5d9c0ad..cf8929c 100644 --- a/StarterNG/Views/Depot/VehicleCards.cs +++ b/StarterNG/Views/Depot/VehicleCards.cs @@ -36,6 +36,11 @@ public sealed class VehicleCards /// Puts copies of the vehicle on a card right after it, the given number of times. public Action? AddCopies { get; set; } + /// The livery picked in the vehicle browser, offered as the card's replacement. + public Func? BrowserSelection { get; set; } + + public Action? 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 /// 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)); } + /// + /// 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. + /// 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; } diff --git a/StarterNG/Views/Depot/VehicleDetails.cs b/StarterNG/Views/Depot/VehicleDetails.cs index c170b7a..b87cb2a 100644 --- a/StarterNG/Views/Depot/VehicleDetails.cs +++ b/StarterNG/Views/Depot/VehicleDetails.cs @@ -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 _showTextureInfo; + /// A setting changed on the cars themselves, which redraws nothing. + 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 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. diff --git a/StarterNG/Views/Depot/Warehouse.cs b/StarterNG/Views/Depot/Warehouse.cs index fd4b577..fb4269b 100644 --- a/StarterNG/Views/Depot/Warehouse.cs +++ b/StarterNG/Views/Depot/Warehouse.cs @@ -11,6 +11,10 @@ using StarterNG.Classes; namespace StarterNG.Views; +/// +/// 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. +/// public sealed class Warehouse { private readonly ListBox _list; @@ -19,6 +23,16 @@ public sealed class Warehouse private readonly Func _target; private readonly Action> _apply; + /// Set while the list is rebuilt, which clears and restores the selection. + private bool _refreshing; + + /// Another consist is selected, or none - not raised mid-rebuild. + public event Action? SelectionChanged; + + /// A consist got a new name: the old one, then the new one. + public event Action? Renamed; + + /// The scenery consist the menu's actions put a warehouse consist into. public Warehouse(ListBox list, Button removeButton, Cursor hand, Func target, Action> apply) { @@ -27,11 +41,20 @@ public sealed class Warehouse _hand = hand; _target = target; _apply = apply; + + _list.SelectionChanged += (_, _) => + { + if (!_refreshing) + SelectionChanged?.Invoke(); + }; } - public void Refresh() + /// The consist to select; the one selected now otherwise. + 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(); - } + /// Asks for a name at the button pressed, and says where the consist went. + 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(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 diff --git a/StarterNG/Views/MenuItems.cs b/StarterNG/Views/MenuItems.cs new file mode 100644 index 0000000..4e83816 --- /dev/null +++ b/StarterNG/Views/MenuItems.cs @@ -0,0 +1,26 @@ +using System; +using Avalonia.Controls; +using Avalonia.Input; + +namespace StarterNG.Views; + +/// A menu entry that runs something when clicked, as nearly all of them do. +internal static class MenuItems +{ + /// The text, or a control where a row holds more than that. + 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; + } + + /// The entry at the end of a menu or a submenu. + 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; + } +} diff --git a/StarterNG/Views/TextureBaseWindow.axaml b/StarterNG/Views/TextureBaseWindow.axaml deleted file mode 100644 index b6bf1d5..0000000 --- a/StarterNG/Views/TextureBaseWindow.axaml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StarterNG/Views/TextureBaseWindow.axaml.cs b/StarterNG/Views/TextureBaseWindow.axaml.cs deleted file mode 100644 index c697356..0000000 --- a/StarterNG/Views/TextureBaseWindow.axaml.cs +++ /dev/null @@ -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 _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 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 Filter( - IEnumerable src, CheckBox? on, TextBox? box, Func 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; - } -} diff --git a/StarterNG/Views/VehicleBaseQuery.cs b/StarterNG/Views/VehicleBaseQuery.cs new file mode 100644 index 0000000..e2cfc81 --- /dev/null +++ b/StarterNG/Views/VehicleBaseQuery.cs @@ -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; } + + /// Sorts as text in the order of the dates; empty for a date not known. + 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; +} + +/// What is typed and picked to look through the vehicle base with. +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; } + + /// The class picked from the list, which has to match whole. + public string? PickedClass { get; init; } +} + +public static class VehicleBaseQuery +{ + private static readonly StringComparer Order = StringComparer.CurrentCultureIgnoreCase; + + public static List ClassCounts(IEnumerable 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 Select(IEnumerable rows, VehicleBaseFilters filters, + Func 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 Filter( + IEnumerable rows, string? typed, Func field) + { + string needle = typed?.Trim() ?? ""; + return needle.Length == 0 + ? rows + : rows.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/StarterNG/Views/VehicleBaseWindow.axaml b/StarterNG/Views/VehicleBaseWindow.axaml new file mode 100644 index 0000000..e340d07 --- /dev/null +++ b/StarterNG/Views/VehicleBaseWindow.axaml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +