mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Rework the depot, warehouse and vehicle base
A consist added to the warehouse gets a name there. The crew type stays through loading a consist. The vehicle base window replaces the texture base. The depot follows consists changed on the scenarios page, also with the warehouse tab left open.
This commit is contained in:
149
StarterNG.Tests/CargoTests.cs
Normal file
149
StarterNG.Tests/CargoTests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class CargoTests
|
||||
{
|
||||
private readonly Consist _consist;
|
||||
private readonly VehicleCatalog _catalog;
|
||||
private readonly VehicleInfo _info;
|
||||
|
||||
public CargoTests()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"), "!=b\nen57-a.mat=en57,EN57")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "en57.fiz"),
|
||||
"Load: LoadAccepted=Passengers,Pantstate MaxLoad=50")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "textures.txt"), "!=b\ntram-a.mat=tram,TRAM")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "tram.fiz"),
|
||||
"Load: LoadAccepted=pantstate,Passengers MaxLoad=52")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "textures.txt"), "!=b\ns1-a.mat=s1,S1")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "s1.fiz"), "Load: LoadAccepted=Coal,Sand"));
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
|
||||
_catalog = installation.Library.Vehicles;
|
||||
_info = new VehicleInfo(_catalog, installation.Physics);
|
||||
_consist = new Consist(_catalog, _info);
|
||||
}
|
||||
|
||||
private (Dynamic Car, List<ConsistItem> Consist) Wagon(string skin, string? type = null, int count = 0)
|
||||
{
|
||||
var car = _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
|
||||
car.LoadType = type;
|
||||
car.LoadCount = count;
|
||||
return (car, new List<ConsistItem> { new() { Cars = new List<Dynamic> { car } } });
|
||||
}
|
||||
|
||||
private Cargo Picking(int index) => new(_info, new Pick(index));
|
||||
|
||||
private sealed class Pick : Random
|
||||
{
|
||||
private readonly int _index;
|
||||
|
||||
public Pick(int index) => _index = index;
|
||||
|
||||
public override int Next(int maxValue) => _index;
|
||||
|
||||
public override int Next(int minValue, int maxValue) => maxValue - 1;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_type_takes_no_more_than_it_holds()
|
||||
{
|
||||
var (car, consist) = Wagon("en57-a", "Passengers", 50);
|
||||
|
||||
Picking(1).RandomTypes(consist);
|
||||
|
||||
Assert.Equal("Pantstate", car.LoadType);
|
||||
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
|
||||
Assert.Contains(" 3 Pantstate ", car.ToTrainsetNode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_type_leaves_a_count_it_can_hold()
|
||||
{
|
||||
var (car, consist) = Wagon("en57-a", "Pantstate", 2);
|
||||
|
||||
Picking(0).RandomTypes(consist);
|
||||
|
||||
Assert.Equal("Passengers", car.LoadType);
|
||||
Assert.Equal(2, car.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_type_with_no_limit_declared_keeps_the_count()
|
||||
{
|
||||
// MaxLoad unset: nothing to hold it to.
|
||||
var (car, consist) = Wagon("s1-a", "Coal", 40);
|
||||
|
||||
Picking(1).RandomTypes(consist);
|
||||
|
||||
Assert.Equal("Sand", car.LoadType);
|
||||
Assert.Equal(40, car.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filling_an_empty_car_goes_by_the_type_it_is_given()
|
||||
{
|
||||
// Its first accepted type is pantographs: the 52 of the .fiz is not theirs.
|
||||
var (car, _) = Wagon("tram-a");
|
||||
|
||||
Picking(0).FillToMax(car);
|
||||
|
||||
Assert.Equal("pantstate", car.LoadType);
|
||||
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_amount_for_an_empty_car_goes_by_the_type_it_is_given()
|
||||
{
|
||||
var (car, consist) = Wagon("tram-a");
|
||||
|
||||
Picking(0).RandomAmounts(consist);
|
||||
|
||||
Assert.Equal("pantstate", car.LoadType);
|
||||
Assert.InRange(car.LoadCount, 1, Dynamic.PantStateMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_count_passed_on_keeps_where_the_next_car_declares_no_limit()
|
||||
{
|
||||
// MaxLoad unset: nothing to hold it to, as when taken from the car before.
|
||||
var (_, consist) = Wagon("s1-a", "Coal", 40);
|
||||
var (next, following) = Wagon("s1-a");
|
||||
consist.AddRange(following);
|
||||
|
||||
Picking(0).CopyToFollowing(consist, consist[0]);
|
||||
|
||||
Assert.Equal("Coal", next.LoadType);
|
||||
Assert.Equal(40, next.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_count_passed_on_is_held_to_what_the_next_car_declares()
|
||||
{
|
||||
var (_, consist) = Wagon("tram-a", "Passengers", 52);
|
||||
var (next, following) = Wagon("en57-a");
|
||||
consist.AddRange(following);
|
||||
|
||||
Picking(0).CopyToFollowing(consist, consist[0]);
|
||||
|
||||
Assert.Equal("Passengers", next.LoadType);
|
||||
Assert.Equal(50, next.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_car_with_nothing_declared_stays_untouched()
|
||||
{
|
||||
var (car, _) = Wagon("en57-a");
|
||||
car.MaxLoad = 0;
|
||||
|
||||
Picking(0).FillToMax(car);
|
||||
|
||||
Assert.Null(car.LoadType);
|
||||
Assert.Equal(0, car.LoadCount);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public class ConsistCrewTests
|
||||
{
|
||||
private readonly Consist _consist;
|
||||
private readonly VehicleCatalog _catalog;
|
||||
private readonly VehicleInfo _info;
|
||||
|
||||
public ConsistCrewTests()
|
||||
{
|
||||
@@ -20,7 +21,129 @@ public class ConsistCrewTests
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
|
||||
_catalog = installation.Library.Vehicles;
|
||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||
_info = new VehicleInfo(_catalog, installation.Physics);
|
||||
_consist = new Consist(_catalog, _info);
|
||||
}
|
||||
|
||||
private List<Dynamic> Cars(params string[] skins) =>
|
||||
skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Reardriver)]
|
||||
[InlineData(eDriverType.Passenger)]
|
||||
public void A_loaded_consist_keeps_the_kind_of_crew(eDriverType crew)
|
||||
{
|
||||
var before = Cars("ep07-001", "b111-01");
|
||||
before[0].DriverType = crew;
|
||||
var loaded = Cars("ep07-002", "b111-01");
|
||||
loaded[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.LoadedOver(before, loaded, _catalog, _info);
|
||||
|
||||
Assert.Equal(crew, cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_with_nobody_in_it_stays_so_after_loading()
|
||||
{
|
||||
var loaded = Cars("ep07-002");
|
||||
loaded[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.LoadedOver(Cars("ep07-001"), loaded, _catalog, _info);
|
||||
|
||||
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_does_not_go_into_loaded_wagons()
|
||||
{
|
||||
var before = Cars("ep07-001");
|
||||
before[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.LoadedOver(before, Cars("b111-01"), _catalog, _info);
|
||||
|
||||
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loaded_over_an_empty_consist_the_vehicles_keep_their_own_crew()
|
||||
{
|
||||
var loaded = Cars("ep07-002");
|
||||
loaded[0].DriverType = eDriverType.Reardriver;
|
||||
|
||||
Assert.Equal(eDriverType.Reardriver,
|
||||
Consist.LoadedOver(new List<Dynamic>(), loaded, _catalog, _info)[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vehicles_joining_a_consist_come_without_their_own_crew()
|
||||
{
|
||||
var before = Cars("ep07-001");
|
||||
before[0].DriverType = eDriverType.Headdriver;
|
||||
var added = Cars("ep07-002");
|
||||
added[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.Joined(before, added);
|
||||
|
||||
Assert.Equal(eDriverType.Headdriver, cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_locomotive_moved_to_the_head_takes_the_driver_in_the_same_cab()
|
||||
{
|
||||
var first = Add("ep07-001", 0);
|
||||
var second = Add("ep07-002", 1);
|
||||
first.Driver = eDriverType.Reardriver;
|
||||
first.Cars[0].DriverType = eDriverType.Reardriver;
|
||||
|
||||
_consist.Move(1, 0);
|
||||
|
||||
Assert.Equal(eDriverType.Reardriver, second.Cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vehicles_joining_an_empty_consist_keep_their_crew()
|
||||
{
|
||||
var added = Cars("ep07-002");
|
||||
added[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
Assert.Equal(eDriverType.Headdriver, Consist.Joined(new List<Dynamic>(), added)[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_wagon_keeps_the_locomotive_as_the_starting_vehicle()
|
||||
{
|
||||
var loco = Add("ep07-001", 0);
|
||||
var wagon = Add("b111-01", 1);
|
||||
loco.Cars[0].Name = "loco";
|
||||
wagon.Cars[0].Name = "wagon";
|
||||
|
||||
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "loco"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_wagon_selected_after_a_stale_pick_falls_back_to_the_driver()
|
||||
{
|
||||
var loco = Add("ep07-001", 0);
|
||||
var wagon = Add("b111-01", 1);
|
||||
loco.Cars[0].Name = "loco";
|
||||
|
||||
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "gone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_crewed_unit_makes_it_the_starting_vehicle()
|
||||
{
|
||||
var first = Add("ep07-001", 0);
|
||||
var wagon = Add("b111-01", 1);
|
||||
first.Cars[0].Name = "loco";
|
||||
wagon.Cars[0].Name = "wagon";
|
||||
wagon.Cars[0].DriverType = eDriverType.Passenger;
|
||||
|
||||
Assert.Equal("wagon", Consist.StartingVehicle(_consist, wagon, "loco"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
98
StarterNG.Tests/ConsistHistoryTests.cs
Normal file
98
StarterNG.Tests/ConsistHistoryTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class ConsistHistoryTests
|
||||
{
|
||||
private static Dynamic Car(string name) => new()
|
||||
{
|
||||
Name = name, DataFolder = "pkp", SkinFile = name, MmdFile = name, PathName = ""
|
||||
};
|
||||
|
||||
private static Trainset With(params string[] names) =>
|
||||
new("trainset none none 0 0\nendtrainset") { Vehicles = names.Select(Car).ToList() };
|
||||
|
||||
private static string[] Names(IEnumerable<Dynamic> vehicles) => vehicles.Select(v => v.Name).ToArray();
|
||||
|
||||
[Fact]
|
||||
public void A_redraw_without_a_change_leaves_nothing_to_undo()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a", "b");
|
||||
|
||||
history.Record(trainset, 0);
|
||||
history.Record(trainset, 1);
|
||||
|
||||
Assert.False(history.CanUndo(trainset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_goes_back_one_change_and_redo_forward_again()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a");
|
||||
history.Record(trainset, 0);
|
||||
trainset.Vehicles.Add(Car("b"));
|
||||
history.Record(trainset, 1);
|
||||
|
||||
var back = history.Undo(trainset, 1);
|
||||
|
||||
Assert.Equal(new[] { "a" }, Names(back!.Vehicles));
|
||||
Assert.True(history.CanRedo(trainset));
|
||||
|
||||
trainset.Vehicles = back.Vehicles.ToList();
|
||||
history.Record(trainset, 0, rebase: true);
|
||||
var forward = history.Redo(trainset, 0);
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, Names(forward!.Vehicles));
|
||||
Assert.Equal(1, forward.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_edit_made_in_place_is_still_undone()
|
||||
{
|
||||
// The vehicles are shared with the editor, so a change to one of them must
|
||||
// not reach the state kept for undo.
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a");
|
||||
history.Record(trainset, 0);
|
||||
|
||||
trainset.Vehicles[0].DriverType = eDriverType.Passenger;
|
||||
var back = history.Undo(trainset, 0);
|
||||
|
||||
Assert.NotEqual(eDriverType.Passenger, back!.Vehicles[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_change_drops_what_could_be_redone()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a");
|
||||
history.Record(trainset, 0);
|
||||
trainset.Vehicles.Add(Car("b"));
|
||||
history.Record(trainset, 1);
|
||||
trainset.Vehicles = history.Undo(trainset, 1)!.Vehicles.ToList();
|
||||
history.Record(trainset, 0, rebase: true);
|
||||
|
||||
trainset.Vehicles.Add(Car("c"));
|
||||
history.Record(trainset, 1);
|
||||
|
||||
Assert.False(history.CanRedo(trainset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_trainset_has_its_own_history()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var first = With("a");
|
||||
var second = With("x");
|
||||
history.Record(first, 0);
|
||||
first.Vehicles.Add(Car("b"));
|
||||
history.Record(first, 1);
|
||||
history.Record(second, 0);
|
||||
|
||||
Assert.True(history.CanUndo(first));
|
||||
Assert.False(history.CanUndo(second));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
47
StarterNG.Tests/RevisionSpanTests.cs
Normal file
47
StarterNG.Tests/RevisionSpanTests.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class RevisionSpanTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("14.01.2005", "2005-01-14", "2005-01-14")]
|
||||
[InlineData("06.12.2016", "2016-12-06", "2016-12-06")]
|
||||
[InlineData("xx.06.2008", "2008-06-01", "2008-06-30")]
|
||||
[InlineData("?.06.2008", "2008-06-01", "2008-06-30")]
|
||||
[InlineData("xx.xx.2011", "2011-01-01", "2011-12-31")]
|
||||
[InlineData("?.?.2011", "2011-01-01", "2011-12-31")]
|
||||
[InlineData("02.2016", "2016-02-01", "2016-02-29")]
|
||||
public void A_credit_line_date_is_as_exact_as_it_says(string text, string from, string to)
|
||||
{
|
||||
var span = RevisionSpan.Parse(text);
|
||||
|
||||
Assert.Equal(DateTime.Parse(from), span?.From);
|
||||
Assert.Equal(DateTime.Parse(to), span?.To);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2016", "2016-01-01", "2016-12-31")]
|
||||
[InlineData("2016-12", "2016-12-01", "2016-12-31")]
|
||||
[InlineData("2016-12-06", "2016-12-06", "2016-12-06")]
|
||||
[InlineData(" 2016-12- ", "2016-12-01", "2016-12-31")]
|
||||
public void What_is_typed_to_filter_by_reads_the_same_way(string text, string from, string to)
|
||||
{
|
||||
var span = RevisionSpan.Parse(text);
|
||||
|
||||
Assert.Equal(DateTime.Parse(from), span?.From);
|
||||
Assert.Equal(DateTime.Parse(to), span?.To);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("?")]
|
||||
[InlineData("xx.xx.xxxx")]
|
||||
[InlineData("31.02.2015")]
|
||||
[InlineData("01.13.2015")]
|
||||
[InlineData("VRZ-99_Kolowag_Septemwri")]
|
||||
[InlineData("201")]
|
||||
public void Anything_else_is_no_date(string? text) =>
|
||||
Assert.Null(RevisionSpan.Parse(text));
|
||||
}
|
||||
197
StarterNG.Tests/TexRandomizerTests.cs
Normal file
197
StarterNG.Tests/TexRandomizerTests.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class TexRandomizerTests
|
||||
{
|
||||
private readonly Consist _consist;
|
||||
private readonly VehicleCatalog _catalog;
|
||||
|
||||
public TexRandomizerTests()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
|
||||
"!=e\nep07-001.mat=ep07,EP07\nep07-002.mat=ep07,EP07")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"),
|
||||
"!=z\n^2\nen57-a.mat=en57a,EN57\nen57-b.mat=en57b,EN57\nen57-c.mat=en57a,EN57\nen57-d.mat=en57b,EN57")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "b111", "textures.txt"),
|
||||
"!=b\nb111-01.mat=b111,B111\nb111-02.mat=b111,B111"));
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
|
||||
_catalog = installation.Library.Vehicles;
|
||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||
}
|
||||
|
||||
private Dynamic Car(string skin) => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
|
||||
|
||||
private ConsistItem Put(params Dynamic[] cars)
|
||||
{
|
||||
var item = new ConsistItem
|
||||
{
|
||||
Cars = cars.ToList(),
|
||||
Grouped = cars.Length > 1,
|
||||
Driver = Consist.UnitDriver(cars)
|
||||
};
|
||||
_consist.Insert(_consist.Count, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>The randomizer with the next pick in line always the other livery of two.</summary>
|
||||
private int Randomize() => new TexRandomizer(_catalog, new SecondPick()).Apply(_consist, t => _consist.MakeDynamic(t, null));
|
||||
|
||||
private sealed class SecondPick : Random
|
||||
{
|
||||
public override int Next(int maxValue) => 1;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Headdriver)]
|
||||
[InlineData(eDriverType.Reardriver)]
|
||||
[InlineData(eDriverType.Passenger)]
|
||||
public void The_crew_stays_on_the_vehicle_that_takes_over(eDriverType crew)
|
||||
{
|
||||
var loco = Car("ep07-001");
|
||||
loco.DriverType = crew;
|
||||
Put(loco);
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
var car = Assert.Single(_consist[0].Cars);
|
||||
Assert.Equal("ep07-002", car.SkinFile);
|
||||
Assert.Equal(crew, car.DriverType);
|
||||
Assert.Equal(crew, _consist[0].Driver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_kept_is_what_the_export_writes()
|
||||
{
|
||||
var loco = Car("ep07-001");
|
||||
loco.DriverType = eDriverType.Headdriver;
|
||||
Put(loco);
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.Contains(" headdriver ", _consist[0].Cars[0].ToTrainsetNode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_locomotive_stays_empty()
|
||||
{
|
||||
// Consist.Replace would staff it; a driver of its own is not this one's to give.
|
||||
Put(Car("ep07-001"));
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.Equal(eDriverType.Nobody, _consist[0].Cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, _consist[0].Driver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_keeps_facing_the_way_it_did()
|
||||
{
|
||||
var loco = Car("ep07-001");
|
||||
loco.Offset = -1f;
|
||||
var item = Put(loco);
|
||||
item.Flipped = true;
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.True(_consist[0].Flipped);
|
||||
Assert.True(Consist.IsTurned(_consist[0].Cars[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_keeps_its_load()
|
||||
{
|
||||
var wagon = Car("b111-01");
|
||||
wagon.LoadType = "Passengers";
|
||||
wagon.LoadCount = 40;
|
||||
wagon.MaxLoad = 60;
|
||||
Put(wagon);
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
var car = _consist[0].Cars[0];
|
||||
Assert.Equal("b111-02", car.SkinFile);
|
||||
Assert.Equal("Passengers", car.LoadType);
|
||||
Assert.Equal(40, car.LoadCount);
|
||||
Assert.Equal(60, car.MaxLoad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_of_the_same_length_keeps_all_of_it_car_by_car()
|
||||
{
|
||||
var first = Car("en57-a");
|
||||
var second = Car("en57-b");
|
||||
second.DriverType = eDriverType.Reardriver;
|
||||
first.LoadType = "Passengers";
|
||||
first.LoadCount = 10;
|
||||
second.LoadType = "Passengers";
|
||||
second.LoadCount = 20;
|
||||
first.Offset = -1f;
|
||||
second.Offset = -1f;
|
||||
var item = Put(first, second);
|
||||
item.Flipped = true;
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
var unit = _consist[0];
|
||||
Assert.Equal(new[] { "en57-c", "en57-d" }, unit.Cars.Select(c => c.SkinFile));
|
||||
Assert.True(unit.Grouped);
|
||||
Assert.True(unit.Flipped);
|
||||
Assert.Equal(new[] { eDriverType.Nobody, eDriverType.Reardriver }, unit.Cars.Select(c => c.DriverType));
|
||||
Assert.Equal(eDriverType.Reardriver, unit.Driver);
|
||||
Assert.Equal(new[] { 10, 20 }, unit.Cars.Select(c => c.LoadCount));
|
||||
Assert.All(unit.Cars, c => Assert.True(Consist.IsTurned(c)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Headdriver, 0)]
|
||||
[InlineData(eDriverType.Reardriver, 1)]
|
||||
public void A_crew_moving_into_a_longer_unit_sits_in_the_car_with_its_cab(eDriverType crew, int car)
|
||||
{
|
||||
var loco = Car("en57-c");
|
||||
loco.DriverType = crew;
|
||||
Put(loco);
|
||||
|
||||
Assert.Equal(1, new TexRandomizer(_catalog, new FirstPick()).Apply(_consist, t => _consist.MakeDynamic(t, null)));
|
||||
|
||||
var unit = _consist[0];
|
||||
Assert.Equal(2, unit.Cars.Count);
|
||||
Assert.Equal(crew, unit.Cars[car].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, unit.Cars[1 - car].DriverType);
|
||||
Assert.Equal(crew, unit.Driver);
|
||||
}
|
||||
|
||||
private sealed class FirstPick : Random
|
||||
{
|
||||
public override int Next(int maxValue) => 0;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_selected_vehicle_stays_selected_as_the_one_that_took_over()
|
||||
{
|
||||
var item = Put(Car("ep07-001"));
|
||||
_consist.Selected = item;
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.Same(_consist[0], _consist.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_not_selected_does_not_take_the_selection()
|
||||
{
|
||||
// ep07-001's turn picks ep07-002; ep07-002's turn picks itself and passes.
|
||||
Put(Car("ep07-001"));
|
||||
var selected = Put(Car("ep07-002"));
|
||||
_consist.Selected = selected;
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
Assert.Same(selected, _consist.Selected);
|
||||
}
|
||||
}
|
||||
77
StarterNG.Tests/TrainsetDisplayTests.cs
Normal file
77
StarterNG.Tests/TrainsetDisplayTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Views;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class TrainsetDisplayTests
|
||||
{
|
||||
private static Trainset Consist(string driver = "headdriver", string? description = null) =>
|
||||
new(string.Concat(
|
||||
description is null ? "" : $"//$o {description}\n",
|
||||
$"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 {driver} 3 0 enddynamic\nendtrainset"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("-ai", true)]
|
||||
[InlineData(" - ai", true)]
|
||||
[InlineData("a - b", false)]
|
||||
[InlineData("rozkladowy", false)]
|
||||
public void A_dash_opening_the_description_says_the_consist_is_ai(string description, bool ai) =>
|
||||
Assert.Equal(ai, TrainsetDisplay.IsAi(Consist(description: description)));
|
||||
|
||||
[Fact]
|
||||
public void No_description_is_no_ai() =>
|
||||
Assert.False(TrainsetDisplay.IsAi(Consist()));
|
||||
|
||||
[Fact]
|
||||
public void An_ai_consist_is_listed_only_when_they_are_shown()
|
||||
{
|
||||
var ai = Consist(description: "-ai");
|
||||
|
||||
Assert.False(TrainsetDisplay.ListedIn(ai, showAi: false, drivableOnly: false, current: null));
|
||||
Assert.True(TrainsetDisplay.ListedIn(ai, showAi: true, drivableOnly: false, current: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drivable_only_leaves_out_a_consist_nobody_sits_in()
|
||||
{
|
||||
var empty = Consist(driver: "nobody");
|
||||
|
||||
Assert.False(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: true, current: null));
|
||||
Assert.True(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: false, current: null));
|
||||
Assert.True(TrainsetDisplay.ListedIn(Consist(), showAi: true, drivableOnly: true, current: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drivable_only_keeps_the_one_picked_whatever_its_crew()
|
||||
{
|
||||
var picked = Consist(driver: "nobody");
|
||||
|
||||
Assert.True(TrainsetDisplay.ListedIn(picked, showAi: true, drivableOnly: true, current: picked));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Being_picked_does_not_show_an_ai_consist_the_filter_hides()
|
||||
{
|
||||
var picked = Consist(description: "-ai");
|
||||
|
||||
Assert.False(TrainsetDisplay.ListedIn(picked, showAi: false, drivableOnly: true, current: picked));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Headdriver, 0)]
|
||||
[InlineData(eDriverType.Reardriver, 1)]
|
||||
[InlineData(eDriverType.Passenger, 2)]
|
||||
[InlineData(eDriverType.Nobody, 3)]
|
||||
public void A_crew_keeps_its_place_in_the_list(eDriverType crew, int place)
|
||||
{
|
||||
Assert.Equal(place, CrewChoices.IndexOf(crew));
|
||||
Assert.Equal(crew, CrewChoices.At(place));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(4)]
|
||||
public void A_list_with_nothing_selected_means_nobody(int place) =>
|
||||
Assert.Equal(eDriverType.Nobody, CrewChoices.At(place));
|
||||
}
|
||||
162
StarterNG.Tests/VehicleBaseQueryTests.cs
Normal file
162
StarterNG.Tests/VehicleBaseQueryTests.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
using StarterNG.Views;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class VehicleBaseQueryTests
|
||||
{
|
||||
private static VehicleBaseRow Row(string skin, string? operatorName = null, string? revision = null,
|
||||
string? author = null, string? depot = null, string? photo = null,
|
||||
string directory = "dynamic/pkp/", string? model = "model")
|
||||
{
|
||||
return new VehicleBaseRow(new VehicleTexture
|
||||
{
|
||||
Directory = directory,
|
||||
Skinfile = skin,
|
||||
Model = model,
|
||||
Meta = new VehicleMeta
|
||||
{
|
||||
Operator = operatorName,
|
||||
RevisionDate = revision,
|
||||
TextureAuthor = author,
|
||||
Depot = depot,
|
||||
PhotoAuthor = photo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VehicleTexture> Catalogued(params string[] textures)
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
foreach (string text in textures)
|
||||
files.WithFile(TestInstallation.At("dynamic", "pkp", text[..text.IndexOf('=')], "textures.txt"),
|
||||
text[(text.IndexOf('=') + 1)..]);
|
||||
var installation = new TestInstallation(files);
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
return installation.Library.Vehicles.Textures;
|
||||
}
|
||||
|
||||
private static string[] Skins(IEnumerable<VehicleBaseRow> rows) => rows.Select(r => r.Skin).ToArray();
|
||||
|
||||
private static string[] Selected(VehicleBaseFilters filters, params VehicleBaseRow[] rows) =>
|
||||
Skins(VehicleBaseQuery.Select(rows, filters, r => r.Skin, descending: false));
|
||||
|
||||
[Fact]
|
||||
public void A_question_mark_in_the_credit_line_is_what_its_author_did_not_know()
|
||||
{
|
||||
var row = Row("a", operatorName: "?", revision: "?", author: "Jan");
|
||||
|
||||
Assert.Equal("", row.Operator);
|
||||
Assert.Equal("", row.Revision);
|
||||
Assert.Null(row.Date);
|
||||
Assert.Equal("", row.DateKey);
|
||||
Assert.Equal("Jan", row.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_date_key_sorts_as_the_dates_do() =>
|
||||
Assert.Equal("20081201", Row("a", revision: "xx.12.2008").DateKey);
|
||||
|
||||
[Fact]
|
||||
public void Each_filter_looks_in_its_own_field_ignoring_case_and_spaces_around()
|
||||
{
|
||||
var a = Row("alfa", operatorName: "PKP", author: "Jan", depot: "Lublin", photo: "Ewa", directory: "dynamic/one/");
|
||||
var b = Row("beta", operatorName: "CD", author: "Olaf", depot: "Praha", photo: "Karel", directory: "dynamic/two/");
|
||||
|
||||
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Name = " ALF " }, a, b));
|
||||
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Operator = "cd" }, a, b));
|
||||
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Author = "jan" }, a, b));
|
||||
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Station = "prah" }, a, b));
|
||||
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Photo = "EW" }, a, b));
|
||||
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Model = "TWO/" }, a, b));
|
||||
Assert.Empty(Selected(new VehicleBaseFilters { Name = "alf", Operator = "cd" }, a, b));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void A_filter_with_nothing_typed_lets_everything_through(string? typed) =>
|
||||
Assert.Equal(new[] { "a", "b" }, Selected(new VehicleBaseFilters { Name = typed }, Row("a"), Row("b")));
|
||||
|
||||
[Theory]
|
||||
[InlineData("2016", null, true)]
|
||||
[InlineData("2016-12", null, true)]
|
||||
[InlineData("2017", null, false)]
|
||||
[InlineData(null, "2016", true)]
|
||||
[InlineData(null, "2015", false)]
|
||||
[InlineData("2010", "2020", true)]
|
||||
public void A_revision_counts_while_its_span_overlaps_what_is_asked(string? from, string? to, bool shown)
|
||||
{
|
||||
var row = Row("a", revision: "xx.xx.2016");
|
||||
|
||||
var found = Selected(new VehicleBaseFilters { RevisionFrom = from, RevisionTo = to }, row);
|
||||
|
||||
Assert.Equal(shown, found.Length == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_month_only_date_overlaps_at_both_of_its_ends()
|
||||
{
|
||||
var row = Row("a", revision: "xx.06.2008");
|
||||
|
||||
Assert.Single(Selected(new VehicleBaseFilters { RevisionFrom = "30.06.2008" }, row));
|
||||
Assert.Single(Selected(new VehicleBaseFilters { RevisionTo = "01.06.2008" }, row));
|
||||
Assert.Empty(Selected(new VehicleBaseFilters { RevisionFrom = "01.07.2008" }, row));
|
||||
Assert.Empty(Selected(new VehicleBaseFilters { RevisionTo = "31.05.2008" }, row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_revision_asked_for_leaves_out_what_has_no_date_and_what_cannot_be_read_asks_nothing()
|
||||
{
|
||||
var dated = Row("dated", revision: "2016");
|
||||
var undated = Row("undated");
|
||||
|
||||
Assert.Equal(new[] { "dated" }, Selected(new VehicleBaseFilters { RevisionFrom = "2000" }, dated, undated));
|
||||
Assert.Equal(new[] { "dated", "undated" },
|
||||
Selected(new VehicleBaseFilters { RevisionFrom = "soon" }, dated, undated));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, new[] { "b", "c", "a", "d" })]
|
||||
[InlineData(true, new[] { "c", "b", "a", "d" })]
|
||||
public void Blanks_stay_at_the_bottom_whichever_way_a_column_is_sorted_and_the_name_settles_a_tie(
|
||||
bool descending, string[] order)
|
||||
{
|
||||
var rows = new[] { Row("d"), Row("c", operatorName: "Z"), Row("a"), Row("b", operatorName: "M") };
|
||||
|
||||
Assert.Equal(order, Skins(VehicleBaseQuery.Select(rows, new VehicleBaseFilters(), r => r.Operator, descending)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_class_list_counts_a_class_under_one_name_whatever_its_case_in_order_of_the_names()
|
||||
{
|
||||
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111\nb111-02.mat=b222,b111",
|
||||
"ep07=!=e\nep07-01.mat=ep07,AP07");
|
||||
|
||||
var counts = VehicleBaseQuery.ClassCounts(textures.Select(t => new VehicleBaseRow(t)));
|
||||
|
||||
Assert.Equal(new[] { ("AP07", 1), ("B111", 2) },
|
||||
counts.Select(c => (c.Name.ToUpperInvariant(), c.Count)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_with_no_class_is_not_counted() =>
|
||||
Assert.Empty(VehicleBaseQuery.ClassCounts(new[] { Row("a") }));
|
||||
|
||||
[Fact]
|
||||
public void The_class_picked_has_to_match_whole_while_the_typed_one_only_has_to_appear()
|
||||
{
|
||||
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111");
|
||||
var row = new VehicleBaseRow(textures[0]);
|
||||
Assert.NotEqual("", row.Class);
|
||||
|
||||
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class.ToUpperInvariant() },
|
||||
r => r.Skin, descending: false));
|
||||
Assert.Empty(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class[..1] + "?" },
|
||||
r => r.Skin, descending: false));
|
||||
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { Class = row.Class[..1] },
|
||||
r => r.Skin, descending: false));
|
||||
}
|
||||
}
|
||||
139
StarterNG.Tests/WarehouseEditTests.cs
Normal file
139
StarterNG.Tests/WarehouseEditTests.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
// PresetStore keeps the disk of the first test that reaches it, a fake one as every
|
||||
// composition root in the tests is built on fakes. So each test has a name to itself.
|
||||
public class WarehouseEditTests
|
||||
{
|
||||
private const string Text =
|
||||
"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\nendtrainset\n";
|
||||
|
||||
private static readonly string Entry = ConsistText.Serialize(new Trainset(Text));
|
||||
|
||||
private readonly WarehouseEdit _edit = new();
|
||||
|
||||
public WarehouseEditTests()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||
AppServices.Initialize(installation.Paths, installation.Files, installation.Clock, installation.Environment);
|
||||
}
|
||||
|
||||
private static TrainsetPreset Preset(string name, string? entry = null) => new() { Name = name, Entry = entry ?? Entry };
|
||||
|
||||
private static TrainsetPreset? Stored(string name) => PresetStore.All().FirstOrDefault(p => p.Name == name);
|
||||
|
||||
[Fact]
|
||||
public void Opening_says_what_is_edited_and_closing_that_nothing_is()
|
||||
{
|
||||
var trainset = _edit.Open(Preset("edit-open"));
|
||||
|
||||
Assert.Same(trainset, _edit.Trainset);
|
||||
Assert.Equal("edit-open", _edit.Name);
|
||||
|
||||
_edit.Close();
|
||||
|
||||
Assert.Null(_edit.Trainset);
|
||||
Assert.Null(_edit.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_opened_again_is_the_same_trainset_so_its_undo_history_outlives_a_switch()
|
||||
{
|
||||
var first = _edit.Open(Preset("edit-switch"));
|
||||
_edit.Open(Preset("edit-switch-other"));
|
||||
|
||||
Assert.Same(first, _edit.Open(Preset("edit-switch")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_entry_written_over_from_elsewhere_is_read_afresh()
|
||||
{
|
||||
var first = _edit.Open(Preset("edit-over"));
|
||||
|
||||
var second = _edit.Open(Preset("edit-over", ConsistText.Serialize(new Trainset(Text.Replace(" v1 ", " v9 ")))));
|
||||
|
||||
Assert.NotSame(first, second);
|
||||
Assert.Equal("v9", second.Vehicles.Single().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_name_of_a_consist_goes_with_any_letter_case()
|
||||
{
|
||||
var first = _edit.Open(Preset("edit-case"));
|
||||
|
||||
Assert.Same(first, _edit.Open(Preset("EDIT-CASE")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_is_written_while_the_consist_stands_as_it_was_read()
|
||||
{
|
||||
PresetStore.Save("edit-unchanged", Entry);
|
||||
_edit.Open(Stored("edit-unchanged")!);
|
||||
PresetStore.Delete("edit-unchanged");
|
||||
|
||||
_edit.Save();
|
||||
|
||||
Assert.Null(Stored("edit-unchanged"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_is_written_once()
|
||||
{
|
||||
PresetStore.Save("edit-changed", Entry);
|
||||
var trainset = _edit.Open(Stored("edit-changed")!);
|
||||
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||
|
||||
_edit.Save();
|
||||
|
||||
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-changed")!.Entry);
|
||||
|
||||
PresetStore.Delete("edit-changed");
|
||||
_edit.Save();
|
||||
|
||||
Assert.Null(Stored("edit-changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_is_written_with_nothing_open()
|
||||
{
|
||||
var trainset = _edit.Open(Preset("edit-closed"));
|
||||
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||
_edit.Close();
|
||||
|
||||
_edit.Save();
|
||||
|
||||
Assert.Null(Stored("edit-closed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rename_keeps_the_trainset_and_saves_under_the_new_name()
|
||||
{
|
||||
var trainset = _edit.Open(Preset("edit-old"));
|
||||
|
||||
_edit.Renamed("edit-old", "edit-new");
|
||||
|
||||
Assert.Equal("edit-new", _edit.Name);
|
||||
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||
_edit.Save();
|
||||
Assert.Null(Stored("edit-old"));
|
||||
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-new")!.Entry);
|
||||
|
||||
Assert.Same(trainset, _edit.Open(Preset("edit-new", Stored("edit-new")!.Entry)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renaming_another_consist_leaves_the_one_open_alone()
|
||||
{
|
||||
var other = _edit.Open(Preset("edit-other"));
|
||||
var open = _edit.Open(Preset("edit-mine"));
|
||||
|
||||
_edit.Renamed("edit-other", "edit-other-new");
|
||||
|
||||
Assert.Same(open, _edit.Trainset);
|
||||
Assert.Equal("edit-mine", _edit.Name);
|
||||
Assert.Same(other, _edit.Open(Preset("edit-other-new")));
|
||||
}
|
||||
}
|
||||
62
StarterNG/Application/WarehouseEdit.cs
Normal file
62
StarterNG/Application/WarehouseEdit.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The warehouse consist the strip edits while the warehouse tab is open, under
|
||||
/// its name there, and the entry as last written - every change saves itself.
|
||||
/// </summary>
|
||||
public sealed class WarehouseEdit
|
||||
{
|
||||
/// <summary>One trainset per warehouse consist, so its undo history outlives a switch.</summary>
|
||||
private readonly Dictionary<string, Trainset> _trainsets = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private string? _saved;
|
||||
|
||||
public Trainset? Trainset { get; private set; }
|
||||
|
||||
public string? Name { get; private set; }
|
||||
|
||||
public Trainset Open(TrainsetPreset preset)
|
||||
{
|
||||
// Written over from elsewhere - "add to warehouse as" the same name - the
|
||||
// entry is read afresh; the history of what it was no longer applies.
|
||||
if (!_trainsets.TryGetValue(preset.Name, out var trainset) ||
|
||||
ConsistText.Serialize(trainset) != preset.Entry)
|
||||
_trainsets[preset.Name] = trainset = new Trainset(preset.Entry);
|
||||
|
||||
Trainset = trainset;
|
||||
Name = preset.Name;
|
||||
_saved = preset.Entry;
|
||||
return trainset;
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
Trainset = null;
|
||||
Name = null;
|
||||
}
|
||||
|
||||
public void Renamed(string old, string fresh)
|
||||
{
|
||||
if (_trainsets.Remove(old, out var trainset))
|
||||
_trainsets[fresh] = trainset;
|
||||
if (string.Equals(Name, old, StringComparison.OrdinalIgnoreCase))
|
||||
Name = fresh;
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
if (Trainset is null || Name is null)
|
||||
return;
|
||||
|
||||
string entry = ConsistText.Serialize(Trainset);
|
||||
if (entry == _saved)
|
||||
return;
|
||||
|
||||
PresetStore.Save(Name, entry);
|
||||
_saved = entry;
|
||||
}
|
||||
}
|
||||
@@ -61,14 +61,18 @@ public sealed class Cargo
|
||||
|
||||
public void FillToMax(Dynamic car)
|
||||
{
|
||||
int max = MaxFor(car);
|
||||
string? type = TypeToLoad(car);
|
||||
if (string.IsNullOrEmpty(type)) return;
|
||||
int max = MaxFor(car, type);
|
||||
if (max <= 0) return;
|
||||
if (string.IsNullOrEmpty(car.LoadType))
|
||||
car.LoadType = AcceptedTypes(car).FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(car.LoadType)) return;
|
||||
car.LoadType = type;
|
||||
car.LoadCount = max;
|
||||
}
|
||||
|
||||
// The type first: what it may hold depends on it, pantographs above all.
|
||||
private string? TypeToLoad(Dynamic car) =>
|
||||
string.IsNullOrEmpty(car.LoadType) ? AcceptedTypes(car).FirstOrDefault() : car.LoadType;
|
||||
|
||||
public void CopyFromPrevious(IReadOnlyList<ConsistItem> consist, ConsistItem item)
|
||||
{
|
||||
int idx = IndexOf(consist, item);
|
||||
@@ -103,7 +107,7 @@ public sealed class Cargo
|
||||
if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
c.LoadType = type;
|
||||
c.LoadCount = Math.Min(lead.LoadCount, MaxFor(c));
|
||||
c.LoadCount = Math.Min(lead.LoadCount, LimitFor(c));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +117,9 @@ public sealed class Cargo
|
||||
{
|
||||
var types = AcceptedTypes(c);
|
||||
if (types.Count == 0) continue;
|
||||
c.LoadType = types[_rng.Next(types.Count)];
|
||||
string type = types[_rng.Next(types.Count)];
|
||||
c.LoadType = type;
|
||||
c.LoadCount = Math.Min(c.LoadCount, LimitFor(c, type));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +133,11 @@ public sealed class Cargo
|
||||
{
|
||||
foreach (var c in consist.SelectMany(u => u.Cars))
|
||||
{
|
||||
int max = MaxFor(c);
|
||||
string? type = TypeToLoad(c);
|
||||
if (string.IsNullOrEmpty(type)) continue;
|
||||
int max = MaxFor(c, type);
|
||||
if (max <= 0) continue;
|
||||
if (string.IsNullOrEmpty(c.LoadType))
|
||||
c.LoadType = AcceptedTypes(c).FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(c.LoadType)) continue;
|
||||
c.LoadType = type;
|
||||
int min = Math.Max(1, max / 10);
|
||||
c.LoadCount = _rng.Next(min, max + 1);
|
||||
}
|
||||
|
||||
@@ -52,12 +52,21 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
public void Raise() => Changed?.Invoke();
|
||||
|
||||
public void LoadFrom(Trainset trainset)
|
||||
/// <param name="select">The unit to select, by position; the first one otherwise.</param>
|
||||
public void LoadFrom(Trainset trainset, int select = 0)
|
||||
{
|
||||
EditingTrainset = trainset;
|
||||
GroupIntoUnits(trainset.Vehicles);
|
||||
Selected = _items.Count == 0 ? null : _items[Math.Clamp(select, 0, _items.Count - 1)];
|
||||
SyncStartingVehicle();
|
||||
Raise();
|
||||
}
|
||||
|
||||
private void GroupIntoUnits(IEnumerable<Dynamic> vehicles)
|
||||
{
|
||||
_items.Clear();
|
||||
|
||||
var cars = trainset.Vehicles.Select(v =>
|
||||
var cars = vehicles.Select(v =>
|
||||
{
|
||||
var c = v.Clone();
|
||||
c.MiniName = _db.MiniFor(c.DataFolder, c.SkinFile, c.MmdFile) ?? c.MiniName;
|
||||
@@ -74,13 +83,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
while (j < cars.Count - 1 && HoldsNext(cars[j]))
|
||||
locked.Add(cars[++j]);
|
||||
|
||||
_items.Add(new ConsistItem
|
||||
{
|
||||
Cars = locked,
|
||||
Grouped = true,
|
||||
Flipped = locked[0].Offset < 0,
|
||||
Driver = UnitDriver(locked)
|
||||
});
|
||||
_items.Add(Unit(locked));
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
@@ -106,13 +109,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
if (group.Count >= 2)
|
||||
{
|
||||
_items.Add(new ConsistItem
|
||||
{
|
||||
Cars = group,
|
||||
Grouped = true,
|
||||
Flipped = group[0].Offset < 0,
|
||||
Driver = UnitDriver(group)
|
||||
});
|
||||
_items.Add(Unit(group));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
@@ -128,13 +125,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
if (group.Count >= 2)
|
||||
{
|
||||
_items.Add(new ConsistItem
|
||||
{
|
||||
Cars = group,
|
||||
Grouped = true,
|
||||
Flipped = group[0].Offset < 0,
|
||||
Driver = UnitDriver(group)
|
||||
});
|
||||
_items.Add(Unit(group));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
@@ -149,9 +140,34 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
Selected = _items.FirstOrDefault();
|
||||
SyncStartingVehicle();
|
||||
/// <summary>
|
||||
/// A unit from cars in the order they run. A turned unit keeps its own order
|
||||
/// reversed, which <see cref="RunningOrder"/> undoes - kept as it runs, its cars
|
||||
/// would swap ends with every load.
|
||||
/// </summary>
|
||||
private static ConsistItem Unit(List<Dynamic> running)
|
||||
{
|
||||
bool flipped = running[0].Offset < 0;
|
||||
if (flipped)
|
||||
running.Reverse();
|
||||
return new ConsistItem
|
||||
{
|
||||
Cars = running,
|
||||
Grouped = true,
|
||||
Flipped = flipped,
|
||||
Driver = UnitDriver(running)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Nothing to edit: the strip goes empty until a consist is loaded again.</summary>
|
||||
public void Unload()
|
||||
{
|
||||
EditingTrainset = null;
|
||||
_items.Clear();
|
||||
Selected = null;
|
||||
SelectedCar = null;
|
||||
Raise();
|
||||
}
|
||||
|
||||
@@ -161,16 +177,49 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
|
||||
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
|
||||
|
||||
/// <summary>
|
||||
/// The vehicle the simulator puts the player into follows the selection - but
|
||||
/// only onto a unit someone sits in. Selecting a wagon to edit it, or adding
|
||||
/// one, which selects it, keeps the pick made before; otherwise Start would
|
||||
/// read "no crew" and the simulator would open in an empty wagon.
|
||||
/// </summary>
|
||||
public void SyncStartingVehicle()
|
||||
{
|
||||
if (Selected is not { Cars.Count: > 0 } item)
|
||||
// A consist from the warehouse, say, is edited here but not the one started.
|
||||
var state = AppServices.Current.State;
|
||||
if (EditingTrainset is null || !ReferenceEquals(EditingTrainset, state.CurrentTrainset))
|
||||
return;
|
||||
|
||||
var driven = item.Cars.FirstOrDefault(c =>
|
||||
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
|
||||
AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
|
||||
if (StartingVehicle(_items, Selected, state.StartingVehicleName) is { } pick)
|
||||
state.StartingVehicleName = pick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selected unit's driver, or its passenger; with no one in it, the pick
|
||||
/// made before while it still can be started from, else the first crew there is.
|
||||
/// </summary>
|
||||
public static string? StartingVehicle(IEnumerable<ConsistItem> items, ConsistItem? selected, string? current)
|
||||
{
|
||||
var cars = items.SelectMany(i => i.Cars).ToList();
|
||||
string? pick =
|
||||
selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
|
||||
?? selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Passenger)?.Name;
|
||||
if (pick != null)
|
||||
return pick;
|
||||
|
||||
if (cars.Any(c => IsNamed(c, current) && Crewed(c)))
|
||||
return current;
|
||||
|
||||
return cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
|
||||
?? cars.FirstOrDefault(Crewed)?.Name;
|
||||
}
|
||||
|
||||
private static bool Crewed(Dynamic car) =>
|
||||
car.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
private static bool IsNamed(Dynamic car, string? name) =>
|
||||
!string.IsNullOrEmpty(name) && string.Equals(car.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery)
|
||||
{
|
||||
string skin = Base(texture.Skinfile);
|
||||
@@ -340,8 +389,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
/// <summary>
|
||||
/// A locomotive that comes to the head of the train takes over the driver of
|
||||
/// the one it put behind, which is left unstaffed: the train is driven from its
|
||||
/// front, from cab A.
|
||||
/// the one it put behind, in the same cab, and that one is left unstaffed.
|
||||
/// </summary>
|
||||
private void HandOverLead(ConsistItem previousHead)
|
||||
{
|
||||
@@ -350,11 +398,55 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver))
|
||||
return;
|
||||
|
||||
var crew = previousHead.Driver;
|
||||
previousHead.Driver = eDriverType.Nobody;
|
||||
foreach (var car in previousHead.Cars)
|
||||
car.DriverType = eDriverType.Nobody;
|
||||
|
||||
Crew(head, eDriverType.Headdriver);
|
||||
Crew(head, crew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vehicles loaded in place of a consist keep its kind of crew: in the unit the
|
||||
/// loaded ones have crew in, else in the first powered one. A driver goes only
|
||||
/// into something powered, and with nobody in the old consist nobody is in the new.
|
||||
/// </summary>
|
||||
public static List<Dynamic> LoadedOver(IReadOnlyList<Dynamic> before, List<Dynamic> loaded,
|
||||
VehicleCatalog db, VehicleInfo info)
|
||||
{
|
||||
if (before.Count == 0 || loaded.Count == 0)
|
||||
return loaded;
|
||||
|
||||
var consist = new Consist(db, info);
|
||||
consist.GroupIntoUnits(loaded);
|
||||
|
||||
var crew = UnitDriver(before);
|
||||
if (crew == eDriverType.Nobody)
|
||||
{
|
||||
foreach (var item in consist._items)
|
||||
Crew(item, eDriverType.Nobody);
|
||||
return consist.Flatten();
|
||||
}
|
||||
|
||||
var seat = consist._items.FirstOrDefault(item => item.Driver != eDriverType.Nobody)
|
||||
?? consist._items.FirstOrDefault(consist.IsPowered);
|
||||
if (seat is not null && (crew == eDriverType.Passenger || consist.IsPowered(seat)))
|
||||
Crew(seat, crew);
|
||||
return consist.Flatten();
|
||||
}
|
||||
|
||||
/// <summary>Vehicles joining a consist that has its crew come without their own.</summary>
|
||||
public static List<Dynamic> Joined(IEnumerable<Dynamic> before, IEnumerable<Dynamic> added)
|
||||
{
|
||||
var joined = before.ToList();
|
||||
bool crewed = joined.Count > 0;
|
||||
foreach (var car in added)
|
||||
{
|
||||
if (crewed)
|
||||
car.DriverType = eDriverType.Nobody;
|
||||
joined.Add(car);
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
public void Remove(ConsistItem item)
|
||||
@@ -673,6 +765,11 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
public static bool HoldsTail(ConsistItem item) =>
|
||||
item.Cars.Count > 0 && HoldsNext(TailCar(item));
|
||||
|
||||
/// <summary>Whether the unit at that place can join a neighbour: one it holds or may form a unit with, or one holding it.</summary>
|
||||
public bool CanJoin(ConsistItem item, int index) =>
|
||||
(index + 1 < Count && (HoldsTail(item) || CanFormUnit(item, this[index + 1]))) ||
|
||||
(index > 0 && HoldsTail(this[index - 1]));
|
||||
|
||||
public static bool HoldsNext(Dynamic car) =>
|
||||
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;
|
||||
|
||||
|
||||
92
StarterNG/Domain/ConsistHistory.cs
Normal file
92
StarterNG/Domain/ConsistHistory.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Undo and redo for the consist editor, one history per trainset. It is fed the
|
||||
/// vehicles as written back after every redraw; a state equal to the last one -
|
||||
/// a redraw for a new selection - changes nothing, so feeding it is cheap to do
|
||||
/// everywhere instead of remembering which of the many edits went through it.
|
||||
/// </summary>
|
||||
public sealed class ConsistHistory
|
||||
{
|
||||
private const int Depth = 100;
|
||||
|
||||
public sealed record State(IReadOnlyList<Dynamic> Vehicles, string Signature, int Selected);
|
||||
|
||||
private sealed class Track
|
||||
{
|
||||
public State? Last;
|
||||
public readonly List<State> Undo = new();
|
||||
public readonly Stack<State> Redo = new();
|
||||
}
|
||||
|
||||
private readonly ConditionalWeakTable<Trainset, Track> _tracks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Notes what the trainset holds now. With <paramref name="rebase"/> it only
|
||||
/// becomes the state undo returns to - for the redraw after an undo itself.
|
||||
/// </summary>
|
||||
public void Record(Trainset trainset, int selected, bool rebase = false)
|
||||
{
|
||||
var track = _tracks.GetOrCreateValue(trainset);
|
||||
string signature = Dynamic.ToTrainsetNodes(trainset.Vehicles);
|
||||
|
||||
if (track.Last is { } last && last.Signature == signature)
|
||||
{
|
||||
track.Last = last with { Selected = selected };
|
||||
return;
|
||||
}
|
||||
|
||||
var now = new State(trainset.Vehicles.Select(v => v.Clone()).ToList(), signature, selected);
|
||||
if (track.Last is { } previous && !rebase)
|
||||
{
|
||||
// Undone, the selection goes back to where the change was made.
|
||||
track.Undo.Add(previous with { Selected = selected });
|
||||
if (track.Undo.Count > Depth)
|
||||
track.Undo.RemoveAt(0);
|
||||
track.Redo.Clear();
|
||||
}
|
||||
track.Last = now;
|
||||
}
|
||||
|
||||
public bool CanUndo(Trainset trainset) =>
|
||||
_tracks.TryGetValue(trainset, out var track) && track.Undo.Count > 0;
|
||||
|
||||
public bool CanRedo(Trainset trainset) =>
|
||||
_tracks.TryGetValue(trainset, out var track) && track.Redo.Count > 0;
|
||||
|
||||
/// <summary>The state before the last change, as fresh copies to put into the trainset.</summary>
|
||||
public State? Undo(Trainset trainset, int selected)
|
||||
{
|
||||
// An edit that never asked for a redraw is still in the trainset; noting it
|
||||
// first makes it the change that gets undone instead of being lost.
|
||||
Record(trainset, selected);
|
||||
var track = _tracks.GetOrCreateValue(trainset);
|
||||
if (track.Undo.Count == 0 || track.Last is not { } last)
|
||||
return null;
|
||||
|
||||
track.Redo.Push(last);
|
||||
track.Last = track.Undo[^1];
|
||||
track.Undo.RemoveAt(track.Undo.Count - 1);
|
||||
return Copy(track.Last);
|
||||
}
|
||||
|
||||
public State? Redo(Trainset trainset, int selected)
|
||||
{
|
||||
Record(trainset, selected);
|
||||
var track = _tracks.GetOrCreateValue(trainset);
|
||||
if (track.Redo.Count == 0 || track.Last is not { } last)
|
||||
return null;
|
||||
|
||||
track.Undo.Add(last);
|
||||
track.Last = track.Redo.Pop();
|
||||
return Copy(track.Last);
|
||||
}
|
||||
|
||||
private static State Copy(State state) =>
|
||||
state with { Vehicles = state.Vehicles.Select(v => v.Clone()).ToList() };
|
||||
}
|
||||
@@ -99,18 +99,49 @@ public sealed class TexRandomizer
|
||||
|
||||
var set = _db.ResolveSet(pick);
|
||||
var unit = set ?? new List<VehicleTexture> { pick };
|
||||
consist[i] = new ConsistItem
|
||||
var replacement = new ConsistItem
|
||||
{
|
||||
Cars = unit.Select(makeCar).ToList(),
|
||||
Grouped = unit.Count > 1,
|
||||
Driver = item.Driver,
|
||||
Flipped = item.Flipped
|
||||
};
|
||||
CarryOver(item, replacement);
|
||||
consist[i] = replacement;
|
||||
if (ReferenceEquals(consist.Selected, item))
|
||||
consist.Selected = replacement;
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Only the livery changes, but a new car has no crew, faces forward and is empty.
|
||||
// The cars are what the export writes, so they are what is taken from; not through
|
||||
// Consist.Replace, which would hand a driver to a locomotive left without one.
|
||||
private static void CarryOver(ConsistItem from, ConsistItem to)
|
||||
{
|
||||
for (int k = 0; k < to.Cars.Count; k++)
|
||||
{
|
||||
var before = from.Cars[Math.Min(k, from.Cars.Count - 1)];
|
||||
var car = to.Cars[k];
|
||||
car.Offset = before.Offset;
|
||||
car.LoadType = before.LoadType;
|
||||
car.LoadCount = before.LoadCount;
|
||||
car.MaxLoad = before.MaxLoad;
|
||||
}
|
||||
|
||||
var crew = Consist.UnitDriver(from.Cars);
|
||||
if (to.Cars.Count == from.Cars.Count)
|
||||
{
|
||||
for (int k = 0; k < to.Cars.Count; k++)
|
||||
to.Cars[k].DriverType = from.Cars[k].DriverType;
|
||||
}
|
||||
else if (crew != eDriverType.Nobody)
|
||||
{
|
||||
Consist.CrewCar(to, crew).DriverType = crew;
|
||||
}
|
||||
to.Driver = crew;
|
||||
}
|
||||
|
||||
private string? ResolveFilterMini(ConsistItem item, int index, Consist consist, VehicleTexture? current)
|
||||
{
|
||||
if (current != null)
|
||||
|
||||
@@ -17,6 +17,27 @@ public static class TrainsetDisplay
|
||||
trainset.Vehicles.Count == 0 ||
|
||||
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
|
||||
|
||||
/// <summary>
|
||||
/// Whether "drivable only" lists the consist. The one picked and one with changes
|
||||
/// not saved stay whatever their crew: emptying a consist must not take away the
|
||||
/// row it is filled again or saved from.
|
||||
/// </summary>
|
||||
public static bool ListedAsDrivable(Trainset trainset, Trainset? current) =>
|
||||
UData.StaffedTrain(trainset) || ReferenceEquals(trainset, current) || SceneryTrainsetText.IsModified(trainset);
|
||||
|
||||
/// <summary>A consist the simulator drives itself: its description starts with a dash.</summary>
|
||||
public static bool IsAi(Trainset trainset) =>
|
||||
!string.IsNullOrEmpty(trainset.Description) &&
|
||||
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Whether a list of the scenery's consists shows it, under the two filters those lists have.</summary>
|
||||
public static bool ListedIn(Trainset trainset, bool showAi, bool drivableOnly, Trainset? current) =>
|
||||
(showAi || !IsAi(trainset)) && (!drivableOnly || ListedAsDrivable(trainset, current));
|
||||
|
||||
/// <summary>The consist's row in those lists: what it is made of, or the track of one with nothing on it.</summary>
|
||||
public static string ListLabel(Trainset trainset) =>
|
||||
CompositionText(trainset) ?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
|
||||
|
||||
public static string? CompositionText(Trainset trainset)
|
||||
{
|
||||
if (IsEmpty(trainset))
|
||||
|
||||
@@ -106,13 +106,7 @@ public static class UDepot
|
||||
d.SkinFile = Dynamic.StripSkinExtension(t[p++]);
|
||||
d.MmdFile = t[p++];
|
||||
d.Offset = float.Parse(t[p++], inv);
|
||||
d.DriverType = t[p++].ToLowerInvariant() switch
|
||||
{
|
||||
"headdriver" => eDriverType.Headdriver,
|
||||
"reardriver" => eDriverType.Reardriver,
|
||||
"passenger" => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
};
|
||||
d.DriverType = Dynamic.ParseDriver(t[p++]);
|
||||
d.Coupling = Coupling.Parse(t[p++]);
|
||||
|
||||
var trailing = new List<string>();
|
||||
|
||||
50
StarterNG/Domain/Vehicles/RevisionSpan.cs
Normal file
50
StarterNG/Domain/Vehicles/RevisionSpan.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StarterNG.Domain.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// A revision date from a livery's credit line, as far as it is known: 14.01.2005,
|
||||
/// xx.06.2008, xx.xx.2011. A span, so a month or a year is not taken for its first day.
|
||||
/// </summary>
|
||||
public readonly record struct RevisionSpan(DateTime From, DateTime To)
|
||||
{
|
||||
/// <summary>Also reads what is typed to filter by: 2016, 2016-12, 2016-12-06, 12.2016.</summary>
|
||||
public static RevisionSpan? Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
string trimmed = text.Trim();
|
||||
string[] parts = trimmed.Split('.', '-');
|
||||
bool yearFirst = trimmed.Contains('-');
|
||||
return parts.Length switch
|
||||
{
|
||||
1 => Of(parts[0], null, null),
|
||||
2 => yearFirst ? Of(parts[0], parts[1], null) : Of(parts[1], parts[0], null),
|
||||
3 => yearFirst ? Of(parts[0], parts[1], parts[2]) : Of(parts[2], parts[1], parts[0]),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static RevisionSpan? Of(string year, string? month, string? day)
|
||||
{
|
||||
if (year.Length != 4 || !Number(year, out int y) || y is < 1800 or > 2200)
|
||||
return null;
|
||||
if (!Number(month, out int m))
|
||||
return new RevisionSpan(new DateTime(y, 1, 1), new DateTime(y, 12, 31));
|
||||
if (m is < 1 or > 12)
|
||||
return null;
|
||||
|
||||
int days = DateTime.DaysInMonth(y, m);
|
||||
if (!Number(day, out int d))
|
||||
return new RevisionSpan(new DateTime(y, m, 1), new DateTime(y, m, days));
|
||||
if (d < 1 || d > days)
|
||||
return null;
|
||||
|
||||
return new RevisionSpan(new DateTime(y, m, d), new DateTime(y, m, d));
|
||||
}
|
||||
|
||||
private static bool Number(string? text, out int value) =>
|
||||
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Input;
|
||||
@@ -40,6 +39,9 @@ public static class Shortcuts
|
||||
public const string ClearConsist = "consist.clear";
|
||||
public const string SetNumber = "vehicle.number";
|
||||
public const string FocusSearch = "search";
|
||||
public const string Undo = "consist.undo";
|
||||
public const string Redo = "consist.redo";
|
||||
public const string SaveToScenery = "consist.savescenery";
|
||||
|
||||
public static readonly IReadOnlyList<Shortcut> All = new[]
|
||||
{
|
||||
@@ -64,6 +66,12 @@ public static class Shortcuts
|
||||
Key.Delete, KeyModifiers.Control),
|
||||
new Shortcut(SetNumber, "Ctrl+N", "ShortcutSetNumber", ShortcutScope.Depot,
|
||||
Key.N, KeyModifiers.Control),
|
||||
new Shortcut(Undo, "Ctrl+Z", "ShortcutUndo", ShortcutScope.Depot,
|
||||
Key.Z, KeyModifiers.Control),
|
||||
new Shortcut(Redo, "Ctrl+Y", "ShortcutRedo", ShortcutScope.Depot,
|
||||
Key.Y, KeyModifiers.Control),
|
||||
new Shortcut(SaveToScenery, "Ctrl+S", "SaveToScenery", ShortcutScope.Depot,
|
||||
Key.S, KeyModifiers.Control),
|
||||
|
||||
new Shortcut("consist.prevnext", "← →", "ShortcutPrevNext", ShortcutScope.Consist),
|
||||
new Shortcut("consist.firstlast", "Home / End", "ShortcutFirstLast", ShortcutScope.Consist),
|
||||
|
||||
@@ -29,8 +29,11 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
||||
private readonly ConcurrentDictionary<string, VehiclePhysics?> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Names repeat between folders: 214m.fiz is one vehicle in 21xm_v1, another in sa134_v1.
|
||||
private sealed record FizIndex(Dictionary<string, string> ByFolderAndName, Dictionary<string, string> ByName);
|
||||
|
||||
private readonly object _indexGate = new();
|
||||
private Dictionary<string, string>? _index;
|
||||
private FizIndex? _index;
|
||||
|
||||
public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
|
||||
{
|
||||
@@ -41,7 +44,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
||||
|
||||
public void Preload() => Index();
|
||||
|
||||
public int IndexedCount => _index?.Count ?? 0;
|
||||
public int IndexedCount => _index?.ByName.Count ?? 0;
|
||||
|
||||
public VehiclePhysics? For(string? dataFolder, string? model)
|
||||
{
|
||||
@@ -52,20 +55,34 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
||||
if (name.Length == 0)
|
||||
return null;
|
||||
|
||||
if (_cache.TryGetValue(name, out var cached))
|
||||
string folder = FolderKey(dataFolder);
|
||||
string key = FizKey(folder, name);
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
var physics = Read(name);
|
||||
_cache[name] = physics;
|
||||
var physics = Read(folder, name);
|
||||
_cache[key] = physics;
|
||||
return physics;
|
||||
}
|
||||
|
||||
private VehiclePhysics? Read(string name)
|
||||
private static string FizKey(string folder, string name) => $"{folder}/{name}";
|
||||
|
||||
/// <summary>A vehicle's folder under dynamic, however the caller spells it.</summary>
|
||||
private static string FolderKey(string? folder)
|
||||
{
|
||||
const string dynamicPrefix = "dynamic/";
|
||||
string key = (folder ?? "").Replace('\\', '/').Trim('/');
|
||||
return key.StartsWith(dynamicPrefix, StringComparison.OrdinalIgnoreCase) ? key[dynamicPrefix.Length..] : key;
|
||||
}
|
||||
|
||||
private VehiclePhysics? Read(string folder, string name)
|
||||
{
|
||||
var index = Index();
|
||||
|
||||
if (!index.TryGetValue(name, out string? path) &&
|
||||
!index.TryGetValue(name + "dumb", out path))
|
||||
if (!index.ByFolderAndName.TryGetValue(FizKey(folder, name), out string? path) &&
|
||||
!index.ByFolderAndName.TryGetValue(FizKey(folder, name + "dumb"), out path) &&
|
||||
!index.ByName.TryGetValue(name, out path) &&
|
||||
!index.ByName.TryGetValue(name + "dumb", out path))
|
||||
return null;
|
||||
|
||||
var physics = new VehiclePhysics { AllowedFlagB = UndeclaredCoupler };
|
||||
@@ -88,26 +105,33 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
|
||||
return physics;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Index()
|
||||
private FizIndex Index()
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
if (_index is not null)
|
||||
return _index;
|
||||
|
||||
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var byFolder = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz"))
|
||||
index[Path.GetFileNameWithoutExtension(file)] = file;
|
||||
// Sorted, so which of two namesakes a name alone finds is the same on every machine.
|
||||
foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz").Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(file);
|
||||
string folder = FolderKey(Path.GetDirectoryName(Path.GetRelativePath(_paths.Dynamic, file)));
|
||||
byFolder[FizKey(folder, name)] = file;
|
||||
byName.TryAdd(name, file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"indexing {_paths.Dynamic}", ex);
|
||||
}
|
||||
|
||||
_index = index;
|
||||
return index;
|
||||
_index = new FizIndex(byFolder, byName);
|
||||
return _index;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
@@ -53,8 +52,8 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
||||
/// </summary>
|
||||
public IEnumerable<string> Sources()
|
||||
{
|
||||
foreach (string maker in Sorted(_files.GetDirectories(_paths.Dynamic)))
|
||||
foreach (string vehicle in Sorted(_files.GetDirectories(maker)))
|
||||
foreach (string maker in Subfolders(_paths.Dynamic))
|
||||
foreach (string vehicle in Subfolders(maker))
|
||||
{
|
||||
string path = Path.Combine(vehicle, TexturesFileName);
|
||||
if (_files.FileExists(path))
|
||||
@@ -62,8 +61,19 @@ public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Sorted(IEnumerable<string> directories) =>
|
||||
directories.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase);
|
||||
// A folder that cannot be listed costs what is in it, not all that comes after it.
|
||||
private IReadOnlyList<string> Subfolders(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _files.GetDirectories(path).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"dynamic: skipping {path}", ex);
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one textures.txt; a file that will not parse is logged and skipped.</summary>
|
||||
public VehicleEntry? Read(string path)
|
||||
|
||||
@@ -43,21 +43,10 @@ public sealed class VehicleCacheRepository : IVehicleRepository
|
||||
_directory = directory;
|
||||
}
|
||||
|
||||
/// <summary>What the last load did, for the developer tab to report.</summary>
|
||||
public VehicleCacheStatus Status { get; private set; } = VehicleCacheStatus.None;
|
||||
|
||||
public int Load(VehicleCatalog catalog) => Load(catalog, useCopy: true);
|
||||
|
||||
/// <summary>Throws the copy away and reads the installation again.</summary>
|
||||
public int Rebuild(VehicleCatalog catalog) => Load(catalog, useCopy: false);
|
||||
|
||||
private int Load(VehicleCatalog catalog, bool useCopy)
|
||||
public int Load(VehicleCatalog catalog)
|
||||
{
|
||||
var (stamp, sources) = Stamp();
|
||||
var cached = useCopy ? Cached(stamp) : null;
|
||||
var entries = cached ?? Migrate(stamp);
|
||||
|
||||
Status = new VehicleCacheStatus(cached is not null, sources, WrittenAt(), _directory);
|
||||
string stamp = Stamp();
|
||||
var entries = Cached(stamp) ?? Migrate(stamp);
|
||||
|
||||
catalog.BeginLoad();
|
||||
|
||||
@@ -77,15 +66,13 @@ public sealed class VehicleCacheRepository : IVehicleRepository
|
||||
/// installation can move on - a file edited, added, removed, or the whole
|
||||
/// installation swapped for another one - shows up as a different text.
|
||||
/// </summary>
|
||||
private (string Text, int Sources) Stamp()
|
||||
private string Stamp()
|
||||
{
|
||||
var stamp = new StringBuilder().Append("root|").Append(Path.GetFullPath(_paths.Root)).Append('\n')
|
||||
.Append("parser|").Append(TexturesTxtParser.Version).Append('\n');
|
||||
int sources = 0;
|
||||
|
||||
foreach (string path in _legacy.Sources())
|
||||
{
|
||||
sources++;
|
||||
long ticks = 0;
|
||||
long size = 0;
|
||||
try
|
||||
@@ -102,20 +89,7 @@ public sealed class VehicleCacheRepository : IVehicleRepository
|
||||
.Append(path.Replace('\\', '/')).Append('\n');
|
||||
}
|
||||
|
||||
return (stamp.ToString(), sources);
|
||||
}
|
||||
|
||||
private DateTime? WrittenAt()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _files.FileExists(DatabasePath) ? _files.GetLastWriteTimeUtc(DatabasePath) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"{DatabaseFileName}: nie znam daty kopii", ex);
|
||||
return null;
|
||||
}
|
||||
return stamp.ToString();
|
||||
}
|
||||
|
||||
private List<VehicleEntry>? Cached(string stamp)
|
||||
@@ -179,12 +153,3 @@ public sealed class VehicleCacheRepository : IVehicleRepository
|
||||
|
||||
private string SourcesPath => Path.Combine(_directory, SourcesFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the catalogue was last filled: from the starter's copy or by reading the
|
||||
/// installation, over how many textures.txt, and when the copy was last written.
|
||||
/// </summary>
|
||||
public sealed record VehicleCacheStatus(bool ServedFromCopy, int Sources, DateTime? Written, string Directory)
|
||||
{
|
||||
public static readonly VehicleCacheStatus None = new(false, 0, null, "");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public static class ConsistContextMenu
|
||||
{
|
||||
/// <summary>A scenery consist's own file: written for good, or read back.</summary>
|
||||
public sealed record SceneryFileActions(Func<bool> IsModified, Action Save, Action Revert);
|
||||
|
||||
public static ContextMenu Build(
|
||||
Control owner,
|
||||
@@ -22,52 +28,60 @@ public static class ConsistContextMenu
|
||||
Func<Dynamic?>? getVehicle = null,
|
||||
Action<Dynamic>? removeVehicle = null,
|
||||
Action? randomOrder = null,
|
||||
Action? randomTurn = null)
|
||||
Action? randomTurn = null,
|
||||
Action? randomTextures = null,
|
||||
SceneryFileActions? sceneryFile = null,
|
||||
KeyGesture? removeAllGesture = null)
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
|
||||
menu.Opening += (_, _) =>
|
||||
{
|
||||
Populate(menu, owner, getTarget, applyVehicles, removeAll);
|
||||
Populate(menu, owner, getTarget, applyVehicles, removeAll, removeAllGesture);
|
||||
|
||||
if (randomOrder != null || randomTurn != null)
|
||||
// Changes not in the scenery file yet come first: the reason to open the menu.
|
||||
if (sceneryFile != null)
|
||||
{
|
||||
var schedule = new MenuItem { Header = App.Loc["ConsistSchedule"] };
|
||||
if (randomOrder != null)
|
||||
{
|
||||
var mo = new MenuItem { Header = App.Loc["RandomizeOrder"] };
|
||||
mo.Click += (_, _) => randomOrder();
|
||||
schedule.Items.Add(mo);
|
||||
}
|
||||
if (randomTurn != null)
|
||||
{
|
||||
var mt = new MenuItem { Header = App.Loc["RandomizeOrientation"] };
|
||||
mt.Click += (_, _) => randomTurn();
|
||||
schedule.Items.Add(mt);
|
||||
}
|
||||
menu.Items.Add(schedule);
|
||||
bool modified = sceneryFile.IsModified();
|
||||
menu.Items.Insert(0, MenuItems.Item(App.Loc["SaveToScenery"], sceneryFile.Save, modified,
|
||||
new KeyGesture(Key.S, KeyModifiers.Control)));
|
||||
menu.Items.Insert(1, MenuItems.Item(App.Loc["RevertFromScenery"], sceneryFile.Revert, modified));
|
||||
menu.Items.Insert(2, new Separator());
|
||||
}
|
||||
|
||||
if (removeVehicle == null || getVehicle?.Invoke() is not { } v) return;
|
||||
// Opened on a vehicle, the menu starts with what concerns that vehicle.
|
||||
if (removeVehicle != null && getVehicle?.Invoke() is { } v)
|
||||
{
|
||||
menu.Items.Insert(0, MenuItems.Item(App.Loc["RemoveVehicle"], () => removeVehicle(v)));
|
||||
menu.Items.Insert(1, new Separator());
|
||||
}
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
var mi = new MenuItem { Header = App.Loc["RemoveVehicle"] };
|
||||
mi.Click += (_, _) => removeVehicle(v);
|
||||
menu.Items.Add(mi);
|
||||
// Straight in the menu rather than under a submenu named like the list.
|
||||
bool hasVehicles = getTarget() is { Vehicles.Count: > 0 };
|
||||
if (randomOrder != null || randomTurn != null || randomTextures != null)
|
||||
menu.Items.Add(new Separator());
|
||||
AddAction(menu, "RandomizeOrder", randomOrder, hasVehicles);
|
||||
AddAction(menu, "RandomizeOrientation", randomTurn, hasVehicles);
|
||||
AddAction(menu, "RandomizeTextures", randomTextures, hasVehicles, ellipsis: true);
|
||||
};
|
||||
return menu;
|
||||
}
|
||||
|
||||
private static void AddAction(ContextMenu menu, string key, Action? run, bool enabled, bool ellipsis = false)
|
||||
{
|
||||
if (run is not null)
|
||||
menu.AddItem(App.Loc[key] + (ellipsis ? "…" : ""), run, enabled);
|
||||
}
|
||||
|
||||
private static void Populate(ContextMenu menu, Control owner,
|
||||
Func<Trainset?> getTarget, Action<List<Dynamic>> applyVehicles, Action? removeAll)
|
||||
Func<Trainset?> getTarget, Action<List<Dynamic>> applyVehicles, Action? removeAll,
|
||||
KeyGesture? removeAllGesture)
|
||||
{
|
||||
menu.Items.Clear();
|
||||
var target = getTarget();
|
||||
bool hasVehicles = target is { Vehicles.Count: > 0 };
|
||||
|
||||
var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles };
|
||||
save.Click += (_, _) => ShowSaveFlyout(owner, target);
|
||||
menu.Items.Add(save);
|
||||
menu.AddItem(App.Loc["AddToWarehouseNamed"], () => ShowSaveFlyout(owner, target), hasVehicles);
|
||||
|
||||
var load = new MenuItem { Header = App.Loc["PresetLoad"] };
|
||||
var presets = PresetStore.All();
|
||||
@@ -78,7 +92,7 @@ public static class ConsistContextMenu
|
||||
else
|
||||
{
|
||||
foreach (var preset in presets)
|
||||
load.Items.Add(BuildPresetItem(menu, preset, applyVehicles));
|
||||
load.Items.Add(BuildPresetItem(preset, target, applyVehicles));
|
||||
}
|
||||
menu.Items.Add(load);
|
||||
|
||||
@@ -92,116 +106,96 @@ public static class ConsistContextMenu
|
||||
foreach (var preset in presets)
|
||||
{
|
||||
var entry = preset;
|
||||
var mi = new MenuItem { Header = entry.Name };
|
||||
mi.Click += (_, _) =>
|
||||
append.AddItem(entry.Name, () =>
|
||||
{
|
||||
var added = ConsistText.VehiclesFrom(entry.Entry);
|
||||
if (added is null || added.Count == 0) return;
|
||||
var merged = target is null
|
||||
? added
|
||||
: new List<Dynamic>(target.Vehicles).Concat(added).ToList();
|
||||
applyVehicles(merged);
|
||||
};
|
||||
append.Items.Add(mi);
|
||||
applyVehicles(target is null ? added : Consist.Joined(target.Vehicles, added));
|
||||
});
|
||||
}
|
||||
}
|
||||
menu.Items.Add(append);
|
||||
|
||||
var sort = new MenuItem { Header = App.Loc["PresetSort"] };
|
||||
var sortName = new MenuItem
|
||||
{
|
||||
Header = (PresetStore.SortMode == PresetSort.Name ? "● " : "○ ") + App.Loc["PresetSortName"]
|
||||
};
|
||||
sortName.Click += (_, _) => PresetStore.SortMode = PresetSort.Name;
|
||||
var sortTrack = new MenuItem
|
||||
{
|
||||
Header = (PresetStore.SortMode == PresetSort.Track ? "● " : "○ ") + App.Loc["PresetSortTrack"]
|
||||
};
|
||||
sortTrack.Click += (_, _) => PresetStore.SortMode = PresetSort.Track;
|
||||
sort.Items.Add(sortName);
|
||||
sort.Items.Add(sortTrack);
|
||||
menu.Items.Add(sort);
|
||||
|
||||
var import = new MenuItem { Header = App.Loc["PresetImportMagazyn"] };
|
||||
import.Click += (_, _) => PresetStore.ImportMagazyn();
|
||||
menu.Items.Add(import);
|
||||
|
||||
// Sorting and importing the warehouse are the warehouse list's business.
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles };
|
||||
copy.Click += async (_, _) =>
|
||||
menu.AddItem(App.Loc["ConsistCopy"], async () =>
|
||||
{
|
||||
if (target != null && TopLevel.GetTopLevel(owner)?.Clipboard is { } cb)
|
||||
await cb.SetTextAsync(ConsistText.Serialize(target));
|
||||
};
|
||||
menu.Items.Add(copy);
|
||||
}, hasVehicles);
|
||||
|
||||
var paste = new MenuItem { Header = App.Loc["ConsistPaste"] };
|
||||
paste.Click += async (_, _) =>
|
||||
menu.AddItem(App.Loc["ConsistPaste"], async () =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(owner)?.Clipboard is not { } cb)
|
||||
return;
|
||||
string? text = await cb.TryGetTextAsync();
|
||||
var vehicles = ConsistText.VehiclesFrom(text);
|
||||
if (vehicles != null)
|
||||
applyVehicles(vehicles);
|
||||
};
|
||||
menu.Items.Add(paste);
|
||||
applyVehicles(KeepCrew(target, vehicles));
|
||||
});
|
||||
|
||||
if (removeAll != null)
|
||||
{
|
||||
menu.Items.Add(new Separator());
|
||||
var clear = new MenuItem
|
||||
{
|
||||
Header = App.Loc["ConsistRemoveAll"],
|
||||
InputGesture = new KeyGesture(Key.Delete, KeyModifiers.Shift),
|
||||
IsEnabled = hasVehicles
|
||||
};
|
||||
clear.Click += (_, _) => removeAll();
|
||||
menu.Items.Add(clear);
|
||||
menu.AddItem(App.Loc["ConsistRemoveAll"], removeAll, hasVehicles, removeAllGesture);
|
||||
}
|
||||
}
|
||||
|
||||
private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset,
|
||||
Action<List<Dynamic>> applyVehicles)
|
||||
/// <summary>Whether a consist may go into the warehouse under name, written over one it has - asked only then.</summary>
|
||||
public static async Task<bool> MayOverwritePresetAsync(Control owner, string? name)
|
||||
{
|
||||
var del = new Button
|
||||
{
|
||||
Content = "✕",
|
||||
Padding = new Thickness(6, 0),
|
||||
MinWidth = 0,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#D05050"))
|
||||
};
|
||||
del.Classes.Add("Basic");
|
||||
ToolTip.SetTip(del, App.Loc["PresetDelete"]);
|
||||
del.Click += (_, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
PresetStore.Delete(preset.Name);
|
||||
menu.Close();
|
||||
};
|
||||
string wanted = name?.Trim() ?? string.Empty;
|
||||
if (!PresetStore.All().Any(p => string.Equals(p.Name, wanted, StringComparison.OrdinalIgnoreCase)) ||
|
||||
TopLevel.GetTopLevel(owner) is not Window window)
|
||||
return true;
|
||||
|
||||
var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center };
|
||||
return await MessageBox.Show(window, string.Format(App.Loc["WarehouseOverwriteConfirm"], wanted),
|
||||
App.Loc["AddToWarehouseNamed"], MessageBoxButtons.YesNo);
|
||||
}
|
||||
|
||||
var dock = new DockPanel { MinWidth = 180 };
|
||||
DockPanel.SetDock(del, Dock.Right);
|
||||
dock.Children.Add(del);
|
||||
dock.Children.Add(label);
|
||||
/// <summary>Whether a warehouse consist may go for good, which this asks first.</summary>
|
||||
public static async Task<bool> MayRemovePresetAsync(Control owner, string name) =>
|
||||
TopLevel.GetTopLevel(owner) is not Window window ||
|
||||
await MessageBox.Show(window, string.Format(App.Loc["WarehouseRemoveConfirm"], name),
|
||||
App.Loc["RemoveFromWarehouse"], MessageBoxButtons.YesNo);
|
||||
|
||||
var item = new MenuItem { Header = dock };
|
||||
item.Click += (_, _) =>
|
||||
/// <summary>Vehicles about to replace those of target, with the kind of crew target has.</summary>
|
||||
public static List<Dynamic> KeepCrew(Trainset? target, List<Dynamic> vehicles)
|
||||
{
|
||||
var library = AppServices.Current.Library;
|
||||
return target is null
|
||||
? vehicles
|
||||
: Consist.LoadedOver(target.Vehicles, vehicles, library.Vehicles,
|
||||
new VehicleInfo(library.Vehicles, AppServices.Current.Physics));
|
||||
}
|
||||
|
||||
private static MenuItem BuildPresetItem(TrainsetPreset preset, Trainset? target, Action<List<Dynamic>> applyVehicles)
|
||||
{
|
||||
return MenuItems.Item(preset.Name, () =>
|
||||
{
|
||||
var vehicles = ConsistText.VehiclesFrom(preset.Entry);
|
||||
if (vehicles != null)
|
||||
applyVehicles(vehicles);
|
||||
};
|
||||
return item;
|
||||
applyVehicles(KeepCrew(target, vehicles));
|
||||
});
|
||||
}
|
||||
|
||||
private static void ShowSaveFlyout(Control owner, Trainset? target)
|
||||
/// <summary>A word next to the control, gone on its own after a moment.</summary>
|
||||
private static void ShowNote(Control owner, string text, bool atPointer)
|
||||
{
|
||||
var note = new Flyout { Content = new TextBlock { Text = text, Margin = new Thickness(8, 4) } };
|
||||
note.ShowAt(owner, showAtPointer: atPointer);
|
||||
DispatcherTimer.RunOnce(note.Hide, TimeSpan.FromSeconds(2.5));
|
||||
}
|
||||
|
||||
/// <summary>Asks for a name and puts the consist into the warehouse; <paramref name="saved"/> gets the name it went in under.</summary>
|
||||
public static void ShowSaveFlyout(Control owner, Trainset? target, bool atPointer = true, Action<string>? saved = null)
|
||||
{
|
||||
if (target is null || target.Vehicles.Count == 0)
|
||||
{
|
||||
ShowNote(owner, App.Loc["WarehouseNothingToAdd"], atPointer);
|
||||
return;
|
||||
}
|
||||
|
||||
var nameBox = new TextBox
|
||||
{
|
||||
@@ -224,10 +218,27 @@ public static class ConsistContextMenu
|
||||
|
||||
var flyout = new Flyout { Content = panel };
|
||||
|
||||
void Commit()
|
||||
async void Commit()
|
||||
{
|
||||
PresetStore.Save(nameBox.Text, ConsistText.Serialize(target));
|
||||
string name = nameBox.Text?.Trim() ?? "";
|
||||
if (name.Length == 0)
|
||||
return;
|
||||
|
||||
flyout.Hide();
|
||||
if (!await MayOverwritePresetAsync(owner, name))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
PresetStore.Save(name, ConsistText.Serialize(target));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex);
|
||||
return;
|
||||
}
|
||||
saved?.Invoke(name);
|
||||
ShowNote(owner, string.Format(App.Loc["WarehouseSaved"], name), atPointer);
|
||||
}
|
||||
|
||||
ok.Click += (_, _) => Commit();
|
||||
@@ -240,13 +251,16 @@ public static class ConsistContextMenu
|
||||
}
|
||||
};
|
||||
|
||||
flyout.ShowAt(owner, showAtPointer: true);
|
||||
flyout.ShowAt(owner, showAtPointer: atPointer);
|
||||
nameBox.Focus();
|
||||
nameBox.SelectAll();
|
||||
}
|
||||
|
||||
private static string DefaultName(Trainset trainset)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trainset.Name))
|
||||
return trainset.Name;
|
||||
|
||||
string joined = string.Join(" + ", trainset.Vehicles.Select(v => v.Name));
|
||||
return joined.Length > 40 ? joined[..40] : joined;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
x:CompileBindings="True"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
|
||||
x:Class="StarterNG.Views.Depot">
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.ConsistTool">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="22" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.ConsistTool:disabled">
|
||||
<Setter Property="Opacity" Value="0.35" />
|
||||
</Style>
|
||||
<Style Selector="Button.ConsistTool materialIcons|MaterialIcon">
|
||||
<Setter Property="Width" Value="16" />
|
||||
<Setter Property="Height" Value="16" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
<!-- TabIndex follows the workflow - pick a consist, browse, build the train,
|
||||
then the editors - not the column order the grid happens to have. -->
|
||||
<Grid Name="depotGrid" Margin="0" ColumnSpacing="3" RowSpacing="3">
|
||||
@@ -20,10 +39,13 @@
|
||||
but shrinks with everything else towards the 666 px floor instead of
|
||||
being clipped. The consist row measures itself, so a whole card fits
|
||||
whether the thumbnails are the small ones or the large ones. Row 1's
|
||||
cap fits the seven lines of the texture tab. -->
|
||||
cap fits the seven lines of the texture tab and the coupling tab with
|
||||
its buttons, and no more: what it leaves goes to the vehicle list.
|
||||
Row 0's floor is what the left column needs with a usable list; below
|
||||
it the list ran over the search box. Row 1's tabs scroll instead. -->
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" MinHeight="150" />
|
||||
<RowDefinition Height="1.57*" MinHeight="120" MaxHeight="240" />
|
||||
<RowDefinition Height="*" MinHeight="264" />
|
||||
<RowDefinition Height="1.57*" MinHeight="120" MaxHeight="172" />
|
||||
<RowDefinition Height="Auto" MinHeight="140" MaxHeight="230" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
@@ -68,9 +90,10 @@
|
||||
DoubleTapped="VehicleListBox_OnDoubleTapped"
|
||||
PointerPressed="VehicleListBox_OnPointerPressed" />
|
||||
|
||||
<!-- The thumbnail - drag it onto the consist - and Add share a line, so
|
||||
the list keeps the height. -->
|
||||
<Grid Grid.Row="5" ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,6,0,0">
|
||||
<!-- The thumbnail - drag it onto the consist - Replace and Add share a
|
||||
line, so the list keeps the height. Replace is what a double-click
|
||||
on the list does, as in the old starter, put where it is seen. -->
|
||||
<Grid Grid.Row="5" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="6" Margin="0,6,0,0">
|
||||
<Border Name="miniPreviewPanel" Height="34"
|
||||
Background="{StaticResource CarbonInputBg}"
|
||||
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
|
||||
@@ -78,7 +101,14 @@
|
||||
PointerPressed="MiniPreview_OnPointerPressed">
|
||||
<Image Name="miniPreview" Stretch="Uniform" Margin="3" />
|
||||
</Border>
|
||||
<Button Name="addVehicleButton" Grid.Column="1" Classes="Flat Accent"
|
||||
<Button Name="replaceVehicleButton" Grid.Column="1" Classes="Flat"
|
||||
Cursor="Hand" IsEnabled="{Binding #addVehicleButton.IsEnabled}"
|
||||
VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center"
|
||||
Click="ReplaceVehicleButton_OnClick"
|
||||
ToolTip.Tip="{Binding [TipReplaceVehicle], Source={x:Static starterNg:App.Loc}}"
|
||||
Content="{Binding [ReplaceVehicle], Source={x:Static starterNg:App.Loc}}" />
|
||||
<Button Name="addVehicleButton" Grid.Column="2" Classes="Flat Accent"
|
||||
MinWidth="80" Cursor="Hand" IsEnabled="False"
|
||||
VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center"
|
||||
@@ -94,7 +124,9 @@
|
||||
Background="{StaticResource CarbonPanelBg}"
|
||||
BorderBrush="{StaticResource CarbonBorderLight}"
|
||||
BorderThickness="1" CornerRadius="2" Padding="6">
|
||||
<TabControl>
|
||||
<!-- The tab picks what the consist strip edits: a consist of the scenery,
|
||||
or a consist kept in the warehouse. -->
|
||||
<TabControl Name="consistSourceTabs" SelectionChanged="ConsistSourceTabs_OnSelectionChanged">
|
||||
<TabItem Name="sceneryConsistsTab"
|
||||
Header="{Binding [SceneryConsists], Source={x:Static starterNg:App.Loc}}">
|
||||
<DockPanel LastChildFill="True">
|
||||
@@ -117,15 +149,18 @@
|
||||
KeyDown="SceneryConsistList_OnKeyDown" />
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<TabItem Header="{Binding [WarehouseConsists], Source={x:Static starterNg:App.Loc}}">
|
||||
<TabItem Name="warehouseTab"
|
||||
Header="{Binding [WarehouseConsists], Source={x:Static starterNg:App.Loc}}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button Name="removeFromWarehouseButton" DockPanel.Dock="Bottom" Classes="Flat"
|
||||
Margin="0,6,0,0" HorizontalAlignment="Stretch" Cursor="Hand"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="RemoveFromWarehouseButton_OnClick"
|
||||
Content="{Binding [RemoveFromWarehouse], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ListBox Name="warehouseList" Classes="Compact"
|
||||
DoubleTapped="WarehouseList_OnDoubleTapped" />
|
||||
<TextBlock DockPanel.Dock="Bottom" Margin="2,6,0,0" FontSize="12" Opacity="0.6"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding [WarehouseEditHint], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ListBox Name="warehouseList" Classes="Compact" />
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
@@ -206,13 +241,72 @@
|
||||
</TabControl>
|
||||
</Border>
|
||||
|
||||
<HeaderedContentControl Grid.Row="2" Grid.ColumnSpan="3" TabIndex="3" Theme="{StaticResource GroupBox}"
|
||||
<HeaderedContentControl Name="consistGroup" Grid.Row="2" Grid.ColumnSpan="3" TabIndex="3"
|
||||
Theme="{StaticResource GroupBox}"
|
||||
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
|
||||
<!-- Mass, length and the rest ride in the title bar instead of taking
|
||||
a row of their own above the cards. -->
|
||||
<HeaderedContentControl.Tag>
|
||||
<StackPanel Name="trainStats" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="16" VerticalAlignment="Center">
|
||||
<!-- What acts on the whole consist, where the consist is: undo first,
|
||||
then the random tools that were only in the list's menu. -->
|
||||
<StackPanel Name="consistTools" Orientation="Horizontal" Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<Button Name="undoButton" Classes="Basic ConsistTool" IsEnabled="False"
|
||||
Click="UndoButton_OnClick"
|
||||
ToolTip.Tip="{Binding [ConsistUndo], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="Undo" />
|
||||
</Button>
|
||||
<Button Name="redoButton" Classes="Basic ConsistTool" IsEnabled="False"
|
||||
Click="RedoButton_OnClick"
|
||||
ToolTip.Tip="{Binding [ConsistRedo], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="Redo" />
|
||||
</Button>
|
||||
<Border Width="1" Margin="6,3" Background="{StaticResource CarbonBorderLight}" />
|
||||
<Button Classes="Basic ConsistTool" Click="RandomOrderButton_OnClick"
|
||||
ToolTip.Tip="{Binding [TipRandomizeOrder], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="ShuffleVariant" />
|
||||
</Button>
|
||||
<Button Classes="Basic ConsistTool" Click="RandomOrientButton_OnClick"
|
||||
ToolTip.Tip="{Binding [TipRandomizeOrientation], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="SwapHorizontalVariant" />
|
||||
</Button>
|
||||
<Button Classes="Basic ConsistTool" Click="RandomTexturesButton_OnClick"
|
||||
ToolTip.Tip="{Binding [TipRandomizeTextures], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="Palette" />
|
||||
</Button>
|
||||
<Border Width="1" Margin="6,3" Background="{StaticResource CarbonBorderLight}" />
|
||||
<!-- A scenery consist only: saved into the scenery file, or put
|
||||
back as the file has it; a warehouse consist saves itself. -->
|
||||
<Button Name="saveToSceneryButton" Classes="Basic ConsistTool" IsEnabled="False"
|
||||
Click="SaveToSceneryButton_OnClick"
|
||||
ToolTip.Tip="{Binding [SaveToScenery], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="ContentSaveOutline" />
|
||||
</Button>
|
||||
<Button Name="revertSceneryButton" Classes="Basic ConsistTool" IsEnabled="False"
|
||||
Click="RevertSceneryButton_OnClick"
|
||||
ToolTip.Tip="{Binding [RevertFromScenery], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="FileRestoreOutline" />
|
||||
</Button>
|
||||
<Button Name="addToWarehouseToolButton" Classes="Basic ConsistTool"
|
||||
Click="AddToWarehouseButton_OnClick"
|
||||
ToolTip.Tip="{Binding [AddToWarehouse], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="ArchiveArrowDownOutline" />
|
||||
</Button>
|
||||
<Button Classes="Basic ConsistTool" Click="ClearConsistButton_OnClick"
|
||||
ToolTip.Tip="{Binding [ConsistRemoveAll], Source={x:Static starterNg:App.Loc}}">
|
||||
<materialIcons:MaterialIcon Kind="DeleteSweepOutline" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<!-- Read only: the scenery's author turns a trainset round, and
|
||||
the starter keeps it that way. -->
|
||||
<TextBlock Name="reversedBadge" IsVisible="False" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="#E0A030"
|
||||
Text="{Binding [ConsistReversed], Source={x:Static starterNg:App.Loc}}"
|
||||
ToolTip.Tip="{Binding [ConsistReversedTip], Source={x:Static starterNg:App.Loc}}" />
|
||||
<StackPanel Name="trainStats" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</HeaderedContentControl.Tag>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Panel Name="consistDropTarget">
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using StarterNG.Classes;
|
||||
@@ -54,6 +47,16 @@ public partial class Depot : UserControl
|
||||
|
||||
private Scenery? _listScenery;
|
||||
|
||||
private readonly ConsistHistory _history = new();
|
||||
|
||||
/// <summary>Set while an undo or redo puts a state back, which is not a change of its own.</summary>
|
||||
private bool _restoring;
|
||||
|
||||
private readonly WarehouseEdit _warehouseEdit = new();
|
||||
|
||||
private bool EditingWarehouse =>
|
||||
_warehouseEdit.Trainset is not null && ReferenceEquals(_consist.EditingTrainset, _warehouseEdit.Trainset);
|
||||
|
||||
public Depot()
|
||||
{
|
||||
_info = new VehicleInfo(_db, AppServices.Current.Physics);
|
||||
@@ -67,13 +70,19 @@ public partial class Depot : UserControl
|
||||
return;
|
||||
|
||||
_warehouse = new Warehouse(warehouseList, removeFromWarehouseButton, _hand,
|
||||
() => _consist.EditingTrainset, ApplyConsist);
|
||||
() => AppServices.Current.State.CurrentTrainset, ApplyFromWarehouse);
|
||||
_warehouse.SelectionChanged += () =>
|
||||
{
|
||||
if (WarehouseTabOpen)
|
||||
EditWarehouseSelection();
|
||||
};
|
||||
_warehouse.Renamed += _warehouseEdit.Renamed;
|
||||
|
||||
_browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox,
|
||||
hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis,
|
||||
() => TextureBaseButton_OnClick(null, null!))
|
||||
hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, OpenVehicleBase)
|
||||
{
|
||||
AddMany = (texture, count) => AddTexture(texture, count)
|
||||
AddMany = (texture, count) => AddTexture(texture, count),
|
||||
Replace = ReplaceSelected
|
||||
};
|
||||
|
||||
_drag = new ConsistDragging(consistStack, consistScroll, consistOverlay,
|
||||
@@ -83,7 +92,13 @@ public partial class Depot : UserControl
|
||||
_cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand,
|
||||
RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag)
|
||||
{
|
||||
AddCopies = AddCopiesAfter
|
||||
AddCopies = AddCopiesAfter,
|
||||
BrowserSelection = () => _browser.Selected,
|
||||
ReplaceWith = (item, texture) =>
|
||||
{
|
||||
_consist.Selected = item;
|
||||
ReplaceSelected(texture);
|
||||
}
|
||||
};
|
||||
|
||||
// Hidden still scrolls - it only drops the bar itself.
|
||||
@@ -92,8 +107,16 @@ public partial class Depot : UserControl
|
||||
_details = new VehicleDetails(
|
||||
generalPanel, consistTexturePanel, couplerPanel, couplerActions,
|
||||
brakesPanel, loadsPanel, damagePanel, removeVehicleButton,
|
||||
_db, _info, _consist, _cargo, _hand,
|
||||
RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo);
|
||||
_info, _consist, _cargo, _hand,
|
||||
RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo)
|
||||
{
|
||||
// A warehouse consist is kept in its file; a scenery one is the list itself.
|
||||
Edited = () =>
|
||||
{
|
||||
if (EditingWarehouse)
|
||||
_warehouseEdit.Save();
|
||||
}
|
||||
};
|
||||
|
||||
_browser.TextureSelected = _details.ShowTexture;
|
||||
|
||||
@@ -112,22 +135,101 @@ public partial class Depot : UserControl
|
||||
AttachedToVisualTree += (_, _) => SyncFromSelection();
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && IsVisible)
|
||||
if (e.Property != IsVisibleProperty)
|
||||
return;
|
||||
if (!IsVisible)
|
||||
{
|
||||
SyncFromSelection();
|
||||
_warehouse.Refresh();
|
||||
RememberConsistOnLeaving();
|
||||
return;
|
||||
}
|
||||
if (ConsistMovedSinceLeaving())
|
||||
ShowSceneryTab();
|
||||
SyncFromSelection();
|
||||
_warehouse.Refresh();
|
||||
};
|
||||
}
|
||||
|
||||
private Trainset? _leftTrainset;
|
||||
|
||||
private List<Dynamic> _leftVehicles = new();
|
||||
|
||||
private void RememberConsistOnLeaving()
|
||||
{
|
||||
_leftTrainset = AppServices.Current.State.CurrentTrainset;
|
||||
_leftVehicles = _leftTrainset?.Vehicles.ToList() ?? new List<Dynamic>();
|
||||
}
|
||||
|
||||
/// <summary>Another consist was picked or this one changed on another page: the warehouse tab left open would hide it.</summary>
|
||||
private bool ConsistMovedSinceLeaving() =>
|
||||
AppServices.Current.State.CurrentTrainset is { } trainset &&
|
||||
(!ReferenceEquals(trainset, _leftTrainset) || !trainset.Vehicles.SequenceEqual(_leftVehicles));
|
||||
|
||||
private void AddToWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
_ = _warehouse.SaveCurrentAsync();
|
||||
_warehouse.SaveCurrent((Control)sender!);
|
||||
|
||||
private void RemoveFromWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
_ = _warehouse.RemoveSelectedAsync();
|
||||
|
||||
private void WarehouseList_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) =>
|
||||
_warehouse.ApplySelected();
|
||||
private bool WarehouseTabOpen => ReferenceEquals(consistSourceTabs.SelectedItem, warehouseTab);
|
||||
|
||||
private void ConsistSourceTabs_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// The lists inside the tabs raise the same event, and it bubbles up here.
|
||||
if (!ReferenceEquals(e.Source, consistSourceTabs) || Design.IsDesignMode)
|
||||
return;
|
||||
|
||||
if (WarehouseTabOpen)
|
||||
{
|
||||
// A consist saved from a menu elsewhere went in while this list was not shown.
|
||||
_warehouse.Refresh();
|
||||
EditWarehouseSelection();
|
||||
}
|
||||
else
|
||||
EditSceneryConsist();
|
||||
}
|
||||
|
||||
/// <summary>The strip takes the warehouse consist selected, or goes empty without one.</summary>
|
||||
private void EditWarehouseSelection()
|
||||
{
|
||||
if (_warehouse.SelectedPreset() is not { } preset)
|
||||
{
|
||||
_warehouseEdit.Close();
|
||||
_consist.Unload();
|
||||
return;
|
||||
}
|
||||
|
||||
var trainset = _warehouseEdit.Open(preset);
|
||||
if (ReferenceEquals(_consist.EditingTrainset, trainset))
|
||||
RebuildConsist();
|
||||
else
|
||||
_consist.LoadFrom(trainset);
|
||||
}
|
||||
|
||||
/// <summary>Back on the scenery tab, the strip edits the consist that will be started.</summary>
|
||||
private void EditSceneryConsist()
|
||||
{
|
||||
_warehouseEdit.Close();
|
||||
if (AppServices.Current.State.CurrentTrainset is { } trainset)
|
||||
_consist.LoadFrom(trainset);
|
||||
else
|
||||
_consist.Unload();
|
||||
}
|
||||
|
||||
private void ShowSceneryTab()
|
||||
{
|
||||
if (WarehouseTabOpen)
|
||||
consistSourceTabs.SelectedItem = sceneryConsistsTab;
|
||||
}
|
||||
|
||||
/// <summary>From the warehouse's menu: shown where it went, on the scenery tab.</summary>
|
||||
private void ApplyFromWarehouse(Trainset target, List<Dynamic> vehicles)
|
||||
{
|
||||
ShowSceneryTab();
|
||||
ApplyConsist(target, vehicles);
|
||||
}
|
||||
|
||||
private bool IsSceneryTrainset(Trainset trainset) =>
|
||||
_listScenery?.Trainsets.Contains(trainset) == true;
|
||||
|
||||
private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) =>
|
||||
_browser.CategoryCombo_OnSelectionChanged(sender, e);
|
||||
@@ -169,6 +271,13 @@ public partial class Depot : UserControl
|
||||
private Dynamic NewVehicle(VehicleTexture texture) =>
|
||||
_consist.MakeDynamic(texture, _listScenery ?? AppServices.Current.State.CurrentScenery);
|
||||
|
||||
/// <summary>The scenery files changed under the page in view; out of view it catches up on its way back.</summary>
|
||||
public void FollowSceneryFiles()
|
||||
{
|
||||
if (IsVisible)
|
||||
SyncFromSelection();
|
||||
}
|
||||
|
||||
private void SyncFromSelection()
|
||||
{
|
||||
|
||||
@@ -177,25 +286,48 @@ public partial class Depot : UserControl
|
||||
PopulateSceneryConsists();
|
||||
|
||||
var trainset = AppServices.Current.State.CurrentTrainset;
|
||||
if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset))
|
||||
_consist.LoadFrom(trainset);
|
||||
else if (_consist.EditingTrainset != null)
|
||||
if (WarehouseTabOpen)
|
||||
RebuildConsist();
|
||||
else if (trainset is null)
|
||||
{
|
||||
// Another scenery, with no consist picked: the old one's is not edited on.
|
||||
if (_consist.EditingTrainset != null)
|
||||
_consist.Unload();
|
||||
}
|
||||
else if (!ReferenceEquals(trainset, _consist.EditingTrainset))
|
||||
_consist.LoadFrom(trainset);
|
||||
else
|
||||
{
|
||||
ReloadIfEditedElsewhere();
|
||||
RebuildConsist();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The strip holds the vehicles written back last. A list that no longer holds
|
||||
/// them was changed on the scenarios page or by a tool, and the next write-back
|
||||
/// would put the strip's old vehicles over that change.
|
||||
/// </summary>
|
||||
private void ReloadIfEditedElsewhere()
|
||||
{
|
||||
if (_consist.EditingTrainset is { } trainset && !_consist.Flatten().SequenceEqual(trainset.Vehicles))
|
||||
_consist.LoadFrom(trainset, _consist.Selected is { } selected ? Math.Max(_consist.IndexOf(selected), 0) : 0);
|
||||
}
|
||||
|
||||
public void RebuildAfterLanguageChange()
|
||||
{
|
||||
_browser.PopulateCategoryCombo();
|
||||
PopulateSceneryConsists();
|
||||
ReloadIfEditedElsewhere();
|
||||
RebuildConsist();
|
||||
_details.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>Redraws the consist cards, e.g. after the thumbnail size changed.</summary>
|
||||
/// <summary>Redraws what depends on the thumbnail size: the consist and the class list.</summary>
|
||||
public void RefreshConsistView()
|
||||
{
|
||||
_browser.InitClassComboTemplates();
|
||||
ReloadIfEditedElsewhere();
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
@@ -217,6 +349,9 @@ public partial class Depot : UserControl
|
||||
}
|
||||
|
||||
emptyHint.IsVisible = _consist.Count == 0;
|
||||
emptyHint.Text = _consist.EditingTrainset is not null
|
||||
? App.Loc["ConsistEmpty"]
|
||||
: App.Loc[WarehouseTabOpen ? "WarehousePickHint" : "ConsistPickHint"];
|
||||
|
||||
// A selection the consist moved on its own - after a removal, say - is only
|
||||
// highlighted; activating it runs what a click would have run.
|
||||
@@ -226,8 +361,10 @@ public partial class Depot : UserControl
|
||||
|
||||
// The stats are read off the trainset, so it is written back first.
|
||||
WriteBackToScenery();
|
||||
RecordHistory();
|
||||
_details.Refresh();
|
||||
UpdateTrainStats();
|
||||
UpdateConsistChrome();
|
||||
|
||||
// The cards are thrown away and rebuilt on every change, so a keyboard-driven
|
||||
// edit has to be handed its card back or focus escapes the strip.
|
||||
@@ -240,7 +377,154 @@ public partial class Depot : UserControl
|
||||
return;
|
||||
|
||||
_consist.EditingTrainset.Vehicles = _consist.Flatten();
|
||||
if (EditingWarehouse)
|
||||
{
|
||||
_warehouseEdit.Save();
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshConsistListLabels();
|
||||
|
||||
// The start button reads the crew off the trainset, which was not written
|
||||
// back yet when the selection named the vehicle to start in - without this
|
||||
// it went on saying "no crew" until a vehicle was clicked on the scenarios tab.
|
||||
if (ReferenceEquals(_consist.EditingTrainset, AppServices.Current.State.CurrentTrainset))
|
||||
AppServices.Current.State.NotifyChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The title says which consist the strip edits; the scenery file's tools and
|
||||
/// "add to warehouse" are for a scenery consist only.
|
||||
/// </summary>
|
||||
private void UpdateConsistChrome()
|
||||
{
|
||||
bool warehouse = EditingWarehouse;
|
||||
consistGroup.Header = warehouse
|
||||
? string.Format(App.Loc["ConsistFromWarehouse"], _warehouseEdit.Name)
|
||||
: App.Loc["Consist"];
|
||||
|
||||
var trainset = _consist.EditingTrainset;
|
||||
bool scenery = trainset is not null && !warehouse && IsSceneryTrainset(trainset);
|
||||
bool modified = scenery && SceneryTrainsetText.IsModified(trainset!);
|
||||
bool sceneryTab = !WarehouseTabOpen;
|
||||
saveToSceneryButton.IsVisible = sceneryTab;
|
||||
revertSceneryButton.IsVisible = sceneryTab;
|
||||
addToWarehouseToolButton.IsVisible = sceneryTab;
|
||||
saveToSceneryButton.IsEnabled = modified;
|
||||
revertSceneryButton.IsEnabled = modified;
|
||||
|
||||
reversedBadge.IsVisible = trainset?.Reversed == true;
|
||||
}
|
||||
|
||||
private void SaveToSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
_ = SaveToSceneryAsync(_consist.EditingTrainset);
|
||||
|
||||
private void RevertSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
RevertFromScenery(_consist.EditingTrainset);
|
||||
|
||||
public void KeySaveToScenery()
|
||||
{
|
||||
if (!EditingWarehouse)
|
||||
_ = SaveToSceneryAsync(_consist.EditingTrainset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the consist into the scenery file for good. Asked first only while
|
||||
/// the file has no backup yet - the first time it is about to change.
|
||||
/// </summary>
|
||||
private async Task SaveToSceneryAsync(Trainset? trainset)
|
||||
{
|
||||
if (trainset is null || _listScenery is not { } scenery || !IsSceneryTrainset(trainset) ||
|
||||
!SceneryTrainsetText.IsModified(trainset) || TopLevel.GetTopLevel(this) is not Window owner)
|
||||
return;
|
||||
|
||||
// A consist from an included file is saved there - shared, maybe, by other scenarios.
|
||||
string path = SaveSceneryTrainset.FileOf(scenery, trainset);
|
||||
string file = trainset.SourceFile ?? Path.GetFileName(scenery.Path);
|
||||
if (!AppServices.Current.Files.FileExists(path + SaveSceneryTrainset.BackupSuffix) &&
|
||||
!await MessageBox.Show(owner,
|
||||
string.Format(App.Loc["SaveToSceneryConfirm"], file, file + SaveSceneryTrainset.BackupSuffix),
|
||||
App.Loc["SaveToScenery"], MessageBoxButtons.YesNo))
|
||||
return;
|
||||
|
||||
var (outcome, detail) = AppServices.Current.SaveTrainset.Execute(scenery, trainset);
|
||||
switch (outcome)
|
||||
{
|
||||
case SceneryTrainsetSaveOutcome.NotFound:
|
||||
await StarterNG.Infrastructure.Diagnostics.ReportAsync(
|
||||
string.Format(App.Loc["SaveToSceneryNotFound"], file));
|
||||
break;
|
||||
case SceneryTrainsetSaveOutcome.Failed:
|
||||
await StarterNG.Infrastructure.Diagnostics.ReportAsync(
|
||||
$"{path}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {detail}");
|
||||
break;
|
||||
}
|
||||
|
||||
RefreshConsistListLabels();
|
||||
UpdateConsistChrome();
|
||||
}
|
||||
|
||||
/// <summary>The consist as its scenery file has it - one more step to undo.</summary>
|
||||
private void RevertFromScenery(Trainset? trainset)
|
||||
{
|
||||
if (trainset is null || !IsSceneryTrainset(trainset) || !SceneryTrainsetText.IsModified(trainset))
|
||||
return;
|
||||
|
||||
var saved = new Trainset(trainset.RawEntry);
|
||||
trainset.Vehicles = saved.Vehicles;
|
||||
trainset.Velocity = saved.Velocity;
|
||||
if (ReferenceEquals(_consist.EditingTrainset, trainset))
|
||||
_consist.LoadFrom(trainset);
|
||||
else
|
||||
RefreshConsistListLabels();
|
||||
}
|
||||
|
||||
private int SelectedIndex() =>
|
||||
_consist.Selected is { } item ? _consist.IndexOf(item) : 0;
|
||||
|
||||
private void RecordHistory()
|
||||
{
|
||||
if (_consist.EditingTrainset is { } trainset)
|
||||
_history.Record(trainset, SelectedIndex(), rebase: _restoring);
|
||||
|
||||
var editing = _consist.EditingTrainset;
|
||||
undoButton.IsEnabled = editing is not null && _history.CanUndo(editing);
|
||||
redoButton.IsEnabled = editing is not null && _history.CanRedo(editing);
|
||||
}
|
||||
|
||||
public void Undo() => Restore(_history.Undo);
|
||||
|
||||
public void Redo() => Restore(_history.Redo);
|
||||
|
||||
private void Restore(Func<Trainset, int, ConsistHistory.State?> step)
|
||||
{
|
||||
if (_consist.EditingTrainset is not { } trainset ||
|
||||
step(trainset, SelectedIndex()) is not { } state)
|
||||
return;
|
||||
|
||||
trainset.Vehicles = state.Vehicles.ToList();
|
||||
_restoring = true;
|
||||
try
|
||||
{
|
||||
_consist.LoadFrom(trainset, state.Selected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UndoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Undo();
|
||||
|
||||
private void RedoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Redo();
|
||||
|
||||
private void ClearConsistButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
KeyClearConsist();
|
||||
|
||||
private void ReplaceVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_browser.Selected != null)
|
||||
ReplaceSelected(_browser.Selected);
|
||||
}
|
||||
|
||||
private void UpdateTrainStats()
|
||||
@@ -254,15 +538,12 @@ public partial class Depot : UserControl
|
||||
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
|
||||
_consist.EditingTrainset,
|
||||
_info.PhysicsFor,
|
||||
car => _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile) is { } t ? VehicleInfo.CategoryOf(t) : null,
|
||||
_info.CategoryOf,
|
||||
AppServices.Current.LoadWeights.Table.WeightOf));
|
||||
}
|
||||
|
||||
private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_consist.Selected is { } item)
|
||||
_consist.Remove(item);
|
||||
}
|
||||
private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
KeyRemoveVehicle();
|
||||
|
||||
private void ConsistFilter_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
@@ -292,8 +573,8 @@ public partial class Depot : UserControl
|
||||
|
||||
foreach (var trainset in _listScenery.Trainsets)
|
||||
{
|
||||
if (IsAiTrainset(trainset) && !showAi) continue;
|
||||
if (drivableOnly && !UData.StaffedTrain(trainset)) continue;
|
||||
if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, AppServices.Current.State.CurrentTrainset))
|
||||
continue;
|
||||
|
||||
var entry = new ListBoxItem
|
||||
{
|
||||
@@ -302,13 +583,29 @@ public partial class Depot : UserControl
|
||||
};
|
||||
|
||||
var ts = trainset;
|
||||
|
||||
// The random tools work on the consist being edited, which is not
|
||||
// necessarily the entry the menu was opened on.
|
||||
void Edit(Action run)
|
||||
{
|
||||
if (!ReferenceEquals(_consist.EditingTrainset, ts))
|
||||
sceneryConsistList.SelectedItem = entry;
|
||||
run();
|
||||
}
|
||||
|
||||
entry.ContextMenu = ConsistContextMenu.Build(
|
||||
entry,
|
||||
() => ts,
|
||||
vehicles => ApplyConsist(ts, vehicles),
|
||||
() => RemoveAllVehicles(ts),
|
||||
randomOrder: ToolRandomOrder,
|
||||
randomTurn: ToolRandomOrientation);
|
||||
randomOrder: () => Edit(() => _consist.RandomizeOrder(_rng)),
|
||||
randomTurn: () => Edit(() => _consist.RandomizeOrientation(_rng)),
|
||||
randomTextures: () => Edit(() => _ = RunTextureRandomizer()),
|
||||
removeAllGesture: new KeyGesture(Key.Delete, KeyModifiers.Control),
|
||||
sceneryFile: new ConsistContextMenu.SceneryFileActions(
|
||||
() => SceneryTrainsetText.IsModified(ts),
|
||||
() => _ = SaveToSceneryAsync(ts),
|
||||
() => RevertFromScenery(ts)));
|
||||
sceneryConsistList.Items.Add(entry);
|
||||
|
||||
if (ReferenceEquals(trainset, AppServices.Current.State.CurrentTrainset))
|
||||
@@ -330,10 +627,6 @@ public partial class Depot : UserControl
|
||||
_consist.LoadFrom(trainset);
|
||||
}
|
||||
|
||||
private static bool IsAiTrainset(Trainset trainset) =>
|
||||
!string.IsNullOrEmpty(trainset.Description) &&
|
||||
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||
|
||||
private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
||||
@@ -348,35 +641,52 @@ public partial class Depot : UserControl
|
||||
private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
|
||||
{
|
||||
target.Vehicles = vehicles;
|
||||
AppServices.Current.State.CurrentScenery = _listScenery;
|
||||
AppServices.Current.State.CurrentTrainset = target;
|
||||
_consist.LoadFrom(target);
|
||||
PopulateSceneryConsists();
|
||||
EditTrainset(target);
|
||||
}
|
||||
|
||||
private void RemoveAllVehicles(Trainset target)
|
||||
{
|
||||
target.Vehicles = new List<Dynamic>();
|
||||
EditTrainset(target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scenery consist becomes the one started as well; a warehouse consist is
|
||||
/// only edited.
|
||||
/// </summary>
|
||||
private void EditTrainset(Trainset target)
|
||||
{
|
||||
if (!IsSceneryTrainset(target))
|
||||
{
|
||||
_consist.LoadFrom(target);
|
||||
return;
|
||||
}
|
||||
|
||||
AppServices.Current.State.CurrentScenery = _listScenery;
|
||||
AppServices.Current.State.CurrentTrainset = target;
|
||||
_consist.LoadFrom(target);
|
||||
PopulateSceneryConsists();
|
||||
}
|
||||
|
||||
private static Control ConsistListContent(Trainset trainset) =>
|
||||
new TextBlock
|
||||
{
|
||||
Text = ConsistLabel(trainset),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap
|
||||
};
|
||||
|
||||
private static string ConsistLabel(Trainset trainset)
|
||||
/// <summary>The composition, after a dot for a consist the scenery file does not have yet.</summary>
|
||||
private static Control ConsistListContent(Trainset trainset)
|
||||
{
|
||||
string? text = TrainsetDisplay.CompositionText(trainset);
|
||||
if (text != null)
|
||||
return text;
|
||||
return string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
|
||||
var label = Scenarios.TrainsetListContent(trainset);
|
||||
if (!SceneryTrainsetText.IsModified(trainset))
|
||||
return label;
|
||||
|
||||
var dot = new TextBlock
|
||||
{
|
||||
Text = "●",
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#E0A030"))
|
||||
};
|
||||
ToolTip.SetTip(dot, App.Loc["ConsistUnsaved"]);
|
||||
var row = new DockPanel();
|
||||
DockPanel.SetDock(dot, Dock.Left);
|
||||
row.Children.Add(dot);
|
||||
row.Children.Add(label);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void RefreshConsistListLabels()
|
||||
@@ -388,22 +698,6 @@ public partial class Depot : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RandomizeCurrentTrainsetAsync()
|
||||
{
|
||||
if (AppServices.Current.State.CurrentTrainset is not { } trainset)
|
||||
return;
|
||||
|
||||
if (!ReferenceEquals(trainset, _consist.EditingTrainset))
|
||||
{
|
||||
PopulateSceneryConsists();
|
||||
_consist.LoadFrom(trainset);
|
||||
}
|
||||
await RunTextureRandomizer();
|
||||
}
|
||||
|
||||
public void ToolRandomOrder() => RandomOrderButton_OnClick(null, null!);
|
||||
public void ToolRandomOrientation() => RandomOrientButton_OnClick(null, null!);
|
||||
|
||||
private async void RandomTexturesButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
await RunTextureRandomizer();
|
||||
|
||||
@@ -464,20 +758,27 @@ public partial class Depot : UserControl
|
||||
|
||||
private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
if (_browser.Selected != null)
|
||||
// On a row only: the list's scrollbar takes double clicks too.
|
||||
if (_browser.Selected != null &&
|
||||
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true) != null)
|
||||
ReplaceSelected(_browser.Selected);
|
||||
}
|
||||
|
||||
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
KeyAddVehicle();
|
||||
|
||||
/// <summary>Keyboard shortcut entry points. See <see cref="Infrastructure.Shortcuts"/>.</summary>
|
||||
public void KeyAddVehicle()
|
||||
{
|
||||
if (_browser.Selected != null)
|
||||
AddTexture(_browser.Selected);
|
||||
}
|
||||
|
||||
/// <summary>Keyboard shortcut entry points. See <see cref="Infrastructure.Shortcuts"/>.</summary>
|
||||
public void KeyAddVehicle() => AddVehicleButton_OnClick(null, null!);
|
||||
|
||||
public void KeyRemoveVehicle() => RemoveVehicleButton_OnClick(null, null!);
|
||||
public void KeyRemoveVehicle()
|
||||
{
|
||||
if (_consist.Selected is { } item)
|
||||
_consist.Remove(item);
|
||||
}
|
||||
|
||||
public void KeyClearConsist()
|
||||
{
|
||||
@@ -493,36 +794,36 @@ public partial class Depot : UserControl
|
||||
searchBox.SelectAll();
|
||||
}
|
||||
|
||||
public void ToolAddAllCategory() => AddAllCategoryButton_OnClick(null, null!);
|
||||
// The tools are reached from any page, and this view catches up with the
|
||||
// selection only when shown - so they do that first, or they would work on the
|
||||
// scenery it showed last. They are about the scenery, so never on the warehouse's.
|
||||
private void PrepareForTool()
|
||||
{
|
||||
ShowSceneryTab();
|
||||
SyncFromSelection();
|
||||
}
|
||||
|
||||
public void ToolAddUniqueMmd() => AddUniqueMmdButton_OnClick(null, null!);
|
||||
|
||||
public void ToolRemoveAllTrains() => RemoveAllTrainsButton_OnClick(null, null!);
|
||||
|
||||
private async void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
/// <summary>What the browser lists, for a tool to add; with nothing listed it says what to pick.</summary>
|
||||
private List<VehicleTexture> TexturesForTool()
|
||||
{
|
||||
var all = _browser.CurrentTextures();
|
||||
if (all.Count == 0)
|
||||
{
|
||||
await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]);
|
||||
return;
|
||||
}
|
||||
_ = StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]);
|
||||
return all;
|
||||
}
|
||||
|
||||
foreach (var t in all)
|
||||
public void ToolAddAllCategory()
|
||||
{
|
||||
PrepareForTool();
|
||||
foreach (var t in TexturesForTool())
|
||||
InsertTextureAt(t, _consist.Count);
|
||||
}
|
||||
|
||||
private async void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
public void ToolAddUniqueMmd()
|
||||
{
|
||||
var all = _browser.CurrentTextures();
|
||||
if (all.Count == 0)
|
||||
{
|
||||
await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]);
|
||||
return;
|
||||
}
|
||||
|
||||
PrepareForTool();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var t in all)
|
||||
foreach (var t in TexturesForTool())
|
||||
{
|
||||
foreach (string model in ModelsOf(t))
|
||||
{
|
||||
@@ -547,22 +848,34 @@ public partial class Depot : UserControl
|
||||
yield return alias.Model!;
|
||||
}
|
||||
|
||||
private async void RemoveAllTrainsButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
public async Task ToolRemoveAllTrainsAsync()
|
||||
{
|
||||
if (_listScenery is null || TopLevel.GetTopLevel(this) is not Window owner)
|
||||
PrepareForTool();
|
||||
if (TopLevel.GetTopLevel(this) is not Window owner)
|
||||
return;
|
||||
|
||||
// Not the first one on the list: a scenery nobody picked is not emptied.
|
||||
var state = AppServices.Current.State;
|
||||
if (state.CurrentScenery is not { } scenery)
|
||||
{
|
||||
await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["NoSceneryConsists"]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await MessageBox.Show(owner, App.Loc["RemoveAllTrainsConfirm"],
|
||||
App.Loc["RemoveAllTrains"], MessageBoxButtons.YesNo))
|
||||
return;
|
||||
|
||||
foreach (var ts in _listScenery.Trainsets)
|
||||
ts.Vehicles = new List<Dynamic>();
|
||||
foreach (var ts in scenery.Trainsets)
|
||||
ts.RemoveVehicles();
|
||||
|
||||
if (_consist.EditingTrainset != null)
|
||||
_consist.LoadFrom(_consist.EditingTrainset);
|
||||
// As in the old starter nothing stays picked, so a start is the free camera.
|
||||
state.StartingVehicleName = null;
|
||||
state.CurrentTrainset = null;
|
||||
_consist.Unload();
|
||||
PopulateSceneryConsists();
|
||||
RebuildConsist();
|
||||
state.NotifyChanged();
|
||||
}
|
||||
|
||||
private void ReplaceSelected(VehicleTexture texture)
|
||||
@@ -607,7 +920,7 @@ public partial class Depot : UserControl
|
||||
private void AddCopiesAfter(ConsistItem item, int count)
|
||||
{
|
||||
int index = _consist.IndexOf(item);
|
||||
if (index < 0 || item.Cars.Count == 0 || _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is not { } texture)
|
||||
if (index < 0 || item.Cars.Count == 0 || _info.TextureFor(item.Cars[0]) is not { } texture)
|
||||
return;
|
||||
|
||||
InsertTexturesAt(texture, index + 1, count);
|
||||
@@ -618,6 +931,11 @@ public partial class Depot : UserControl
|
||||
/// <summary>The vehicle count times in a row from at - each staffed as added - and one redraw.</summary>
|
||||
private void InsertTexturesAt(VehicleTexture texture, int at, int count)
|
||||
{
|
||||
// With no consist picked the vehicles would be nowhere but on the strip, and
|
||||
// gone with its next load. The strip's hint says what to pick.
|
||||
if (_consist.EditingTrainset is null)
|
||||
return;
|
||||
|
||||
var set = _db.ResolveSet(texture);
|
||||
var unit = set ?? new List<VehicleTexture> { texture };
|
||||
|
||||
@@ -641,12 +959,21 @@ public partial class Depot : UserControl
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
private async void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
private void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||
OpenVehicleBase();
|
||||
|
||||
private async void OpenVehicleBase()
|
||||
{
|
||||
if (TopLevel.GetTopLevel(this) is not Window owner) return;
|
||||
var win = new TextureBaseWindow();
|
||||
var win = new VehicleBaseWindow();
|
||||
await win.ShowDialog(owner);
|
||||
if (win.Picked != null)
|
||||
_browser.SyncHideArchival();
|
||||
if (win.Picked is null)
|
||||
return;
|
||||
|
||||
if (win.PickedToAdd)
|
||||
AddTexture(win.Picked);
|
||||
else
|
||||
ReplaceSelected(win.Picked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public static class CouplingBits
|
||||
@@ -6,3 +9,17 @@ public static class CouplingBits
|
||||
{ "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage",
|
||||
"CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" };
|
||||
}
|
||||
|
||||
public static class CrewChoices
|
||||
{
|
||||
public static readonly (eDriverType Type, string Key)[] All =
|
||||
{
|
||||
(eDriverType.Headdriver, "DriverHead"), (eDriverType.Reardriver, "DriverRear"),
|
||||
(eDriverType.Passenger, "DriverPassenger"), (eDriverType.Nobody, "DriverNobody")
|
||||
};
|
||||
|
||||
public static int IndexOf(eDriverType type) => Array.FindIndex(All, choice => choice.Type == type);
|
||||
|
||||
/// <summary>The crew in that place of the list; nobody for no place at all, as a list with nothing selected has.</summary>
|
||||
public static eDriverType At(int index) => index >= 0 && index < All.Length ? All[index].Type : eDriverType.Nobody;
|
||||
}
|
||||
|
||||
29
StarterNG/Views/Depot/TextureMenuItems.cs
Normal file
29
StarterNG/Views/Depot/TextureMenuItems.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.IO;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
/// <summary>What the lists of liveries offer about a livery itself.</summary>
|
||||
internal static class TextureMenuItems
|
||||
{
|
||||
/// <param name="owner">A control of the window, for its clipboard.</param>
|
||||
public static void Add(ContextMenu menu, Control owner, VehicleTexture texture)
|
||||
{
|
||||
menu.AddItem(App.Loc["CopyTextureName"], async () =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard)
|
||||
await clipboard.SetTextAsync(Consist.Base(texture.Skinfile));
|
||||
});
|
||||
menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(FolderOf(texture)));
|
||||
}
|
||||
|
||||
private static string FolderOf(VehicleTexture texture)
|
||||
{
|
||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
|
||||
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
||||
return Directory.Exists(dir) ? dir : Path.Combine(Directory.GetCurrentDirectory(), texture.Directory);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Controls;
|
||||
@@ -45,7 +41,7 @@ public sealed class VehicleBrowser
|
||||
_suppress = true;
|
||||
hideArchivalCheck.IsChecked = hideArchival;
|
||||
_suppress = false;
|
||||
Rebuild();
|
||||
PopulateCategoryCombo();
|
||||
}
|
||||
|
||||
public VehicleTexture? Selected { get; private set; }
|
||||
@@ -55,6 +51,9 @@ public sealed class VehicleBrowser
|
||||
/// <summary>Adds the vehicle to the consist the given number of times.</summary>
|
||||
public Action<VehicleTexture, int>? AddMany { get; set; }
|
||||
|
||||
/// <summary>Puts the vehicle in place of the one selected in the consist.</summary>
|
||||
public Action<VehicleTexture>? Replace { get; set; }
|
||||
|
||||
private bool _suppress;
|
||||
private bool _syncingCombos;
|
||||
|
||||
@@ -96,7 +95,7 @@ public sealed class VehicleBrowser
|
||||
|
||||
|
||||
// In the old starter's order: traction first, then the wagons, then the rest.
|
||||
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
|
||||
internal static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
|
||||
{
|
||||
("CatElectricLoco", c => c == "e"),
|
||||
("CatDieselLoco", c => c == "s"),
|
||||
@@ -202,10 +201,10 @@ public sealed class VehicleBrowser
|
||||
!(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived);
|
||||
|
||||
/// <summary>
|
||||
/// The classes with their thumbnail above the name: in the open list at the size
|
||||
/// the consist cards use, called again when that size changes; in the closed box
|
||||
/// at a fixed modest one, so the class picked is seen, as in the old starter,
|
||||
/// without taking the height the vehicle list needs.
|
||||
/// The classes with their thumbnail: above the name in the open list, at the size
|
||||
/// the consist cards use, called again when that size changes; beside it in the
|
||||
/// closed box at a fixed modest one, so the class picked is seen, as in the old
|
||||
/// starter, without taking the height the vehicle list needs.
|
||||
/// </summary>
|
||||
public void InitClassComboTemplates()
|
||||
{
|
||||
@@ -213,7 +212,7 @@ public sealed class VehicleBrowser
|
||||
classCombo.ItemTemplate = new FuncDataTemplate<string>(
|
||||
(cls, _) => ClassComboContent(cls, height, stacked: true), false);
|
||||
classCombo.SelectionBoxItemTemplate = new FuncDataTemplate<string>(
|
||||
(cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: true), false);
|
||||
(cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: false), false);
|
||||
}
|
||||
|
||||
private const int PickedClassThumbHeight = 30;
|
||||
@@ -363,6 +362,7 @@ public sealed class VehicleBrowser
|
||||
var row = new ListBoxItem
|
||||
{
|
||||
Content = BrowserLabel(texture, _db.ResolveSet(texture)),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
Tag = texture
|
||||
};
|
||||
vehicleListBox.Items.Add(row);
|
||||
@@ -415,60 +415,26 @@ public sealed class VehicleBrowser
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
|
||||
var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddMany is not null };
|
||||
addMany.Click += (_, _) =>
|
||||
AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count));
|
||||
menu.Items.Add(addMany);
|
||||
// What the buttons under the list do, for the vehicle the menu was opened on.
|
||||
menu.AddItem(App.Loc["AddVehicle"], () => AddMany?.Invoke(texture, 1), AddMany is not null,
|
||||
new KeyGesture(Key.Insert));
|
||||
menu.AddItem(App.Loc["ReplaceVehicle"], () => Replace?.Invoke(texture), Replace is not null);
|
||||
menu.AddItem(App.Loc["AddMany"],
|
||||
() => AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count)),
|
||||
AddMany is not null);
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
var copy = new MenuItem { Header = App.Loc["CopyTextureName"] };
|
||||
copy.Click += async (_, _) =>
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(vehicleListBox);
|
||||
if (top?.Clipboard != null)
|
||||
await top.Clipboard.SetTextAsync(BrowserName(texture));
|
||||
};
|
||||
menu.Items.Add(copy);
|
||||
|
||||
var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] };
|
||||
open.Click += (_, _) =>
|
||||
{
|
||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
|
||||
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
||||
if (!Directory.Exists(dir))
|
||||
dir = Path.Combine(Directory.GetCurrentDirectory(), texture.Directory);
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
|
||||
string.Format(App.Loc["FaultFileNotFound"], dir));
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
|
||||
$"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}");
|
||||
}
|
||||
};
|
||||
menu.Items.Add(open);
|
||||
TextureMenuItems.Add(menu, vehicleListBox, texture);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
var baseItem = new MenuItem { Header = App.Loc["OpenTextureBase"] };
|
||||
baseItem.Click += (_, _) => _openTextureBase();
|
||||
menu.Items.Add(baseItem);
|
||||
menu.AddItem(App.Loc["OpenTextureBase"], _openTextureBase);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a detail panel with what is known about a livery: the skin itself,
|
||||
/// then whatever its credit line carries.
|
||||
/// Fills a detail panel with what is known about a livery: the model it
|
||||
/// dresses, then whatever its credit line carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built from the same <see cref="DetailRows"/> as the general tab, so the
|
||||
@@ -491,7 +457,7 @@ public sealed class VehicleBrowser
|
||||
tooltip.Add($"{label}: {value}");
|
||||
}
|
||||
|
||||
Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile));
|
||||
// No line for the skin's name: the general tab and the list both carry it.
|
||||
Reading(App.Loc["Model"], texture.Directory + texture.Model);
|
||||
|
||||
if (texture.Meta is { } meta)
|
||||
@@ -557,20 +523,35 @@ public sealed class VehicleBrowser
|
||||
// called a vehicle something none of the other panels did.
|
||||
private static string BrowserName(VehicleTexture texture) => Consist.Base(texture.Skinfile);
|
||||
|
||||
// The file name alone, as the old starter listed it - the operator is in the
|
||||
// texture panel - with a faint count for a set of cars.
|
||||
// The file name, as the old starter listed it, with a faint count for a set
|
||||
// of cars and the operator at the far end - liveries of one vehicle differ
|
||||
// mostly by who runs them.
|
||||
private static Control BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
|
||||
{
|
||||
var name = new TextBlock { Text = BrowserName(texture) };
|
||||
if (set is not { Count: > 1 })
|
||||
return name;
|
||||
// Clear of the scroll bar, which lies over the right edge of the list.
|
||||
var row = new DockPanel { Margin = new Thickness(0, 0, 10, 0) };
|
||||
|
||||
return new StackPanel
|
||||
string? op = texture.Meta?.Operator;
|
||||
if (!string.IsNullOrWhiteSpace(op) && op != "?")
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 6,
|
||||
Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } }
|
||||
};
|
||||
var operatorText = new TextBlock
|
||||
{
|
||||
Text = op,
|
||||
Opacity = 0.55,
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
};
|
||||
DockPanel.SetDock(operatorText, Dock.Right);
|
||||
row.Children.Add(operatorText);
|
||||
}
|
||||
|
||||
var name = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
|
||||
name.Children.Add(new TextBlock { Text = BrowserName(texture) });
|
||||
if (set is { Count: > 1 })
|
||||
name.Children.Add(new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 });
|
||||
row.Children.Add(name);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
|
||||
|
||||
@@ -36,6 +36,11 @@ public sealed class VehicleCards
|
||||
/// <summary>Puts copies of the vehicle on a card right after it, the given number of times.</summary>
|
||||
public Action<ConsistItem, int>? AddCopies { get; set; }
|
||||
|
||||
/// <summary>The livery picked in the vehicle browser, offered as the card's replacement.</summary>
|
||||
public Func<VehicleTexture?>? BrowserSelection { get; set; }
|
||||
|
||||
public Action<ConsistItem, VehicleTexture>? ReplaceWith { get; set; }
|
||||
|
||||
private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new();
|
||||
|
||||
public Dynamic? PressedCar { get; set; }
|
||||
@@ -113,9 +118,7 @@ public sealed class VehicleCards
|
||||
|
||||
if (item.Grouped && item.Cars.Count > 1)
|
||||
controls.Children.Add(SmallButton(MaterialIconKind.CallSplit, App.Loc["TipSplit"], () => _consist.Split(item)));
|
||||
else if ((index + 1 < _consist.Count &&
|
||||
(Consist.HoldsTail(item) || _consist.CanFormUnit(item, _consist[index + 1]))) ||
|
||||
(index > 0 && Consist.HoldsTail(_consist[index - 1])))
|
||||
else if (_consist.CanJoin(item, index))
|
||||
controls.Children.Add(SmallButton(MaterialIconKind.Link, App.Loc["TipJoin"], () => _consist.Join(item)));
|
||||
|
||||
var remove = SmallButton(MaterialIconKind.Close, App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item));
|
||||
@@ -147,6 +150,14 @@ public sealed class VehicleCards
|
||||
card.KeyDown += (_, e) => OnCardKey(card, item, e);
|
||||
card.PointerPressed += (_, e) =>
|
||||
{
|
||||
// The menu is about the card it opened on, so that card is selected -
|
||||
// without moving the browser, whose pick the menu offers as a replacement.
|
||||
if (e.GetCurrentPoint(card).Properties.IsRightButtonPressed)
|
||||
{
|
||||
Select(item, PressedCar, syncBrowser: false);
|
||||
PressedCar = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
@@ -244,7 +255,7 @@ public sealed class VehicleCards
|
||||
/// </summary>
|
||||
public void Activate(ConsistItem item) => Select(item, null);
|
||||
|
||||
private void Select(ConsistItem item, Dynamic? car)
|
||||
private void Select(ConsistItem item, Dynamic? car, bool syncBrowser = true)
|
||||
{
|
||||
var target = car ?? (ReferenceEquals(_consist.Selected, item)
|
||||
? _consist.ActiveCar(item)
|
||||
@@ -258,7 +269,8 @@ public sealed class VehicleCards
|
||||
_consist.SyncStartingVehicle();
|
||||
RefreshCardSelectionChrome();
|
||||
_refreshDetails();
|
||||
_selectInBrowser(target);
|
||||
if (syncBrowser)
|
||||
_selectInBrowser(target);
|
||||
}
|
||||
|
||||
private Border? CardFor(ConsistItem item) =>
|
||||
@@ -335,8 +347,9 @@ public sealed class VehicleCards
|
||||
Redraw(keepFocus: true);
|
||||
break;
|
||||
|
||||
// The way a right click does it: the menu fills itself up as it is requested.
|
||||
case Key.Enter:
|
||||
card.ContextMenu?.Open(card);
|
||||
card.RaiseEvent(new ContextRequestedEventArgs());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -612,48 +625,65 @@ public sealed class VehicleCards
|
||||
btn.Foreground = new SolidColorBrush(Color.Parse(color));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything that can be done to one card, the keys that do it alongside: the
|
||||
/// card's own buttons and keys were the only way to most of it before.
|
||||
/// </summary>
|
||||
private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
menu.Opening += (_, _) =>
|
||||
{
|
||||
menu.Items.Clear();
|
||||
int index = _consist.IndexOf(item);
|
||||
|
||||
var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddCopies is not null };
|
||||
addMany.Click += (_, _) =>
|
||||
AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count));
|
||||
menu.Items.Add(addMany);
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
var num = new MenuItem { Header = App.Loc["WagonNumber"] };
|
||||
num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
|
||||
menu.Items.Add(num);
|
||||
// Clicking a card shows its own livery in the browser; offering that
|
||||
// as the replacement would replace the vehicle with itself.
|
||||
var picked = BrowserSelection?.Invoke();
|
||||
var active = _consist.ActiveCar(item);
|
||||
if (picked is not null &&
|
||||
ReferenceEquals(picked, _db.TextureFor(active.DataFolder, active.SkinFile, active.MmdFile)))
|
||||
picked = null;
|
||||
menu.AddItem(picked is null
|
||||
? App.Loc["ReplaceWithBrowser"]
|
||||
: $"{App.Loc["ReplaceWithBrowser"]}: {Consist.Base(picked.Skinfile)}",
|
||||
() => { if (picked is not null) ReplaceWith?.Invoke(item, picked); },
|
||||
picked is not null && ReplaceWith is not null);
|
||||
menu.AddItem(App.Loc["AddMany"],
|
||||
() => AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count)),
|
||||
AddCopies is not null);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
menu.AddItem(App.Loc["TipFlip"], () => { _consist.Flip(item); Redraw(keepFocus: true); },
|
||||
gesture: new KeyGesture(Key.Space));
|
||||
menu.AddItem(App.Loc["TipMoveLeft"], () => { _consist.MoveLeft(item); Redraw(keepFocus: true); },
|
||||
index > 0, new KeyGesture(Key.Left, KeyModifiers.Control));
|
||||
menu.AddItem(App.Loc["TipMoveRight"], () => { _consist.MoveRight(item); Redraw(keepFocus: true); },
|
||||
index >= 0 && index < _consist.Count - 1, new KeyGesture(Key.Right, KeyModifiers.Control));
|
||||
|
||||
var copyPrev = new MenuItem
|
||||
{
|
||||
Header = App.Loc["LoadCopyPrev"],
|
||||
IsEnabled = _consist.IndexOf(item) > 0
|
||||
};
|
||||
copyPrev.Click += (_, _) => { _cargo.CopyFromPrevious(_consist, item); _redraw(); };
|
||||
menu.Items.Add(copyPrev);
|
||||
if (item.Grouped && item.Cars.Count > 1)
|
||||
menu.AddItem(App.Loc["TipSplit"], () => _consist.Split(item));
|
||||
else if (_consist.CanJoin(item, index))
|
||||
menu.AddItem(App.Loc["TipJoin"], () => _consist.Join(item));
|
||||
|
||||
var copyToAll = new MenuItem
|
||||
{
|
||||
Header = App.Loc["LoadCopyToAll"],
|
||||
IsEnabled = _cargo.IsLoadable(item)
|
||||
};
|
||||
copyToAll.Click += (_, _) => { _cargo.CopyToFollowing(_consist, item); _redraw(); };
|
||||
menu.Items.Add(copyToAll);
|
||||
var crew = new MenuItem { Header = App.Loc["CrewLabel"] };
|
||||
foreach (var (type, key) in CrewChoices.All)
|
||||
crew.AddItem((item.Driver == type ? "● " : "○ ") + App.Loc[key], () => _consist.SetCrew(item, type));
|
||||
menu.Items.Add(crew);
|
||||
|
||||
var max = new MenuItem
|
||||
{
|
||||
Header = App.Loc["LoadMax"],
|
||||
IsEnabled = _cargo.IsLoadable(item)
|
||||
};
|
||||
max.Click += (_, _) => { _cargo.FillUnit(item); _redraw(); };
|
||||
menu.Items.Add(max);
|
||||
// The member the panels show, not always the first car of a unit.
|
||||
menu.AddItem(App.Loc["WagonNumber"] + "…",
|
||||
() => ShowWagonNumberFlyout(anchor, _consist.ActiveCar(item)),
|
||||
gesture: new KeyGesture(Key.N, KeyModifiers.Control));
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
menu.AddItem(App.Loc["LoadCopyPrev"], () => { _cargo.CopyFromPrevious(_consist, item); _redraw(); }, index > 0);
|
||||
menu.AddItem(App.Loc["LoadCopyToAll"], () => { _cargo.CopyToFollowing(_consist, item); _redraw(); },
|
||||
_cargo.IsLoadable(item));
|
||||
menu.AddItem(App.Loc["LoadMax"], () => { _cargo.FillUnit(item); _redraw(); }, _cargo.IsLoadable(item));
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
menu.AddItem(App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item), gesture: new KeyGesture(Key.Delete));
|
||||
};
|
||||
return menu;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
@@ -29,7 +27,6 @@ public sealed class VehicleDetails
|
||||
private readonly StackPanel damagePanel;
|
||||
private readonly Button removeVehicleButton;
|
||||
|
||||
private readonly VehicleCatalog _db;
|
||||
private readonly VehicleInfo _info;
|
||||
private readonly Consist _consist;
|
||||
private readonly Cargo _cargo;
|
||||
@@ -39,12 +36,15 @@ public sealed class VehicleDetails
|
||||
private readonly Action _refreshChrome;
|
||||
private readonly Action<VehicleTexture, StackPanel> _showTextureInfo;
|
||||
|
||||
/// <summary>A setting changed on the cars themselves, which redraws nothing.</summary>
|
||||
public Action? Edited { get; init; }
|
||||
|
||||
public VehicleDetails(
|
||||
StackPanel generalPanel, StackPanel consistTexturePanel, StackPanel couplerPanel,
|
||||
Panel couplerActions,
|
||||
StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel,
|
||||
Button removeVehicleButton,
|
||||
VehicleCatalog db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
|
||||
VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
|
||||
Action redraw, Action refreshChrome, Action<VehicleTexture, StackPanel> showTextureInfo)
|
||||
{
|
||||
this.generalPanel = generalPanel;
|
||||
@@ -56,7 +56,6 @@ public sealed class VehicleDetails
|
||||
this.damagePanel = damagePanel;
|
||||
this.removeVehicleButton = removeVehicleButton;
|
||||
|
||||
_db = db;
|
||||
_info = info;
|
||||
_consist = consist;
|
||||
_cargo = cargo;
|
||||
@@ -97,7 +96,7 @@ public sealed class VehicleDetails
|
||||
|
||||
BuildGeneralInfo(_consist.Selected);
|
||||
|
||||
if (_db.TextureFor(_consist.ActiveCar(_consist.Selected).DataFolder, _consist.ActiveCar(_consist.Selected).SkinFile, _consist.ActiveCar(_consist.Selected).MmdFile) is { } tex)
|
||||
if (_info.TextureFor(_consist.ActiveCar(_consist.Selected)) is { } tex)
|
||||
_showTextureInfo(tex, consistTexturePanel);
|
||||
else
|
||||
consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"]));
|
||||
@@ -269,24 +268,12 @@ public sealed class VehicleDetails
|
||||
private Control BuildVehicleSection(ConsistItem item)
|
||||
{
|
||||
var driver = new ComboBox { FontSize = 12, MinWidth = 0 };
|
||||
foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" })
|
||||
foreach (var (_, key) in CrewChoices.All)
|
||||
driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] });
|
||||
driver.SelectedIndex = item.Driver switch
|
||||
{
|
||||
eDriverType.Headdriver => 0,
|
||||
eDriverType.Reardriver => 1,
|
||||
eDriverType.Passenger => 2,
|
||||
_ => 3
|
||||
};
|
||||
driver.SelectedIndex = CrewChoices.IndexOf(item.Driver);
|
||||
driver.SelectionChanged += (_, _) =>
|
||||
{
|
||||
var picked = driver.SelectedIndex switch
|
||||
{
|
||||
0 => eDriverType.Headdriver,
|
||||
1 => eDriverType.Reardriver,
|
||||
2 => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
};
|
||||
var picked = CrewChoices.At(driver.SelectedIndex);
|
||||
if (picked == item.Driver) return;
|
||||
|
||||
_consist.SetCrew(item, picked);
|
||||
@@ -338,7 +325,11 @@ public sealed class VehicleDetails
|
||||
IsChecked = d.Coupling.ThermoDynamic,
|
||||
FontSize = 12
|
||||
};
|
||||
thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true;
|
||||
thermo.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
d.Coupling.ThermoDynamic = thermo.IsChecked == true;
|
||||
Edited?.Invoke();
|
||||
};
|
||||
|
||||
// Label-less row, so the box indents to the control column with everything
|
||||
// else instead of starting at the panel's left edge on its own.
|
||||
@@ -436,6 +427,7 @@ public sealed class VehicleDetails
|
||||
Load = (loadCombo.SelectedItem as ComboBoxItem)?.Tag as string
|
||||
};
|
||||
foreach (var c in Consist.UnitTargets(item)) c.Coupling.SetBrake(b);
|
||||
Edited?.Invoke();
|
||||
}
|
||||
|
||||
modeCombo.SelectionChanged += (_, _) => Apply();
|
||||
@@ -501,6 +493,7 @@ public sealed class VehicleDetails
|
||||
};
|
||||
foreach (var c in _consist.MemberTargets(item))
|
||||
c.Coupling.SetWheels(ws);
|
||||
Edited?.Invoke();
|
||||
}
|
||||
|
||||
sway.ValueChanged += (_, _) => Apply();
|
||||
@@ -739,6 +732,7 @@ public sealed class VehicleDetails
|
||||
c.LoadType = string.IsNullOrEmpty(type) ? null : type;
|
||||
c.LoadCount = string.IsNullOrEmpty(type) ? 0 : Math.Min(count, _cargo.LimitFor(c, type));
|
||||
}
|
||||
Edited?.Invoke();
|
||||
}
|
||||
|
||||
// Seed the pantograph combo from the model before any handler is listening.
|
||||
|
||||
@@ -11,6 +11,10 @@ using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The warehouse tab: consists kept for later. The one selected is the one the
|
||||
/// consist strip edits, and the menu puts it into the scenery's selected consist.
|
||||
/// </summary>
|
||||
public sealed class Warehouse
|
||||
{
|
||||
private readonly ListBox _list;
|
||||
@@ -19,6 +23,16 @@ public sealed class Warehouse
|
||||
private readonly Func<Trainset?> _target;
|
||||
private readonly Action<Trainset, List<Dynamic>> _apply;
|
||||
|
||||
/// <summary>Set while the list is rebuilt, which clears and restores the selection.</summary>
|
||||
private bool _refreshing;
|
||||
|
||||
/// <summary>Another consist is selected, or none - not raised mid-rebuild.</summary>
|
||||
public event Action? SelectionChanged;
|
||||
|
||||
/// <summary>A consist got a new name: the old one, then the new one.</summary>
|
||||
public event Action<string, string>? Renamed;
|
||||
|
||||
/// <param name="target">The scenery consist the menu's actions put a warehouse consist into.</param>
|
||||
public Warehouse(ListBox list, Button removeButton, Cursor hand,
|
||||
Func<Trainset?> target, Action<Trainset, List<Dynamic>> apply)
|
||||
{
|
||||
@@ -27,11 +41,20 @@ public sealed class Warehouse
|
||||
_hand = hand;
|
||||
_target = target;
|
||||
_apply = apply;
|
||||
|
||||
_list.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (!_refreshing)
|
||||
SelectionChanged?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
/// <param name="select">The consist to select; the one selected now otherwise.</param>
|
||||
public void Refresh(string? select = null)
|
||||
{
|
||||
string? current = SelectedName();
|
||||
string? current = select ?? SelectedName();
|
||||
string? before = SelectedName();
|
||||
_refreshing = true;
|
||||
_list.Items.Clear();
|
||||
|
||||
foreach (var preset in PresetStore.All())
|
||||
@@ -52,10 +75,16 @@ public sealed class Warehouse
|
||||
|
||||
_list.ContextMenu ??= BuildMenu();
|
||||
_removeButton.IsEnabled = _list.Items.Count > 0;
|
||||
_refreshing = false;
|
||||
|
||||
if (!string.Equals(before, SelectedName(), StringComparison.Ordinal))
|
||||
SelectionChanged?.Invoke();
|
||||
}
|
||||
|
||||
private string? SelectedName() => (_list.SelectedItem as ListBoxItem)?.Tag as string;
|
||||
|
||||
public TrainsetPreset? SelectedPreset() => SelectedName() is { } name ? PresetNamed(name) : null;
|
||||
|
||||
private static TrainsetPreset? PresetNamed(string name) =>
|
||||
PresetStore.All().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal));
|
||||
|
||||
@@ -68,20 +97,14 @@ public sealed class Warehouse
|
||||
bool hasPreset = SelectedName() != null;
|
||||
bool hasTarget = _target() != null;
|
||||
|
||||
void Add(string key, Action run, bool enabled = true)
|
||||
{
|
||||
var mi = new MenuItem { Header = App.Loc[key], IsEnabled = enabled };
|
||||
mi.Click += (_, _) => run();
|
||||
menu.Items.Add(mi);
|
||||
}
|
||||
|
||||
Add("WarehouseApply", ApplySelected, hasPreset && hasTarget);
|
||||
Add("WarehouseAppend", AppendSelected, hasPreset && hasTarget);
|
||||
Add("ChangeName", RenameSelected, hasPreset);
|
||||
menu.AddItem(App.Loc["WarehouseUseInScenery"], ApplySelected, hasPreset && hasTarget);
|
||||
menu.AddItem(App.Loc["WarehouseAppendToScenery"], AppendSelected, hasPreset && hasTarget);
|
||||
menu.AddItem(App.Loc["ChangeName"], RenameSelected, hasPreset);
|
||||
menu.AddItem(App.Loc["RemoveFromWarehouse"], () => _ = RemoveSelectedAsync(), hasPreset);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
Add("PresetImportMagazyn", () => _ = ImportMagazynAsync());
|
||||
Add("WarehouseImportFile", () => _ = ImportFileAsync());
|
||||
menu.AddItem(App.Loc["PresetImportMagazyn"], () => _ = ImportMagazynAsync());
|
||||
menu.AddItem(App.Loc["WarehouseImportFile"], () => _ = ImportFileAsync());
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
var sort = new MenuItem { Header = App.Loc["PresetSort"] };
|
||||
@@ -92,68 +115,41 @@ public sealed class Warehouse
|
||||
return menu;
|
||||
}
|
||||
|
||||
private MenuItem SortItem(PresetSort mode, string locKey)
|
||||
{
|
||||
var item = new MenuItem
|
||||
{
|
||||
Header = (PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey]
|
||||
};
|
||||
item.Click += (_, _) => { PresetStore.SortMode = mode; Refresh(); };
|
||||
return item;
|
||||
}
|
||||
private MenuItem SortItem(PresetSort mode, string locKey) =>
|
||||
MenuItems.Item((PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey],
|
||||
() => { PresetStore.SortMode = mode; Refresh(); });
|
||||
|
||||
public async Task SaveCurrentAsync()
|
||||
{
|
||||
if (_target() is not { } trainset)
|
||||
return;
|
||||
|
||||
string name = trainset.Name;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = App.Loc["PresetEmpty"];
|
||||
|
||||
try
|
||||
{
|
||||
PresetStore.Save(name, ConsistText.Serialize(trainset));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex);
|
||||
return;
|
||||
}
|
||||
Refresh();
|
||||
}
|
||||
/// <summary>Asks for a name at the button pressed, and says where the consist went.</summary>
|
||||
public void SaveCurrent(Control anchor) =>
|
||||
ConsistContextMenu.ShowSaveFlyout(anchor, _target(), atPointer: false, saved: name => Refresh(name));
|
||||
|
||||
public void ApplySelected()
|
||||
{
|
||||
if (SelectedName() is not { } name || _target() is not { } target)
|
||||
return;
|
||||
|
||||
if (PresetNamed(name) is not { } preset)
|
||||
if (_target() is not { } target || SelectedPreset() is not { } preset)
|
||||
return;
|
||||
|
||||
if (ConsistText.VehiclesFrom(preset.Entry) is { } vehicles)
|
||||
_apply(target, vehicles);
|
||||
_apply(target, ConsistContextMenu.KeepCrew(target, vehicles));
|
||||
}
|
||||
|
||||
public void AppendSelected()
|
||||
{
|
||||
if (SelectedName() is not { } name || _target() is not { } target)
|
||||
if (_target() is not { } target)
|
||||
return;
|
||||
|
||||
var vehicles = ConsistText.VehiclesFrom(PresetNamed(name)?.Entry);
|
||||
var vehicles = ConsistText.VehiclesFrom(SelectedPreset()?.Entry);
|
||||
if (vehicles is null || vehicles.Count == 0)
|
||||
return;
|
||||
|
||||
var merged = new List<Dynamic>(target.Vehicles);
|
||||
merged.AddRange(vehicles);
|
||||
_apply(target, merged);
|
||||
_apply(target, Domain.Consist.Joined(target.Vehicles, vehicles));
|
||||
}
|
||||
|
||||
public void RenameSelected()
|
||||
{
|
||||
if (SelectedName() is not { } name || PresetNamed(name) is not { } preset)
|
||||
if (SelectedPreset() is not { } preset)
|
||||
return;
|
||||
|
||||
string name = preset.Name;
|
||||
var nameBox = new TextBox { Text = name, MinWidth = 240 };
|
||||
var ok = new Button { Content = App.Loc["Ok"], Cursor = _hand };
|
||||
ok.Classes.Add("Accent");
|
||||
@@ -169,16 +165,23 @@ public sealed class Warehouse
|
||||
panel.Children.Add(ok);
|
||||
|
||||
var flyout = new Flyout { Content = panel };
|
||||
ok.Click += (_, _) =>
|
||||
ok.Click += async (_, _) =>
|
||||
{
|
||||
string? fresh = nameBox.Text?.Trim();
|
||||
if (!string.IsNullOrEmpty(fresh) && !string.Equals(fresh, name, StringComparison.Ordinal))
|
||||
{
|
||||
PresetStore.Save(fresh, preset.Entry);
|
||||
PresetStore.Delete(name);
|
||||
Refresh();
|
||||
}
|
||||
flyout.Hide();
|
||||
if (string.IsNullOrEmpty(fresh) || string.Equals(fresh, name, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
// Differing only in case is this very entry, not another one written over.
|
||||
if (!string.Equals(fresh, name, StringComparison.OrdinalIgnoreCase) &&
|
||||
!await ConsistContextMenu.MayOverwritePresetAsync(_list, fresh))
|
||||
return;
|
||||
|
||||
// Deleted first: a new name differing only in case is the same entry.
|
||||
PresetStore.Delete(name);
|
||||
PresetStore.Save(fresh, preset.Entry);
|
||||
Renamed?.Invoke(name, fresh);
|
||||
Refresh(fresh);
|
||||
};
|
||||
flyout.ShowAt(_list);
|
||||
nameBox.Focus();
|
||||
@@ -214,7 +217,7 @@ public sealed class Warehouse
|
||||
|
||||
public async Task RemoveSelectedAsync()
|
||||
{
|
||||
if (SelectedName() is not { } name)
|
||||
if (SelectedName() is not { } name || !await ConsistContextMenu.MayRemovePresetAsync(_list, name))
|
||||
return;
|
||||
|
||||
try
|
||||
|
||||
26
StarterNG/Views/MenuItems.cs
Normal file
26
StarterNG/Views/MenuItems.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
/// <summary>A menu entry that runs something when clicked, as nearly all of them do.</summary>
|
||||
internal static class MenuItems
|
||||
{
|
||||
/// <param name="header">The text, or a control where a row holds more than that.</param>
|
||||
public static MenuItem Item(object header, Action run, bool enabled = true, KeyGesture? gesture = null)
|
||||
{
|
||||
var item = new MenuItem { Header = header, IsEnabled = enabled, InputGesture = gesture };
|
||||
item.Click += (_, _) => run();
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>The entry at the end of a menu or a submenu.</summary>
|
||||
public static MenuItem AddItem(this ItemsControl menu, object header, Action run, bool enabled = true,
|
||||
KeyGesture? gesture = null)
|
||||
{
|
||||
var item = Item(header, run, enabled, gesture);
|
||||
menu.Items.Add(item);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:starterNg="clr-namespace:StarterNG"
|
||||
xmlns:views="clr-namespace:StarterNG.Views"
|
||||
x:Class="StarterNG.Views.TextureBaseWindow"
|
||||
x:CompileBindings="True"
|
||||
Title="{Binding [TextureBaseTitle], Source={x:Static starterNg:App.Loc}}"
|
||||
Width="980" Height="560"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
MinWidth="720" MinHeight="400">
|
||||
<DockPanel Margin="10">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
ColumnSpacing="8" RowSpacing="6" Margin="0,0,0,8">
|
||||
<CheckBox Name="fNameOn" Grid.Row="0" Grid.Column="0" Content="{Binding [TextureInfo], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fName" Grid.Row="0" Grid.Column="1" PlaceholderText="{Binding [Search], Source={x:Static starterNg:App.Loc}}" />
|
||||
<CheckBox Name="fOpOn" Grid.Row="0" Grid.Column="2" Content="{Binding [TexOperator], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fOp" Grid.Row="0" Grid.Column="3" />
|
||||
|
||||
<CheckBox Name="fMiniOn" Grid.Row="1" Grid.Column="0" Content="{Binding [Class], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fMini" Grid.Row="1" Grid.Column="1" />
|
||||
<CheckBox Name="fAuthorOn" Grid.Row="1" Grid.Column="2" Content="{Binding [TexAuthor], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fAuthor" Grid.Row="1" Grid.Column="3" />
|
||||
|
||||
<CheckBox Name="fStationOn" Grid.Row="2" Grid.Column="0" Content="{Binding [TexStation], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fStation" Grid.Row="2" Grid.Column="1" />
|
||||
<CheckBox Name="fPhotoOn" Grid.Row="2" Grid.Column="2" Content="{Binding [TexPhoto], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fPhoto" Grid.Row="2" Grid.Column="3" />
|
||||
|
||||
<CheckBox Name="fModelOn" Grid.Row="3" Grid.Column="0" Content="{Binding [Model], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fModel" Grid.Row="3" Grid.Column="1" />
|
||||
|
||||
<CheckBox Name="fRevFromOn" Grid.Row="4" Grid.Column="0" Content="{Binding [RevisionFrom], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fRevFrom" Grid.Row="4" Grid.Column="1" PlaceholderText="RRRR-MM-DD" />
|
||||
<CheckBox Name="fRevToOn" Grid.Row="4" Grid.Column="2" Content="{Binding [RevisionTo], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fRevTo" Grid.Row="4" Grid.Column="3" PlaceholderText="RRRR-MM-DD" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Name="statusText" DockPanel.Dock="Bottom" Margin="0,6,0,0"
|
||||
FontSize="12" Opacity="0.75" />
|
||||
|
||||
<Grid DockPanel.Dock="Left" Width="260" RowDefinitions="*,4,*">
|
||||
<ListBox Name="typeList" Grid.Row="0" Classes="Compact" />
|
||||
<GridSplitter Grid.Row="1" Height="4" HorizontalAlignment="Stretch"
|
||||
Background="Transparent" ResizeDirection="Rows" />
|
||||
<ListBox Name="modelList" Grid.Row="2" Classes="Compact">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:TextureModelRow">
|
||||
<Grid ColumnDefinitions="28,90,*" ColumnSpacing="4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding No}" Opacity="0.6" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Klasa}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Sciezka}" Opacity="0.75"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
|
||||
<GridSplitter Name="spSplitter" DockPanel.Dock="Left" Width="4"
|
||||
Background="Transparent" ResizeDirection="Columns" />
|
||||
|
||||
<ListBox Name="textureList" DoubleTapped="TextureList_OnDoubleTapped">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:TextureRow">
|
||||
<Grid ColumnDefinitions="1.4*,0.9*,1.2*,90,*" ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Skin}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Mini}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Operator}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Text="{Binding Revision}" Opacity="0.85" />
|
||||
<TextBlock Grid.Column="4" Text="{Binding Author}" Opacity="0.75" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -1,224 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public partial class TextureBaseWindow : Window
|
||||
{
|
||||
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
|
||||
private readonly List<TextureRow> _all = new();
|
||||
|
||||
public VehicleTexture? Picked { get; private set; }
|
||||
|
||||
public TextureBaseWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
foreach (var t in _db.Textures)
|
||||
{
|
||||
if (_db.IsSetFollower(t)) continue;
|
||||
_all.Add(new TextureRow(t, _db));
|
||||
}
|
||||
|
||||
foreach (var box in new Control[] { fName, fOp, fMini, fAuthor, fStation, fPhoto, fModel })
|
||||
{
|
||||
if (box is TextBox tb)
|
||||
tb.TextChanged += (_, _) => Refresh();
|
||||
}
|
||||
foreach (var box in new Control[] { fNameOn, fOpOn, fMiniOn, fAuthorOn, fStationOn, fPhotoOn,
|
||||
fModelOn, fRevFromOn, fRevToOn })
|
||||
{
|
||||
if (box is CheckBox cb)
|
||||
cb.IsCheckedChanged += (_, _) => Refresh();
|
||||
}
|
||||
|
||||
fRevFrom.TextChanged += (_, _) => Refresh();
|
||||
fRevTo.TextChanged += (_, _) => Refresh();
|
||||
|
||||
BuildTypeList();
|
||||
typeList.SelectionChanged += (_, _) => { BuildModelList(); Refresh(); };
|
||||
modelList.SelectionChanged += (_, _) => Refresh();
|
||||
|
||||
textureList.ContextRequested += TextureList_OnContextRequested;
|
||||
textureList.AddHandler(PointerReleasedEvent, TextureList_OnPointerReleased, handledEventsToo: true);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void BuildModelList()
|
||||
{
|
||||
modelList.ItemsSource = null;
|
||||
|
||||
string? cat = (typeList.SelectedItem as ListBoxItem)?.Tag as string;
|
||||
var rows = _all
|
||||
.Where(r => cat == null || string.Equals(r.Category, cat, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(r => r.Mini.Length > 0)
|
||||
.GroupBy(r => r.Mini, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((g, i) => new TextureModelRow($"{i + 1}.", g.Key, g.First().Directory))
|
||||
.ToList();
|
||||
|
||||
modelList.ItemsSource = rows;
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(string? text) =>
|
||||
DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var d) ? d : null;
|
||||
|
||||
private void BuildTypeList()
|
||||
{
|
||||
typeList.Items.Add(new ListBoxItem { Content = App.Loc["CatAll"], Tag = null });
|
||||
|
||||
foreach (string cat in _all.Select(r => r.Category)
|
||||
.Where(c => c.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase))
|
||||
typeList.Items.Add(new ListBoxItem { Content = cat, Tag = cat });
|
||||
|
||||
typeList.SelectedIndex = 0;
|
||||
BuildModelList();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
IEnumerable<TextureRow> q = _all;
|
||||
|
||||
q = Filter(q, fNameOn, fName, r => r.Skin);
|
||||
q = Filter(q, fOpOn, fOp, r => r.Operator);
|
||||
q = Filter(q, fMiniOn, fMini, r => r.Mini);
|
||||
q = Filter(q, fAuthorOn, fAuthor, r => r.Author);
|
||||
q = Filter(q, fStationOn, fStation, r => r.Station);
|
||||
q = Filter(q, fPhotoOn, fPhoto, r => r.Photo);
|
||||
q = Filter(q, fModelOn, fModel, r => r.Model);
|
||||
|
||||
if ((typeList.SelectedItem as ListBoxItem)?.Tag is string cat)
|
||||
q = q.Where(r => string.Equals(r.Category, cat, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (modelList.SelectedItem is TextureModelRow mr)
|
||||
q = q.Where(r => string.Equals(r.Mini, mr.Klasa, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (fRevFromOn.IsChecked == true && ParseDate(fRevFrom.Text) is { } from)
|
||||
q = q.Where(r => r.RevisionDate is { } d && d >= from);
|
||||
if (fRevToOn.IsChecked == true && ParseDate(fRevTo.Text) is { } to)
|
||||
q = q.Where(r => r.RevisionDate is { } d && d <= to);
|
||||
|
||||
var list = q
|
||||
.OrderBy(r => r.Mini, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(r => r.Skin, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(5000)
|
||||
.ToList();
|
||||
|
||||
textureList.ItemsSource = list;
|
||||
statusText.Text = $"{list.Count} / {_all.Count}";
|
||||
}
|
||||
|
||||
private static IEnumerable<TextureRow> Filter(
|
||||
IEnumerable<TextureRow> src, CheckBox? on, TextBox? box, Func<TextureRow, string> field)
|
||||
{
|
||||
if (on?.IsChecked != true) return src;
|
||||
string needle = box?.Text?.Trim() ?? "";
|
||||
if (needle.Length < 1) return src;
|
||||
return src.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private void TextureList_OnDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (textureList.SelectedItem is not TextureRow row) return;
|
||||
Picked = row.Texture;
|
||||
}
|
||||
|
||||
// DoubleTapped is raised from the second press, while the list still holds the
|
||||
// pointer. Closing there leaves the capture on a dead window and on Windows the
|
||||
// owner stops taking clicks. The release ends the gesture and drops the capture,
|
||||
// so that is where the window goes.
|
||||
private void TextureList_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (Picked != null)
|
||||
Close();
|
||||
}
|
||||
|
||||
private void TextureList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (textureList.SelectedItem is not TextureRow row) return;
|
||||
|
||||
var menu = new ContextMenu();
|
||||
var copy = new MenuItem { Header = App.Loc["CopyTextureName"] };
|
||||
copy.Click += async (_, _) =>
|
||||
{
|
||||
if (Clipboard != null)
|
||||
await Clipboard.SetTextAsync(row.Skin);
|
||||
};
|
||||
menu.Items.Add(copy);
|
||||
|
||||
var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] };
|
||||
open.Click += (_, _) =>
|
||||
{
|
||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
|
||||
row.Texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
||||
if (!Directory.Exists(dir))
|
||||
dir = Path.Combine(Directory.GetCurrentDirectory(), row.Texture.Directory);
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
|
||||
string.Format(App.Loc["FaultFileNotFound"], dir));
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(
|
||||
$"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}");
|
||||
}
|
||||
};
|
||||
menu.Items.Add(open);
|
||||
menu.Open(textureList);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TextureModelRow(string No, string Klasa, string Sciezka);
|
||||
|
||||
internal sealed class TextureRow
|
||||
{
|
||||
public VehicleTexture Texture { get; }
|
||||
public string Skin { get; }
|
||||
public string Mini { get; }
|
||||
public string Operator { get; }
|
||||
public string Revision { get; }
|
||||
public string Author { get; }
|
||||
public string Station { get; }
|
||||
public string Photo { get; }
|
||||
public string Model { get; }
|
||||
public string Category { get; }
|
||||
public string Directory { get; }
|
||||
public DateTime? RevisionDate { get; }
|
||||
|
||||
public TextureRow(VehicleTexture t, VehicleCatalog db)
|
||||
{
|
||||
Texture = t;
|
||||
Skin = Path.GetFileNameWithoutExtension(t.Skinfile);
|
||||
Mini = db.ResolveMiniName(t) ?? t.ResolvedClass ?? "";
|
||||
Operator = t.Meta?.Operator ?? "";
|
||||
Revision = t.Meta?.RevisionDate ?? "";
|
||||
Author = t.Meta?.TextureAuthor ?? "";
|
||||
Station = t.Meta?.Depot ?? "";
|
||||
Photo = t.Meta?.PhotoAuthor ?? "";
|
||||
Model = t.Model ?? "";
|
||||
Category = t.ResolvedCategory ?? "";
|
||||
Directory = t.Directory ?? "";
|
||||
RevisionDate = DateTime.TryParse(Revision, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var rev) ? rev : null;
|
||||
}
|
||||
}
|
||||
109
StarterNG/Views/VehicleBaseQuery.cs
Normal file
109
StarterNG/Views/VehicleBaseQuery.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public sealed record VehicleClassRow(string Name, int Count);
|
||||
|
||||
public sealed class VehicleBaseRow
|
||||
{
|
||||
public VehicleTexture Texture { get; }
|
||||
public string Skin { get; }
|
||||
public string Class { get; }
|
||||
public string Operator { get; }
|
||||
public string Revision { get; }
|
||||
public string Author { get; }
|
||||
public string Station { get; }
|
||||
public string Photo { get; }
|
||||
public string Model { get; }
|
||||
public string? Category { get; }
|
||||
public RevisionSpan? Date { get; }
|
||||
|
||||
/// <summary>Sorts as text in the order of the dates; empty for a date not known.</summary>
|
||||
public string DateKey => Date?.From.ToString("yyyyMMdd", CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
public VehicleBaseRow(VehicleTexture texture)
|
||||
{
|
||||
Texture = texture;
|
||||
Skin = Consist.Base(texture.Skinfile);
|
||||
Class = VehicleInfo.ClassOf(texture);
|
||||
Operator = Known(texture.Meta?.Operator);
|
||||
Revision = Known(texture.Meta?.RevisionDate);
|
||||
Author = Known(texture.Meta?.TextureAuthor);
|
||||
Station = Known(texture.Meta?.Depot);
|
||||
Photo = Known(texture.Meta?.PhotoAuthor);
|
||||
Model = texture.Directory + texture.Model;
|
||||
Category = VehicleInfo.CategoryOf(texture);
|
||||
Date = RevisionSpan.Parse(Revision);
|
||||
}
|
||||
|
||||
// The credit lines write a question mark for what their author did not know.
|
||||
private static string Known(string? value) => value is null or "?" ? "" : value;
|
||||
}
|
||||
|
||||
/// <summary>What is typed and picked to look through the vehicle base with.</summary>
|
||||
public sealed record VehicleBaseFilters
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Class { get; init; }
|
||||
public string? Operator { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public string? Station { get; init; }
|
||||
public string? Photo { get; init; }
|
||||
public string? RevisionFrom { get; init; }
|
||||
public string? RevisionTo { get; init; }
|
||||
|
||||
/// <summary>The class picked from the list, which has to match whole.</summary>
|
||||
public string? PickedClass { get; init; }
|
||||
}
|
||||
|
||||
public static class VehicleBaseQuery
|
||||
{
|
||||
private static readonly StringComparer Order = StringComparer.CurrentCultureIgnoreCase;
|
||||
|
||||
public static List<VehicleClassRow> ClassCounts(IEnumerable<VehicleBaseRow> rows) => rows
|
||||
.Where(r => r.Class.Length > 0)
|
||||
.GroupBy(r => r.Class, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new VehicleClassRow(g.Key, g.Count()))
|
||||
.OrderBy(c => c.Name, Order)
|
||||
.ToList();
|
||||
|
||||
public static List<VehicleBaseRow> Select(IEnumerable<VehicleBaseRow> rows, VehicleBaseFilters filters,
|
||||
Func<VehicleBaseRow, string> sortKey, bool descending)
|
||||
{
|
||||
rows = Filter(rows, filters.Name, r => r.Skin);
|
||||
rows = Filter(rows, filters.Class, r => r.Class);
|
||||
rows = Filter(rows, filters.Operator, r => r.Operator);
|
||||
rows = Filter(rows, filters.Author, r => r.Author);
|
||||
rows = Filter(rows, filters.Model, r => r.Model);
|
||||
rows = Filter(rows, filters.Station, r => r.Station);
|
||||
rows = Filter(rows, filters.Photo, r => r.Photo);
|
||||
|
||||
if (filters.PickedClass is { } picked)
|
||||
rows = rows.Where(r => string.Equals(r.Class, picked, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (RevisionSpan.Parse(filters.RevisionFrom) is { } from)
|
||||
rows = rows.Where(r => r.Date is { } date && date.To >= from.From);
|
||||
if (RevisionSpan.Parse(filters.RevisionTo) is { } to)
|
||||
rows = rows.Where(r => r.Date is { } date && date.From <= to.To);
|
||||
|
||||
// Blanks stay at the bottom whichever way a column is sorted.
|
||||
var blanksLast = rows.OrderBy(r => sortKey(r).Length == 0);
|
||||
var sorted = descending ? blanksLast.ThenByDescending(sortKey, Order) : blanksLast.ThenBy(sortKey, Order);
|
||||
return sorted.ThenBy(r => r.Skin, Order).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<VehicleBaseRow> Filter(
|
||||
IEnumerable<VehicleBaseRow> rows, string? typed, Func<VehicleBaseRow, string> field)
|
||||
{
|
||||
string needle = typed?.Trim() ?? "";
|
||||
return needle.Length == 0
|
||||
? rows
|
||||
: rows.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
160
StarterNG/Views/VehicleBaseWindow.axaml
Normal file
160
StarterNG/Views/VehicleBaseWindow.axaml
Normal file
@@ -0,0 +1,160 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:starterNg="clr-namespace:StarterNG"
|
||||
xmlns:views="clr-namespace:StarterNG.Views"
|
||||
x:Class="StarterNG.Views.VehicleBaseWindow"
|
||||
x:CompileBindings="True"
|
||||
Title="{Binding [TextureBaseTitle], Source={x:Static starterNg:App.Loc}}"
|
||||
Width="980" Height="600"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
MinWidth="820" MinHeight="420"
|
||||
KeyDown="Window_OnKeyDown">
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.Panel">
|
||||
<Setter Property="Background" Value="{StaticResource CarbonPanelBg}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CarbonBorderLight}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Padding" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.FilterLabel">
|
||||
<Setter Property="Foreground" Value="{StaticResource CarbonFgDim}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.Heading">
|
||||
<Setter Property="Foreground" Value="{StaticResource CarbonFgDim}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Margin" Value="4,0,0,3" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.SortHead">
|
||||
<Setter Property="Foreground" Value="{StaticResource CarbonFgDim}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.SortHead:pointerover">
|
||||
<Setter Property="Foreground" Value="{StaticResource CarbonFg}" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" RowSpacing="3" Margin="3">
|
||||
<!-- Whatever is typed filters straight away. -->
|
||||
<Border Grid.Row="0" Classes="Panel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,*,Auto,*,Auto,*,Auto,*" RowDefinitions="Auto,Auto"
|
||||
ColumnSpacing="6" RowSpacing="4">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="FilterLabel" Text="{Binding [TextureInfo], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fName" Grid.Row="0" Grid.Column="1" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Classes="FilterLabel" Text="{Binding [Class], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fClass" Grid.Row="0" Grid.Column="3" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="4" Classes="FilterLabel" Text="{Binding [TexOperator], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fOperator" Grid.Row="0" Grid.Column="5" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="6" Classes="FilterLabel" Text="{Binding [TexAuthor], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fAuthor" Grid.Row="0" Grid.Column="7" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="8" Classes="FilterLabel" Text="{Binding [Model], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fModel" Grid.Row="0" Grid.Column="9" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="FilterLabel" Text="{Binding [TexStation], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fStation" Grid.Row="1" Grid.Column="1" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Classes="FilterLabel" Text="{Binding [TexPhoto], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBox Name="fPhoto" Grid.Row="1" Grid.Column="3" />
|
||||
<TextBlock Name="revFromLabel" Grid.Row="1" Grid.Column="4" Classes="FilterLabel" />
|
||||
<TextBox Name="fRevFrom" Grid.Row="1" Grid.Column="5"
|
||||
PlaceholderText="{Binding [TextureBaseDateHint], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBlock Name="revToLabel" Grid.Row="1" Grid.Column="6" Classes="FilterLabel" />
|
||||
<TextBox Name="fRevTo" Grid.Row="1" Grid.Column="7"
|
||||
PlaceholderText="{Binding [TextureBaseDateHint], Source={x:Static starterNg:App.Loc}}" />
|
||||
<Button Name="clearButton" Grid.Row="1" Grid.Column="8" Grid.ColumnSpan="2" Classes="Flat"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Cursor="Hand" Click="ClearButton_OnClick"
|
||||
Content="{Binding [ClearFilters], Source={x:Static starterNg:App.Loc}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="230,3,*">
|
||||
<Border Grid.Column="0" Classes="Panel">
|
||||
<Grid RowDefinitions="Auto,*,4,Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Classes="Heading" Text="{Binding [Category], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ListBox Name="categoryList" Grid.Row="1" Classes="Compact" />
|
||||
<GridSplitter Grid.Row="2" Height="4" HorizontalAlignment="Stretch"
|
||||
Background="Transparent" ResizeDirection="Rows" />
|
||||
<TextBlock Grid.Row="3" Classes="Heading" Text="{Binding [Class], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ListBox Name="classList" Grid.Row="4" Classes="Compact">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:VehicleClassRow">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6" Margin="0,0,10,0">
|
||||
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Count}" Opacity="0.55" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<CheckBox Name="hideArchivalCheck" Grid.Row="5" Margin="0,6,0,0" FontSize="12"
|
||||
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBlock Name="statusText" Grid.Row="6" Margin="4,4,0,0" FontSize="11"
|
||||
Foreground="{StaticResource CarbonFgDim}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1" Width="3" Background="Transparent" ResizeDirection="Columns" />
|
||||
|
||||
<Border Grid.Column="2" Classes="Panel">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- The columns of the rows below; 14 is the rows' padding and their margin
|
||||
clear of the scroll bar. -->
|
||||
<Grid ColumnDefinitions="1.4*,0.9*,0.9*,82,1.2*" ColumnSpacing="8" Margin="4,0,14,3">
|
||||
<TextBlock Name="headSkin" Grid.Column="0" Classes="SortHead" Tapped="Head_OnTapped" />
|
||||
<TextBlock Name="headClass" Grid.Column="1" Classes="SortHead" Tapped="Head_OnTapped" />
|
||||
<TextBlock Name="headOperator" Grid.Column="2" Classes="SortHead" Tapped="Head_OnTapped" />
|
||||
<TextBlock Name="headRevision" Grid.Column="3" Classes="SortHead" Tapped="Head_OnTapped" />
|
||||
<TextBlock Name="headAuthor" Grid.Column="4" Classes="SortHead" Tapped="Head_OnTapped" />
|
||||
</Grid>
|
||||
<ListBox Name="vehicleList" Grid.Row="1" Classes="Compact"
|
||||
SelectionChanged="VehicleList_OnSelectionChanged"
|
||||
DoubleTapped="VehicleList_OnDoubleTapped"
|
||||
KeyDown="VehicleList_OnKeyDown">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="views:VehicleBaseRow">
|
||||
<Grid ColumnDefinitions="1.4*,0.9*,0.9*,82,1.2*" ColumnSpacing="8" Margin="0,0,10,0">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Skin}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Class}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Operator}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Text="{Binding Revision}" Opacity="0.85" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="4" Text="{Binding Author}" Opacity="0.75" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Name="emptyText" Grid.Row="1" IsVisible="False" Margin="4,6,4,0"
|
||||
TextWrapping="Wrap" Foreground="{StaticResource CarbonFgDim}"
|
||||
Text="{Binding [TextureBaseNoMatch], Source={x:Static starterNg:App.Loc}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- The vehicle picked: what the list has no column for, and what can be done with it. -->
|
||||
<Border Grid.Row="2" Classes="Panel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="8" Height="46">
|
||||
<Border Width="170" Background="{StaticResource CarbonInputBg}"
|
||||
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1" CornerRadius="2">
|
||||
<Image Name="preview" Stretch="Uniform" Margin="2" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock Name="detailTitle" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Name="detailText" FontSize="12" Foreground="{StaticResource CarbonFgDim}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Name="replaceButton" Grid.Column="2" Classes="Flat" MinWidth="80" Cursor="Hand"
|
||||
IsEnabled="False" VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center" Click="ReplaceButton_OnClick"
|
||||
ToolTip.Tip="{Binding [TipReplaceVehicle], Source={x:Static starterNg:App.Loc}}"
|
||||
Content="{Binding [ReplaceVehicle], Source={x:Static starterNg:App.Loc}}" />
|
||||
<Button Name="addButton" Grid.Column="3" Classes="Flat Accent" MinWidth="80" Cursor="Hand"
|
||||
IsEnabled="False" VerticalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center" Click="AddButton_OnClick"
|
||||
Content="{Binding [AddVehicle], Source={x:Static starterNg:App.Loc}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
272
StarterNG/Views/VehicleBaseWindow.axaml.cs
Normal file
272
StarterNG/Views/VehicleBaseWindow.axaml.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Every livery of the installation, to look through by what its credit line says.
|
||||
/// Whatever is typed filters straight away; a column's head sorts by it.
|
||||
/// </summary>
|
||||
public partial class VehicleBaseWindow : Window
|
||||
{
|
||||
private const int PreviewHeight = 40;
|
||||
|
||||
private sealed record Column(TextBlock Head, string LocKey, Func<VehicleBaseRow, string> Key);
|
||||
|
||||
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
|
||||
private readonly MiniTextures _minis = new();
|
||||
private readonly List<VehicleBaseRow> _all = new();
|
||||
private readonly TextBox[] _filters;
|
||||
private readonly Column[] _columns;
|
||||
private Column _sortBy;
|
||||
private bool _descending;
|
||||
private bool _clearing;
|
||||
|
||||
public VehicleTexture? Picked { get; private set; }
|
||||
|
||||
/// <summary>The vehicle picked goes behind the selected one instead of taking its place.</summary>
|
||||
public bool PickedToAdd { get; private set; }
|
||||
|
||||
public VehicleBaseWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
foreach (var texture in _db.Textures)
|
||||
if (!_db.IsSetFollower(texture))
|
||||
_all.Add(new VehicleBaseRow(texture));
|
||||
|
||||
revFromLabel.Text = App.Loc["RevisionFrom"].TrimEnd(':', ' ');
|
||||
revToLabel.Text = App.Loc["RevisionTo"].TrimEnd(':', ' ');
|
||||
|
||||
_columns = new[]
|
||||
{
|
||||
new Column(headSkin, "TextureInfo", r => r.Skin),
|
||||
new Column(headClass, "Class", r => r.Class),
|
||||
new Column(headOperator, "TexOperator", r => r.Operator),
|
||||
new Column(headRevision, "TexRevision", r => r.DateKey),
|
||||
new Column(headAuthor, "TexAuthor", r => r.Author)
|
||||
};
|
||||
_sortBy = _columns[1];
|
||||
ShowSort();
|
||||
|
||||
_filters = new[] { fName, fClass, fOperator, fAuthor, fModel, fStation, fPhoto, fRevFrom, fRevTo };
|
||||
foreach (var box in _filters)
|
||||
box.TextChanged += (_, _) => Refresh();
|
||||
|
||||
hideArchivalCheck.IsChecked = AppServices.Current.Settings.HideArchivalVehicles;
|
||||
hideArchivalCheck.IsCheckedChanged += HideArchivalCheck_OnChanged;
|
||||
|
||||
FillCategories();
|
||||
categoryList.SelectionChanged += (_, _) => { FillClasses(); Refresh(); };
|
||||
classList.SelectionChanged += (_, _) => Refresh();
|
||||
|
||||
vehicleList.ContextRequested += VehicleList_OnContextRequested;
|
||||
vehicleList.AddHandler(PointerReleasedEvent, VehicleList_OnPointerReleased, handledEventsToo: true);
|
||||
|
||||
ShowSelected();
|
||||
Refresh();
|
||||
Opened += (_, _) => fName.Focus();
|
||||
}
|
||||
|
||||
private void FillCategories()
|
||||
{
|
||||
categoryList.Items.Add(new ListBoxItem { Content = App.Loc["CatAll"], Tag = null });
|
||||
foreach (var (locKey, match) in VehicleBrowser.CategoryDefs)
|
||||
if (_all.Any(r => match(r.Category)))
|
||||
categoryList.Items.Add(new ListBoxItem { Content = App.Loc[locKey], Tag = match });
|
||||
|
||||
categoryList.SelectedIndex = 0;
|
||||
FillClasses();
|
||||
}
|
||||
|
||||
private bool Offered(VehicleBaseRow row) =>
|
||||
hideArchivalCheck.IsChecked != true || !row.Texture.ResolvedArchived;
|
||||
|
||||
private bool InCategory(VehicleBaseRow row) =>
|
||||
(categoryList.SelectedItem as ListBoxItem)?.Tag is not Func<string?, bool> match || match(row.Category);
|
||||
|
||||
/// <summary>The classes of the category, keeping the one picked if it is still among them.</summary>
|
||||
private void FillClasses()
|
||||
{
|
||||
string? keep = (classList.SelectedItem as VehicleClassRow)?.Name;
|
||||
|
||||
var rows = VehicleBaseQuery.ClassCounts(_all.Where(r => Offered(r) && InCategory(r)));
|
||||
|
||||
classList.ItemsSource = rows;
|
||||
classList.SelectedItem = rows.FirstOrDefault(c => string.Equals(c.Name, keep, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (_clearing)
|
||||
return;
|
||||
|
||||
var list = VehicleBaseQuery.Select(
|
||||
_all.Where(r => Offered(r) && InCategory(r)),
|
||||
new VehicleBaseFilters
|
||||
{
|
||||
Name = fName.Text,
|
||||
Class = fClass.Text,
|
||||
Operator = fOperator.Text,
|
||||
Author = fAuthor.Text,
|
||||
Model = fModel.Text,
|
||||
Station = fStation.Text,
|
||||
Photo = fPhoto.Text,
|
||||
RevisionFrom = fRevFrom.Text,
|
||||
RevisionTo = fRevTo.Text,
|
||||
PickedClass = (classList.SelectedItem as VehicleClassRow)?.Name
|
||||
},
|
||||
_sortBy.Key, _descending);
|
||||
|
||||
var keep = vehicleList.SelectedItem;
|
||||
vehicleList.ItemsSource = list;
|
||||
if (keep is VehicleBaseRow row && list.Contains(row))
|
||||
vehicleList.SelectedItem = row;
|
||||
|
||||
emptyText.IsVisible = list.Count == 0;
|
||||
statusText.Text = string.Format(App.Loc["TextureBaseCount"], list.Count, _all.Count(Offered));
|
||||
}
|
||||
|
||||
private void Head_OnTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
var column = _columns.First(c => ReferenceEquals(c.Head, sender));
|
||||
_descending = ReferenceEquals(column, _sortBy) && !_descending;
|
||||
_sortBy = column;
|
||||
ShowSort();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void ShowSort()
|
||||
{
|
||||
foreach (var column in _columns)
|
||||
column.Head.Text = App.Loc[column.LocKey] +
|
||||
(!ReferenceEquals(column, _sortBy) ? "" : _descending ? " ▼" : " ▲");
|
||||
}
|
||||
|
||||
private void ClearButton_OnClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_clearing = true;
|
||||
foreach (var box in _filters)
|
||||
box.Text = "";
|
||||
categoryList.SelectedIndex = 0;
|
||||
classList.SelectedItem = null;
|
||||
_clearing = false;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void HideArchivalCheck_OnChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
|
||||
AppServices.Current.SettingsStore.Save();
|
||||
FillClasses();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => ShowSelected();
|
||||
|
||||
private void ShowSelected()
|
||||
{
|
||||
var row = vehicleList.SelectedItem as VehicleBaseRow;
|
||||
replaceButton.IsEnabled = row is not null;
|
||||
addButton.IsEnabled = row is not null;
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
preview.Source = null;
|
||||
detailTitle.Text = "";
|
||||
detailText.Text = App.Loc["TextureBasePick"];
|
||||
ToolTip.SetTip(detailText, null);
|
||||
return;
|
||||
}
|
||||
|
||||
preview.Source = _minis.Get(_db.ResolveMiniName(row.Texture), PreviewHeight);
|
||||
detailTitle.Text = string.Join(" · ", new[] { row.Skin, row.Class, row.Operator }.Where(s => s.Length > 0));
|
||||
|
||||
var lines = new[]
|
||||
{
|
||||
(App.Loc["Model"], row.Model), (App.Loc["TexStation"], row.Station),
|
||||
(App.Loc["TexRevision"], row.Revision), (App.Loc["TexPhoto"], row.Photo)
|
||||
}
|
||||
.Where(p => p.Item2.Length > 0)
|
||||
.Select(p => $"{p.Item1}: {p.Item2}")
|
||||
.ToList();
|
||||
detailText.Text = string.Join(" · ", lines);
|
||||
ToolTip.SetTip(detailText, string.Join("\n", lines));
|
||||
}
|
||||
|
||||
private void Take(VehicleBaseRow row, bool add)
|
||||
{
|
||||
Picked = row.Texture;
|
||||
PickedToAdd = add;
|
||||
}
|
||||
|
||||
/// <returns>Whether there was one selected to take.</returns>
|
||||
private bool TakeSelected(bool add)
|
||||
{
|
||||
if (vehicleList.SelectedItem is not VehicleBaseRow row) return false;
|
||||
Take(row, add);
|
||||
Close();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReplaceButton_OnClick(object? sender, RoutedEventArgs e) => TakeSelected(add: false);
|
||||
|
||||
private void AddButton_OnClick(object? sender, RoutedEventArgs e) => TakeSelected(add: true);
|
||||
|
||||
private void Window_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Escape) return;
|
||||
Close();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void VehicleList_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && TakeSelected(add: false))
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void VehicleList_OnDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (vehicleList.SelectedItem is VehicleBaseRow row)
|
||||
Take(row, add: false);
|
||||
}
|
||||
|
||||
// DoubleTapped is raised from the second press, while the list still holds the
|
||||
// pointer. Closing there leaves the capture on a dead window and on Windows the
|
||||
// owner stops taking clicks. The release ends the gesture and drops the capture,
|
||||
// so that is where the window goes.
|
||||
private void VehicleList_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (Picked != null)
|
||||
Close();
|
||||
}
|
||||
|
||||
private void VehicleList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (vehicleList.SelectedItem is not VehicleBaseRow row) return;
|
||||
|
||||
var menu = new ContextMenu();
|
||||
|
||||
void Pick(string locKey, bool add) => menu.AddItem(App.Loc[locKey], () =>
|
||||
{
|
||||
Take(row, add);
|
||||
Dispatcher.UIThread.Post(Close);
|
||||
});
|
||||
|
||||
Pick("ReplaceVehicle", add: false);
|
||||
Pick("AddVehicle", add: true);
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
TextureMenuItems.Add(menu, vehicleList, row.Texture);
|
||||
menu.Open(vehicleList);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user