Files
StarterNG/StarterNG/Views/Scenarios.axaml.cs
maj00r 8c046cfed4 Match scenery vehicles by folder too, as the old starter did
The old starter looked a vehicle up by folder, skin and model. The folder was
left out here, and 39 skin names recur across folders - "red" is a Clio, an
Audi and a crane - so a vehicle could get another folder's livery.
2026-10-04 19:07:23 +02:00

1176 lines
39 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.VisualTree;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Application;
using StarterNG.Domain.Sceneries;
using StarterNG.Presentation.Scenarios;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Views;
public partial class Scenarios : UserControl
{
public List<Scenery> Sceneries;
private Scenery? _currentScenery;
private bool _loadingWeather;
private bool _todaySeason;
private bool _loadingFilters;
public Scenarios()
{
InitializeComponent();
SyncConsistRowHeight();
Sceneries = AppServices.Current.Library.Sceneries;
_loadingFilters = true;
archivalSwitch.IsChecked = !AppServices.Current.Settings.ShowArchivalSceneries;
_loadingFilters = false;
BuildSceneryTree();
RestoreLastScenery();
AttachedToVisualTree += (_, _) => OnReentered();
PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && IsVisible)
OnReentered();
};
}
private void OnReentered()
{
RefreshVehicleLabels();
RefreshSelectedConsist();
}
private void BuildSceneryTree()
{
sceneryList.Items.Clear();
bool includeArchival = archivalSwitch.IsChecked != true;
bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree;
var favorites = AppServices.Current.Favorites;
var nodes = new SceneryTreeBuilder(AppServices.Current.SceneryTexts)
.Build(Sceneries, includeArchival, App.Loc.CurrentLangCode, favorites.Contains, App.Loc["Favorites"]);
foreach (var node in nodes)
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
}
private static TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups)
{
var item = new TreeViewItem
{
Header = node.Label,
Tag = node.IsGroup ? null : node.SceneryIndex,
IsExpanded = node.IsGroup && expandGroups
};
foreach (var child in node.Children)
item.Items.Add(ToTreeItem(child, expandGroups));
return item;
}
private Scenery? SelectedScenery =>
sceneryList.SelectedItem is TreeViewItem { Tag: int index } && index < Sceneries.Count
? Sceneries[index]
: null;
// A right click picks the scenery under it, so the menu speaks of that one. A
// group has nothing to star, and the menu is not opened on it at all - it would
// otherwise act on whatever scenery was selected before.
private void SceneryList_OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
switch ((e.Source as Visual).FindAncestorOfType<TreeViewItem>(includeSelf: true))
{
case { Tag: int } item:
sceneryList.SelectedItem = item;
break;
case not null:
e.Handled = true;
break;
}
}
private void SceneryMenu_OnOpening(object? sender, CancelEventArgs e)
{
var scenery = SelectedScenery;
favoriteMenuItem.IsEnabled = scenery is not null;
favoriteMenuItem.Header = App.Loc[scenery is not null && AppServices.Current.Favorites.Contains(scenery)
? "FavoriteRemove"
: "FavoriteAdd"];
}
private void ToggleFavorite_OnClick(object? sender, RoutedEventArgs e)
{
if (SelectedScenery is not { } scenery)
return;
AppServices.Current.Favorites.Toggle(scenery);
BuildSceneryTree();
AppServices.Current.Settings.LastScenery = Path.GetFileNameWithoutExtension(scenery.Path);
RestoreLastScenery();
}
private void RestoreLastScenery()
{
string last = AppServices.Current.Settings.LastScenery;
string want = Path.GetFileNameWithoutExtension(last);
TreeViewItem? MatchLeaf(TreeViewItem node)
{
if (node.Tag is not int idx || idx < 0 || idx >= Sceneries.Count)
return null;
var scn = Sceneries[idx];
if (string.IsNullOrWhiteSpace(want)) return null;
if (string.Equals(scn.DisplayName, want, StringComparison.OrdinalIgnoreCase) ||
string.Equals(Path.GetFileNameWithoutExtension(scn.Path), want, StringComparison.OrdinalIgnoreCase) ||
string.Equals(node.Header as string, want, StringComparison.OrdinalIgnoreCase))
return node;
return null;
}
TreeViewItem? firstLeaf = null;
foreach (var obj in sceneryList.Items)
{
if (obj is not TreeViewItem node)
continue;
if (node.Tag is int)
{
firstLeaf ??= node;
if (MatchLeaf(node) is { } hit)
{
ExpandAncestors(hit);
sceneryList.SelectedItem = hit;
return;
}
}
foreach (var childObj in node.Items)
{
if (childObj is not TreeViewItem { Tag: int } child)
continue;
firstLeaf ??= child;
if (MatchLeaf(child) is { } hit)
{
node.IsExpanded = true;
sceneryList.SelectedItem = hit;
return;
}
}
}
if (firstLeaf != null)
{
if (firstLeaf.Parent is TreeViewItem parent)
parent.IsExpanded = true;
sceneryList.SelectedItem = firstLeaf;
}
}
private static void ExpandAncestors(TreeViewItem node)
{
var parent = node.Parent;
while (parent != null)
{
if (parent is TreeViewItem tvi)
tvi.IsExpanded = true;
parent = parent.Parent;
}
}
private void PersistLastScenery(Scenery scenery)
{
string name = scenery.DisplayName;
if (string.Equals(AppServices.Current.Settings.LastScenery, name, StringComparison.OrdinalIgnoreCase))
return;
AppServices.Current.Settings.LastScenery = name;
AppServices.Current.SettingsStore.Save();
}
private static string TrainsetListLabel(Trainset trainset) =>
TrainsetDisplay.CompositionText(trainset)
?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
private static Control TrainsetListContent(Trainset trainset) =>
new Avalonia.Controls.TextBlock
{
Text = TrainsetListLabel(trainset),
TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis,
TextWrapping = Avalonia.Media.TextWrapping.NoWrap
};
private void PopulateVehicleList(Scenery scn)
{
vehicleList.Items.Clear();
bool showAi = AppServices.Current.Settings.ShowAiVehicles;
bool drivableOnly = AppServices.Current.Settings.DrivableOnly;
for (int i = 0; i < scn.Trainsets.Count; i++)
{
var trainset = scn.Trainsets[i];
if (IsAiTrainset(trainset) && !showAi) continue;
if (drivableOnly && !UData.StaffedTrain(trainset)) continue;
var listItem = new ListBoxItem
{
Content = TrainsetListContent(trainset),
Tag = i
};
listItem.ContextMenu = ConsistContextMenu.Build(
listItem,
() => trainset,
vehicles => ApplyConsist(listItem, trainset, vehicles));
vehicleList.Items.Add(listItem);
}
if (vehicleList.Items.Count > 0)
vehicleList.SelectedIndex = 0;
}
private static bool IsAiTrainset(Trainset trainset) =>
!string.IsNullOrEmpty(trainset.Description) &&
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
{
if (_loadingFilters) return;
AppServices.Current.Settings.ShowArchivalSceneries = archivalSwitch.IsChecked != true;
AppServices.Current.SettingsStore.Save();
BuildSceneryTree();
RestoreLastScenery();
}
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
{
trainset.Vehicles = vehicles;
item.Content = TrainsetListContent(trainset);
if (ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset))
ShowConsist(trainset);
}
private void RefreshVehicleLabels()
{
if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count)
return;
var scn = Sceneries[tag];
foreach (var obj in vehicleList.Items)
{
if (obj is ListBoxItem { Tag: int vi } lbi && vi < scn.Trainsets.Count)
lbi.Content = TrainsetListContent(scn.Trainsets[vi]);
}
}
private void SceneryList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
var item = sceneryList.SelectedItem as TreeViewItem;
if (item?.Tag is not int tag)
{
foreach (TreeViewItem? toCollapse in sceneryList.Items)
{
if (toCollapse != null)
toCollapse.IsExpanded = false;
}
if (item != null)
item.IsExpanded = true;
if (item is { Items.Count: > 0 })
sceneryList.SelectedItem = item.Items[0];
return;
}
Scenery selectedScn = Sceneries[tag];
ShowSceneryInfo(selectedScn);
ShowWeather(selectedScn);
PersistLastScenery(selectedScn);
missionDescription.Text = "";
timetableContent.Text = App.Loc["NoTimetable"];
PopulateVehicleList(selectedScn);
}
private void ShowSceneryInfo(Scenery scenery)
{
AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode);
sceneryDescription.Text = scenery.LocalizedDescription(AppServices.Current.SceneryTexts);
string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery);
if (imagePath is not null)
{
try
{
sceneryImage.Source = new Bitmap(imagePath);
}
catch (Exception ex)
{
sceneryImage.Source = null;
StarterNG.Infrastructure.Diagnostics.Log($"Scenery image {imagePath}", ex);
}
}
else
{
sceneryImage.Source = null;
}
PopulateAttachments(scenery);
PopulateFaults(scenery);
}
private void PopulateFaults(Scenery scenery)
{
var lines = new List<string>();
lines.AddRange(scenery.Faults);
var db = AppServices.Current.Library.Vehicles;
foreach (var ts in scenery.Trainsets)
{
if (!ts.Parsed)
lines.Add($"# unparsed trainset: {ts.Name}");
foreach (var v in ts.Vehicles)
{
if (string.IsNullOrEmpty(v.SkinFile)) continue;
if (db.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile) is null)
lines.Add($"# unknown texture: {v.Name} ({v.SkinFile})");
}
}
foreach (var v in scenery.LooseVehicles)
{
if (db.TextureFor(v.DataFolder, v.SkinFile, v.MmdFile) is null)
lines.Add($"# unknown loose texture: {v.Name} ({v.SkinFile})");
}
if (lines.Count > 0)
StarterNG.Infrastructure.Diagnostics.Log(lines);
}
private void PopulateAttachments(Scenery scenery)
{
attachmentsPanel.Children.Clear();
foreach (var att in scenery.Attachments)
{
var btn = new Button
{
Content = att.Label,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Left,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
Tag = att.FilePath
};
btn.Classes.Add("Flat");
ToolTip.SetTip(btn, att.FilePath);
btn.Click += (_, _) => OpenAttachment(att.FilePath);
attachmentsPanel.Children.Add(btn);
}
}
private static void OpenAttachment(string relativePath)
{
string full = Path.GetFullPath(relativePath);
if (!File.Exists(full))
full = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
if (!File.Exists(full))
return;
try
{
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.Log($"opening {full}", ex);
}
}
public void RebuildAfterLanguageChange()
{
string? selected = AppServices.Current.State.CurrentScenery?.Path;
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
AppServices.Current.Settings.LastScenery =
Path.GetFileNameWithoutExtension(selected);
RestoreLastScenery();
}
if (AppServices.Current.State.CurrentScenery != null)
ShowSceneryInfo(AppServices.Current.State.CurrentScenery);
RefreshSelectedConsist();
}
private bool _reloading;
private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
{
if (_reloading) return;
_reloading = true;
try
{
string? selected = AppServices.Current.State.CurrentScenery?.Path;
await Task.Run(() => AppServices.Current.Library.ReloadSceneries());
Sceneries = AppServices.Current.Library.Sceneries;
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
AppServices.Current.Settings.LastScenery =
Path.GetFileNameWithoutExtension(selected);
}
RestoreLastScenery();
}
finally
{
_reloading = false;
}
}
public event Action<Scenery>? RandomizeTexturesRequested;
private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e)
{
if (AppServices.Current.State.CurrentScenery is { } scenery)
RandomizeTexturesRequested?.Invoke(scenery);
}
/// <summary>
/// The floor of the consist row, so the small thumbnails do not sit in the
/// height the large ones need.
/// </summary>
private const double SmallConsistRowHeight = 84;
private const double LargeConsistRowHeight = 120;
private void SyncConsistRowHeight() =>
scenariosGrid.RowDefinitions[1].MinHeight = AppServices.Current.Settings.LargeThumbnails
? LargeConsistRowHeight
: SmallConsistRowHeight;
public void RefreshConsistView()
{
SyncConsistRowHeight();
RefreshVehicleLabels();
if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset);
}
private static readonly Avalonia.Media.IBrush SelectedVehicleBrush =
new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#41C400"));
private readonly Dictionary<string, Bitmap?> _miniCache = new();
private Bitmap? LoadMini(string? name, int height)
{
string key = $"{name}@{height}";
if (_miniCache.TryGetValue(key, out var cached))
return cached;
Bitmap? bmp = null;
string? path = AppServices.Current.MiniTextures.PathFor(name);
if (path != null)
{
try
{
using var fs = File.OpenRead(path);
bmp = Bitmap.DecodeToHeight(fs, height);
}
catch (Exception ex)
{
StarterNG.Infrastructure.Diagnostics.Log($"Mini {path}", ex);
}
}
_miniCache[key] = bmp;
return bmp;
}
private void OpenSceneryDir_OnClick(object? sender, RoutedEventArgs e)
{
var scn = AppServices.Current.State.CurrentScenery;
string dir = scn != null
? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery")
: Path.GetFullPath("scenery");
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}");
}
}
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
var tItem = sceneryList.SelectedItem as TreeViewItem;
if (tItem?.Tag is not int tTag)
return;
Scenery selectedScn = Sceneries[tTag];
ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem;
if (vItem?.Tag is not int vTag)
return;
Trainset selectedTrainset = selectedScn.Trainsets[vTag];
AppServices.Current.State.CurrentScenery = selectedScn;
AppServices.Current.State.CurrentTrainset = selectedTrainset;
AppServices.Current.State.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
ShowConsist(selectedTrainset);
ShowTimetable(selectedScn, selectedTrainset);
}
private void ShowWeather(Scenery scenery)
{
_currentScenery = scenery;
_loadingWeather = true;
try
{
weatherForm.IsEnabled = true;
var weather = scenery.Weather;
weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride);
weatherDate.SelectedDate = Seasons.DateOf(weather.Day);
weatherTemp.Value = weather.Temperature;
weatherFog.Value = FogScale.ToSlider(weather.FogEnd);
SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0");
SelectByTag(weatherOvercast, weather.OvercastRandom
? RandomTag
: weather.Overcast.ToString(CultureInfo.InvariantCulture));
ApplyTodayPreview(weather.Day == 0);
UpdateWeatherLabels();
}
finally
{
_loadingWeather = false;
}
}
private void CaptureWeather()
{
if (_loadingWeather || _currentScenery is null)
return;
var weather = _currentScenery.Weather;
if (weatherTime.SelectedTime is { } t)
weather.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
weather.Day = _todaySeason ? 0 : SelectedDayOfYear();
weather.Temperature = weatherTemp.Value;
weather.FogEnd = FogScale.ToMetres(weatherFog.Value);
if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov)
{
weather.OvercastRandom = ov == RandomTag;
if (!weather.OvercastRandom &&
double.TryParse(ov, NumberStyles.Float, CultureInfo.InvariantCulture, out double oc))
weather.Overcast = oc;
}
weather.Dirty = true;
UpdateWeatherLabels();
}
private void UpdateWeatherLabels()
{
weatherTempLabel.Text = $"{(int)weatherTemp.Value} °C";
weatherFogLabel.Text = FogScale2(FogScale.ToMetres(weatherFog.Value));
weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear());
weatherRestore.IsEnabled = _currentScenery?.Weather.Changed ?? false;
}
private static CultureInfo LanguageCulture
{
get
{
string code = App.Loc.CurrentLangCode;
if (code is "en" or "")
return CultureInfo.InvariantCulture;
try
{
return CultureInfo.GetCultureInfo(code == "cz" ? "cs" : code);
}
catch (CultureNotFoundException)
{
return CultureInfo.InvariantCulture;
}
}
}
/// <summary>Day-of-year arithmetic for the weather tab's date and season pickers.</summary>
private static SeasonDates Seasons => new(AppServices.Current.Clock);
/// <summary>Fog read-out in the language the user is reading the rest in.</summary>
private static string FogScale2(int metres) => FogScale.Format(metres, LanguageCulture);
private const string RandomTag = "random";
private static void SelectByTag(ComboBox combo, string tag, string? excludeFromNearest = null)
{
ComboBoxItem? first = null;
ComboBoxItem? nearest = null;
double bestDistance = double.MaxValue;
bool haveValue = double.TryParse(tag, NumberStyles.Float, CultureInfo.InvariantCulture,
out double value);
foreach (var obj in combo.Items)
{
if (obj is not ComboBoxItem item)
continue;
first ??= item;
if ((item.Tag as string) == tag)
{
combo.SelectedItem = item;
return;
}
if (haveValue && item.Tag is string itemTag && itemTag != excludeFromNearest &&
double.TryParse(itemTag, NumberStyles.Float, CultureInfo.InvariantCulture,
out double itemValue))
{
double distance = System.Math.Abs(itemValue - value);
if (distance < bestDistance)
{
bestDistance = distance;
nearest = item;
}
}
}
combo.SelectedItem = nearest ?? first;
}
private void WeatherTime_OnChanged(object? sender, TimePickerSelectedValueChangedEventArgs e) => CaptureWeather();
private void WeatherTimeNow_OnClick(object? sender, RoutedEventArgs e)
{
var now = DateTime.Now;
weatherTime.SelectedTime = new TimeSpan(now.Hour, now.Minute, 0);
}
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
{
if (_currentScenery is null) return;
_currentScenery.Weather.Restore();
ShowWeather(_currentScenery);
}
private static TimeSpan? ParseTime(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var m = Regex.Match(text, @"^\s*(\d{1,2})[:.](\d{2})");
if (m.Success &&
int.TryParse(m.Groups[1].Value, out int h) && h is >= 0 and <= 23 &&
int.TryParse(m.Groups[2].Value, out int min) && min is >= 0 and <= 59)
return new TimeSpan(h, min, 0);
return null;
}
private void ApplyTodayPreview(bool today)
{
_todaySeason = today;
if (today)
{
bool prev = _loadingWeather;
_loadingWeather = true;
weatherDate.SelectedDate = Seasons.DateOf(0);
_loadingWeather = prev;
}
}
private void Weather_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => CaptureWeather();
private void Weather_OnSliderChanged(object? sender, Avalonia.Controls.Primitives.RangeBaseValueChangedEventArgs e) => CaptureWeather();
private void WeatherDate_OnChanged(object? sender, DatePickerSelectedValueChangedEventArgs e)
{
if (!_loadingWeather)
{
_todaySeason = false;
_loadingWeather = true;
SelectByTag(weatherSeason, SelectedDayOfYear().ToString(), excludeFromNearest: "0");
_loadingWeather = false;
}
CaptureWeather();
}
private int SelectedDayOfYear() =>
weatherDate.SelectedDate is { } date ? date.DayOfYear : Seasons.Today;
private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e)
{
if (_loadingWeather)
return;
if ((weatherSeason.SelectedItem as ComboBoxItem)?.Tag is string tag &&
int.TryParse(tag, out int day))
{
if (day == 0)
{
ApplyTodayPreview(true);
}
else
{
ApplyTodayPreview(false);
_loadingWeather = true;
weatherDate.SelectedDate = Seasons.DateOf(day);
_loadingWeather = false;
}
}
CaptureWeather();
}
private void ShowTimetable(Scenery scenery, Trainset trainset)
{
string? path = AppServices.Current.Timetables.Resolve(scenery, trainset.Name);
if (path is null)
{
timetableContent.Text = App.Loc["NoTimetable"];
UpdateTimetableTab(false);
return;
}
try
{
timetableContent.Text = LegacyText.Decode(File.ReadAllBytes(path));
UpdateTimetableTab(true);
}
catch
{
timetableContent.Text = App.Loc["NoTimetable"];
UpdateTimetableTab(false);
}
}
private void UpdateTimetableTab(bool hasTimetable)
{
bool usable = hasTimetable || attachmentsPanel.Children.Count > 0;
timetableTab.IsEnabled = usable;
ToolTip.SetTip(timetableTab, usable ? null : App.Loc["NoTimetable"]);
if (!usable && timetableTab.IsSelected && timetableTab.Parent is TabControl tabs)
tabs.SelectedIndex = 0;
}
private const double ConsistDragThreshold = 6;
private Trainset? _stripTrainset;
private int _pressIndex = -1;
private Point _pressPoint;
private bool _dragging;
private bool _stripWired;
private int _dragBlockStart = -1;
private int _dragBlockLength;
private double _dragBlockWidth = 32;
private double _grabOffset;
private Control? _carried;
private readonly List<int> _reducedToVehicle = new();
private readonly List<int> _unitStarts = new();
private readonly Infrastructure.DropGap _dropGap = new();
private readonly Infrastructure.DropIndexTracker _dropTracker = new();
private readonly Infrastructure.EdgeScroller _edgeScroller = new();
private void ShowConsist(Trainset trainset)
{
consistStack.Children.Clear();
_stripTrainset = trainset;
_dropGap.Forget();
if (!_stripWired)
{
_stripWired = true;
consistStack.PointerMoved += Strip_OnPointerMoved;
consistStack.PointerReleased += Strip_OnPointerReleased;
consistStack.PointerCaptureLost += (_, _) => CancelConsistDrag();
}
ContextMenu VehicleMenu(Dynamic vehicle) => ConsistContextMenu.Build(
consistStack,
() => trainset,
vehicles =>
{
trainset.Vehicles = vehicles;
RefreshVehicleLabels();
ShowConsist(trainset);
UpdateTrainStats(trainset);
},
removeAll: null,
getVehicle: () => vehicle,
removeVehicle: v =>
{
int gap = trainset.Vehicles.IndexOf(v);
trainset.Vehicles.Remove(v);
bool wasPicked = !string.IsNullOrEmpty(v.Name) &&
string.Equals(v.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase);
if (wasPicked && trainset.Vehicles.Count > 0)
{
int next = Math.Clamp(gap, 0, trainset.Vehicles.Count - 1);
AppServices.Current.State.StartingVehicleName = trainset.Vehicles[next].Name;
AppServices.Current.State.NotifyChanged();
}
RefreshVehicleLabels();
ShowConsist(trainset);
UpdateTrainStats(trainset);
});
var db = AppServices.Current.Library.Vehicles;
foreach (var train in trainset.Vehicles)
{
string miniName = db.MiniFor(train.DataFolder, train.SkinFile, train.MmdFile) ?? train.SkinFile;
if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile))
miniName = train.SkinFile;
int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
var bmp = LoadMini(miniName, thumbH);
Control visual = bmp is not null
? MiniTextures.Sharp(new Image
{
Source = bmp,
Height = thumbH,
Stretch = Avalonia.Media.Stretch.Uniform,
Margin = new Thickness(0)
})
: MiniTextures.MissingVisual(train.SkinFile, thumbH);
bool selected = !string.IsNullOrEmpty(train.Name) &&
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
StringComparison.OrdinalIgnoreCase);
var slot = new Border
{
Transitions = new Transitions
{
new DoubleTransition
{
Property = OpacityProperty,
Duration = TimeSpan.FromMilliseconds(120)
}
},
Child = visual,
Padding = new Thickness(0),
BorderThickness = new Thickness(0, 2),
BorderBrush = selected ? SelectedVehicleBrush : Avalonia.Media.Brushes.Transparent,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
ContextMenu = VehicleMenu(train)
};
ToolTip.SetTip(slot, train.Name);
int slotIndex = consistStack.Children.Count;
slot.PointerPressed += (_, ev) =>
{
if (!ev.GetCurrentPoint(slot).Properties.IsLeftButtonPressed) return;
_pressIndex = slotIndex;
_pressPoint = ev.GetPosition(consistStack);
_dragging = false;
};
consistStack.Children.Add(slot);
}
string desc = trainset.Description ?? "";
if (desc.TrimStart().StartsWith('-'))
desc = desc.TrimStart()[1..].TrimStart();
missionDescription.Text = AppServices.Current.SceneryTexts.Translate(desc);
UpdateTrainStats(trainset);
}
private void Strip_OnPointerMoved(object? sender, PointerEventArgs e)
{
var point = e.GetPosition(consistStack);
if (!_dragging)
{
if (_pressIndex < 0 || !e.GetCurrentPoint(consistStack).Properties.IsLeftButtonPressed)
return;
var delta = point - _pressPoint;
if (Math.Abs(delta.X) < ConsistDragThreshold && Math.Abs(delta.Y) < ConsistDragThreshold)
return;
BeginConsistDrag(e);
if (!_dragging) return;
}
Canvas.SetLeft(_carried!, point.X - _grabOffset);
double pointerX = e.GetPosition(consistScroll).X;
if (_dropTracker.Update(pointerX + consistScroll.Offset.X))
{
int reduced = ReducedTarget();
_dropGap.Show(consistStack,
reduced < _reducedToVehicle.Count
? _reducedToVehicle[reduced]
: consistStack.Children.Count,
_dragBlockWidth);
}
_edgeScroller.Update(consistScroll, pointerX);
}
private void Strip_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_dragging)
{
int index = _pressIndex;
_pressIndex = -1;
if (e.InitialPressMouseButton != MouseButton.Left ||
index < 0 || _stripTrainset is not { } clicked ||
index >= clicked.Vehicles.Count)
return;
AppServices.Current.State.StartingVehicleName = clicked.Vehicles[index].Name;
AppServices.Current.State.NotifyChanged();
ShowConsist(clicked);
return;
}
int target = _dropTracker.Index >= 0 ? ReducedTarget() : -1;
int start = _dragBlockStart;
int length = _dragBlockLength;
var trainset = _stripTrainset;
EndConsistDrag();
if (trainset != null && start >= 0 && target >= 0)
MoveConsistBlock(trainset, start, length, target);
}
private void BeginConsistDrag(PointerEventArgs e)
{
if (_stripTrainset is not { } trainset || _pressIndex < 0 ||
_pressIndex >= trainset.Vehicles.Count)
return;
_dragBlockStart = SetStart(trainset.Vehicles, _pressIndex);
_dragBlockLength = SetLength(trainset.Vehicles, _dragBlockStart);
if (_dragBlockStart + _dragBlockLength > consistStack.Children.Count)
return;
var first = consistStack.Children[_dragBlockStart];
var blockBounds = first.Bounds;
_dragBlockWidth = 0;
for (int i = _dragBlockStart; i < _dragBlockStart + _dragBlockLength; i++)
_dragBlockWidth += consistStack.Children[i].Bounds.Width;
_dragBlockWidth = Math.Max(_dragBlockWidth, 16);
_grabOffset = Math.Clamp(_pressPoint.X - blockBounds.Left, 0, _dragBlockWidth);
CaptureDropTargets(trainset);
_carried = BuildCarriedVisual(blockBounds.Height);
Canvas.SetTop(_carried, blockBounds.Top);
Canvas.SetLeft(_carried, blockBounds.Left);
consistOverlay.Children.Add(_carried);
for (int i = _dragBlockStart; i < _dragBlockStart + _dragBlockLength; i++)
consistStack.Children[i].IsVisible = false;
_dragging = true;
e.Pointer.Capture(consistStack);
}
private void CaptureDropTargets(Trainset trainset)
{
var vehicles = trainset.Vehicles;
_reducedToVehicle.Clear();
for (int i = 0; i < vehicles.Count && i < consistStack.Children.Count; i++)
if (i < _dragBlockStart || i >= _dragBlockStart + _dragBlockLength)
_reducedToVehicle.Add(i);
_unitStarts.Clear();
var midpoints = new List<double>();
int r = 0;
while (r < _reducedToVehicle.Count)
{
int length = 1;
while (r + length < _reducedToVehicle.Count &&
_reducedToVehicle[r + length] == _reducedToVehicle[r + length - 1] + 1 &&
HoldsNext(vehicles[_reducedToVehicle[r + length - 1]]))
length++;
double left = ChildEdge(_reducedToVehicle[r], right: false);
double right = ChildEdge(_reducedToVehicle[r + length - 1], right: true);
midpoints.Add((left + right) / 2);
_unitStarts.Add(r);
r += length;
}
_dropTracker.Capture(midpoints);
}
private double ChildEdge(int childIndex, bool right)
{
var bounds = consistStack.Children[childIndex].Bounds;
double edge = right ? bounds.Right : bounds.Left;
return childIndex > _dragBlockStart ? edge - _dragBlockWidth : edge;
}
private int ReducedTarget()
{
int unit = _dropTracker.Index;
if (unit < 0) return -1;
return unit < _unitStarts.Count ? _unitStarts[unit] : _reducedToVehicle.Count;
}
private Control BuildCarriedVisual(double height)
{
var panel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal };
for (int i = _dragBlockStart; i < _dragBlockStart + _dragBlockLength; i++)
{
var source = consistStack.Children[i];
// The ghost mirrors what the slot shows, stand-in wagons included, so
// dragging a vehicle with no thumbnail still carries something visible.
Control copy = source is Border { Child: Image image }
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
: MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
panel.Children.Add(copy);
}
return new Border
{
Child = panel,
Height = height,
Opacity = 0.85,
BorderThickness = new Thickness(0, 2),
BorderBrush = SelectedVehicleBrush
};
}
private void CancelConsistDrag()
{
if (!_dragging) return;
EndConsistDrag();
}
private void EndConsistDrag()
{
for (int i = _dragBlockStart; i >= 0 && i < _dragBlockStart + _dragBlockLength; i++)
if (i < consistStack.Children.Count)
consistStack.Children[i].IsVisible = true;
if (_carried != null)
consistOverlay.Children.Remove(_carried);
_carried = null;
_dropTracker.Reset();
_edgeScroller.Stop();
_dropGap.Hide(consistStack);
_dragBlockStart = -1;
_dragBlockLength = 0;
_pressIndex = -1;
_dragging = false;
}
private void MoveConsistBlock(Trainset trainset, int start, int length, int target)
{
var vehicles = trainset.Vehicles;
if (start < 0 || length <= 0 || start + length > vehicles.Count)
return;
var block = vehicles.GetRange(start, length);
vehicles.RemoveRange(start, length);
target = Math.Clamp(target, 0, vehicles.Count);
if (target == start)
{
vehicles.InsertRange(start, block);
return;
}
vehicles.InsertRange(target, block);
RefreshVehicleLabels();
ShowConsist(trainset);
UpdateTrainStats(trainset);
}
private static bool HoldsNext(Dynamic car) =>
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;
private static int SetStart(List<Dynamic> vehicles, int index)
{
while (index > 0 && HoldsNext(vehicles[index - 1]))
index--;
return index;
}
private static int SetLength(List<Dynamic> vehicles, int start)
{
int length = 1;
while (start + length < vehicles.Count && HoldsNext(vehicles[start + length - 1]))
length++;
return length;
}
private void UpdateTrainStats(Trainset? trainset)
{
if (trainset is null)
{
trainStats.Children.Clear();
return;
}
var db = AppServices.Current.Library.Vehicles;
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
trainset,
car => AppServices.Current.Physics.For(car.DataFolder, db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.Model)
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile),
car => db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.ResolvedCategory));
}
private void RefreshSelectedConsist()
{
if (AppServices.Current.State.CurrentTrainset is { } trainset)
ShowConsist(trainset);
}
}