diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs
index 9640373..d2d82c9 100644
--- a/StarterNG/MainWindow.axaml.cs
+++ b/StarterNG/MainWindow.axaml.cs
@@ -19,8 +19,6 @@ namespace StarterNG;
public partial class MainWindow : Window
{
- // Tracks Ctrl for Start bypass (Pascal btnStart.Tag while Ctrl held).
- private KeyModifiers _mods;
private DateTime _openedUtc = DateTime.UtcNow;
public MainWindow()
@@ -48,13 +46,6 @@ public partial class MainWindow : Window
UpdateStartButton();
};
Activated += (_, _) => CheckExternalSettings();
- AddHandler(InputElement.KeyDownEvent, (_, e) => { _mods = e.KeyModifiers; UpdateStartButton(); }, handledEventsToo: true);
- AddHandler(InputElement.KeyUpEvent, (_, e) => { _mods = e.KeyModifiers; UpdateStartButton(); }, handledEventsToo: true);
- AddHandler(InputElement.PointerMovedEvent, (_, e) =>
- {
- var m = e.KeyModifiers;
- if (m != _mods) { _mods = m; UpdateStartButton(); }
- }, handledEventsToo: true);
}
// Lets the user drag the window by the top caption strip.
@@ -185,15 +176,14 @@ public partial class MainWindow : Window
}
}
- // Pascal actStartUpdate: Start needs scenery + trainset + staffed/passenger
- // vehicle. Hold Ctrl to force START (bypass crew check).
+ // Pascal actStartUpdate: Start needs a scenery and a trainset. A consist with
+ // nobody aboard still starts, after the confirmation in StartButton_OnClick.
private void UpdateStartButton()
{
if (startButton is null) return;
var scenery = AppState.Instance.CurrentScenery;
var trainset = AppState.Instance.CurrentTrainset;
- bool ctrl = _mods.HasFlag(KeyModifiers.Control);
if (scenery is null)
{
@@ -207,14 +197,11 @@ public partial class MainWindow : Window
startButton.IsEnabled = false;
return;
}
- if (!HasStartableVehicle(trainset) && !ctrl)
- {
- startButton.Content = App.Loc["StartNoStaff"];
- startButton.IsEnabled = false;
- return;
- }
-
- startButton.Content = App.Loc["Start"];
+ // No driver and no passenger: still startable, but say so on the button
+ // and ask before launching.
+ bool staffed = HasStartableVehicle(trainset);
+ startButton.Content = staffed ? App.Loc["Start"] : App.Loc["StartNoStaff"];
+ ToolTip.SetTip(startButton, staffed ? null : App.Loc["StartNoStaffConfirm"]);
startButton.IsEnabled = true;
}
@@ -236,7 +223,7 @@ public partial class MainWindow : Window
// ── START: export the (possibly depot-modified) scenery and launch the sim.
// Global on the main window, like the original Starter's bottom START button.
- private void StartButton_OnClick(object? sender, RoutedEventArgs e)
+ private async void StartButton_OnClick(object? sender, RoutedEventArgs e)
{
var scenery = AppState.Instance.CurrentScenery;
if (scenery is null)
@@ -245,7 +232,11 @@ public partial class MainWindow : Window
if (trainset is null)
return;
- if (!HasStartableVehicle(trainset) && !_mods.HasFlag(KeyModifiers.Control))
+ // Launching a consist with no driver and no passenger is legal (a scenery
+ // can be watched from the outside), so confirm instead of blocking it.
+ if (!HasStartableVehicle(trainset) &&
+ !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"],
+ App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
return;
if (trainset.Vehicles.Count > 0)
diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml
index 477b399..5704251 100644
--- a/StarterNG/Views/Depot.axaml
+++ b/StarterNG/Views/Depot.axaml
@@ -13,97 +13,106 @@
-
+
-
+
-
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+ Selecting one loads it into the editor below; the header carries the
+ scenery name (see PopulateSceneryConsists). -->
+
-
-
-
+
+
-
diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs
index 88b8f37..16b053e 100644
--- a/StarterNG/Views/Depot.axaml.cs
+++ b/StarterNG/Views/Depot.axaml.cs
@@ -6,6 +6,7 @@ using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Layout;
@@ -37,6 +38,12 @@ public partial class Depot : UserControl
private int ConsistThumbHeight =>
StarterNG.Classes.Settings.Instance.LargeThumbnails ? 68 : 34;
+ // Display height of the mini under the vehicle list; the class combo uses
+ // the same size.
+ private const int MiniPreviewHeight = 54;
+
+ private const int CompactThumbHeight = 24;
+
private readonly Cursor _hand = new(StandardCursorType.Hand);
private int _nameCounter;
@@ -45,6 +52,7 @@ public partial class Depot : UserControl
// suppresses combo SelectionChanged handlers while we populate them
private bool _suppress;
+ private bool _syncingCombos; // set while the combos mirror a pick
// debounces search input so we don't rebuild on every keystroke
private DispatcherTimer? _searchTimer;
@@ -166,6 +174,7 @@ public partial class Depot : UserControl
Dispatcher.UIThread.Post(() =>
{
hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
+ InitClassComboTemplates();
PopulateCategoryCombo(); // -> populates class combo -> builds browser
PopulateSceneryConsists(); // -> lists the current scenery's consists
RebuildConsist();
@@ -366,12 +375,8 @@ public partial class Depot : UserControl
private void RebuildClassCombo()
{
- // No "All" entry: an unselected class means "every class in the category".
- // Each class row shows its category mini thumbnail (like the Pascal starter).
_suppress = true;
- classCombo.Items.Clear();
- foreach (string cls in ClassesForCategory(_categoryFilter))
- classCombo.Items.Add(new ComboBoxItem { Content = ClassComboContent(cls), Tag = cls });
+ FillClassCombo();
classCombo.SelectedIndex = -1;
_suppress = false;
@@ -379,27 +384,88 @@ public partial class Depot : UserControl
BuildList();
}
- private Control ClassComboContent(string cls)
+ // The dropdown shows the big stacked miniature, the closed combo a one-line
+ // row. Plain "=": SelectionBoxItemTemplate coerces to ItemTemplate while
+ // unset, so "??=" would never assign.
+ private void InitClassComboTemplates()
{
- var row = new StackPanel
+ classCombo.ItemTemplate = new FuncDataTemplate(
+ (cls, _) => ClassComboContent(cls, large: true), false);
+ classCombo.SelectionBoxItemTemplate = new FuncDataTemplate(
+ (cls, _) => ClassComboContent(cls, large: false), false);
+ }
+
+ // Fills the class combo for the current category filter; the caller owns the
+ // selection and _classFilter. Runs under _suppress.
+ // No "All" entry: an unselected class means "every class in the category".
+ private void FillClassCombo()
+ {
+ classCombo.Items.Clear();
+ foreach (string cls in ClassesForCategory(_categoryFilter))
+ classCombo.Items.Add(cls);
+ }
+
+ // Points both combos at the given category/class pair without firing their
+ // handlers. Returns false when they already matched.
+ private bool ApplyFilters(string? category, string? cls)
+ {
+ var catItem = categoryCombo.Items.OfType()
+ .FirstOrDefault(it => it.Tag is Func f && f(category));
+
+ if (catItem != null && ReferenceEquals(categoryCombo.SelectedItem, catItem) &&
+ string.Equals(_classFilter, cls, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ _suppress = true;
+ try
{
- Orientation = Orientation.Horizontal,
- Spacing = 8,
+ if (catItem != null)
+ {
+ categoryCombo.SelectedItem = catItem;
+ _categoryFilter = catItem.Tag as Func;
+ }
+ FillClassCombo();
+ classCombo.SelectedItem = classCombo.Items.OfType()
+ .FirstOrDefault(s => string.Equals(s, cls, StringComparison.OrdinalIgnoreCase));
+ _classFilter = classCombo.SelectedItem as string;
+ }
+ finally { _suppress = false; }
+ return true;
+ }
+
+ // Every texture of a class shares its category.
+ private string? CategoryOfClass(string cls) =>
+ _db.Textures.FirstOrDefault(t => string.Equals(ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
+ is { } t ? CategoryOf(t) : null;
+
+ private Control ClassComboContent(string cls, bool large)
+ {
+ int height = large ? MiniPreviewHeight : CompactThumbHeight;
+ var cell = new StackPanel
+ {
+ Orientation = large ? Orientation.Vertical : Orientation.Horizontal,
+ Spacing = large ? 2 : 8,
+ HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center
};
- var bmp = GetMiniBitmap(cls, 28);
+ var bmp = GetMiniBitmap(cls, height);
if (bmp != null)
- row.Children.Add(new Image
+ cell.Children.Add(new Image
{
- Source = bmp, Height = 28, Width = 56,
- Stretch = Stretch.Uniform, VerticalAlignment = VerticalAlignment.Center
+ Source = bmp, Height = height,
+ Stretch = Stretch.Uniform,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
});
- row.Children.Add(new TextBlock
+ cell.Children.Add(new TextBlock
{
Text = cls,
- VerticalAlignment = VerticalAlignment.Center
+ HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextAlignment = TextAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis
});
- return row;
+ return cell;
}
private IEnumerable ClassesForCategory(Func? category) =>
@@ -422,7 +488,14 @@ public partial class Depot : UserControl
private void ClassCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppress) return;
- _classFilter = (classCombo.SelectedItem as ComboBoxItem)?.Tag as string;
+ string? cls = classCombo.SelectedItem as string;
+ _classFilter = cls;
+
+ // ApplyFilters refills this very combo, so it cannot run from here.
+ if (cls != null && _categoryFilter == null &&
+ PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); BuildList(); }))
+ return;
+
BuildList();
}
@@ -453,22 +526,25 @@ public partial class Depot : UserControl
// ----------------------------------------------------------- vehicle browser
// Flat list of plain-text vehicle entries (no expanders, like the original
- // Starter). Empty by default: an entry only appears once a category is chosen
- // or a search is typed. Picking one drives the mini preview and Add button.
+ // Starter). Empty by default: an entry only appears once a category or class
+ // is chosen or a search is typed. Picking one drives the mini preview and the
+ // Add button.
private void BuildList()
{
// reset selection-dependent UI
_browserSelected = null;
miniPreview.Source = null;
addVehicleButton.IsEnabled = false;
+ browserDetails.IsVisible = false;
vehicleListBox.Items.Clear();
string search = searchBox.Text?.Trim() ?? "";
bool hasSearch = search.Length > 0;
- // nothing selected and nothing searched -> keep the list empty
- if (_categoryFilter == null && !hasSearch)
+ // no filter and nothing searched -> keep the list empty (a class counts
+ // as a filter: it can be picked without a category)
+ if (_categoryFilter == null && _classFilter == null && !hasSearch)
return;
var matched = _db.Textures
@@ -502,9 +578,11 @@ public partial class Depot : UserControl
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
{
_browserSelected = texture;
- miniPreview.Source = GetMiniBitmap(_db.ResolveMiniName(texture), 54);
+ miniPreview.Source = GetMiniBitmap(_db.ResolveMiniName(texture), MiniPreviewHeight);
addVehicleButton.IsEnabled = true;
ShowTextureInfo(texture);
+ browserDetails.IsVisible = true;
+ SyncCombosTo(texture);
}
else
{
@@ -512,6 +590,7 @@ public partial class Depot : UserControl
miniPreview.Source = null;
addVehicleButton.IsEnabled = false;
textureInfoPanel.Children.Clear();
+ browserDetails.IsVisible = false;
}
}
@@ -551,12 +630,16 @@ public partial class Depot : UserControl
private void ShowTextureInfo(VehicleTexture texture)
{
textureInfoPanel.Children.Clear();
+ // The rows are trimmed, so the tooltip carries the full text.
+ var full = new List();
void Row(string label, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return;
+ string line = $"{label}: {value}";
+ full.Add(line);
textureInfoPanel.Children.Add(new TextBlock
{
- Text = $"{label}: {value}",
+ Text = line,
FontSize = 10,
Opacity = 0.85,
TextTrimming = TextTrimming.CharacterEllipsis
@@ -565,12 +648,15 @@ public partial class Depot : UserControl
Row(App.Loc["TextureInfo"], Base(texture.Skinfile));
var meta = texture.Meta;
- if (meta == null) return;
- Row(App.Loc["TexOperator"], meta.Operator);
- Row(App.Loc["TexStation"], meta.Depot);
- Row(App.Loc["TexRevision"], meta.RevisionDate);
- Row(App.Loc["TexAuthor"], meta.TextureAuthor);
- Row(App.Loc["TexPhoto"], meta.PhotoAuthor);
+ if (meta != null)
+ {
+ Row(App.Loc["TexOperator"], meta.Operator);
+ Row(App.Loc["TexStation"], meta.Depot);
+ Row(App.Loc["TexRevision"], meta.RevisionDate);
+ Row(App.Loc["TexAuthor"], meta.TextureAuthor);
+ Row(App.Loc["TexPhoto"], meta.PhotoAuthor);
+ }
+ ToolTip.SetTip(textureInfoPanel, string.Join("\n", full));
}
// Double-click an entry to replace the selected consist vehicle.
@@ -763,8 +849,8 @@ public partial class Depot : UserControl
sceneryConsistList.Items.Clear();
_listScenery = AppState.Instance.CurrentScenery ?? _sceneries.FirstOrDefault();
- mapSceneryLabel.Text = _listScenery != null
- ? Path.GetFileNameWithoutExtension(_listScenery.Path)
+ mapConsistsPanel.Header = _listScenery != null
+ ? $"{App.Loc["MapConsists"]}: {Path.GetFileNameWithoutExtension(_listScenery.Path)}"
: App.Loc["NoSceneryConsists"];
if (_listScenery != null)
@@ -1179,6 +1265,11 @@ public partial class Depot : UserControl
controls.Children.Add(SmallButton("⇄", App.Loc["TipFlip"], () => FlipItem(item)));
controls.Children.Add(SmallButton("▶", App.Loc["TipMoveRight"], () => MoveRight(item)));
+ var remove = SmallButton("✕", App.Loc["RemoveSelectedVehicle"], () => RemoveItem(item));
+ remove.Foreground = new SolidColorBrush(Color.Parse("#E05252"));
+ remove.FontWeight = FontWeight.Bold;
+ controls.Children.Add(remove);
+
var inner = new StackPanel { Spacing = 4 };
inner.Children.Add(minis);
inner.Children.Add(nameBlock);
@@ -1268,37 +1359,51 @@ public partial class Depot : UserControl
var set = _db.ResolveSet(texture);
var browserTex = set is { Count: > 0 } ? set[0] : texture;
- string? category = CategoryOf(browserTex);
- var catItem = categoryCombo.Items.OfType()
- .FirstOrDefault(it => it.Tag is Func f && f(category));
-
- if (catItem != null)
+ WhileSyncing(() =>
{
- if (!ReferenceEquals(categoryCombo.SelectedItem, catItem))
- categoryCombo.SelectedItem = catItem; // -> rebuilds class combo + list
- else
- RebuildClassCombo();
- }
-
- // Select the vehicle's class (type) in the class combo.
- string cls = ClassOf(browserTex);
- var classItem = classCombo.Items.OfType()
- .FirstOrDefault(it => it.Tag is string s &&
- string.Equals(s, cls, StringComparison.OrdinalIgnoreCase));
- if (classItem != null)
- {
- _suppress = true;
- classCombo.SelectedItem = classItem;
- _classFilter = cls;
- _suppress = false;
+ ApplyFilters(CategoryOf(browserTex), ClassOf(browserTex));
BuildList();
- }
+ SelectListEntry(browserTex);
+ });
+ }
- // highlight the matching entry (selection drives the mini + Add button)
+ // Mirrors a list pick in the combos, then restores the selection that the
+ // rebuilt list dropped.
+ private void SyncCombosTo(VehicleTexture texture)
+ {
+ if (_syncingCombos || !ApplyFilters(CategoryOf(texture), ClassOf(texture)))
+ return;
+
+ PostSyncing(() => { BuildList(); SelectListEntry(texture); });
+ }
+
+ // Runs work with the sync flag up, so the selections it makes do not come
+ // back as another sync.
+ private void WhileSyncing(Action work)
+ {
+ _syncingCombos = true;
+ try { work(); }
+ finally { _syncingCombos = false; }
+ }
+
+ // Same, on the next dispatcher pass: clearing Items from inside a
+ // SelectionChanged throws. False when a sync is already pending.
+ private bool PostSyncing(Action work)
+ {
+ if (_syncingCombos)
+ return false;
+
+ _syncingCombos = true;
+ Dispatcher.UIThread.Post(() => WhileSyncing(work), DispatcherPriority.Background);
+ return true;
+ }
+
+ private void SelectListEntry(VehicleTexture texture)
+ {
foreach (var obj in vehicleListBox.Items)
if (obj is ListBoxItem { Tag: VehicleTexture t } entry &&
- string.Equals(t.Skinfile, browserTex.Skinfile, StringComparison.OrdinalIgnoreCase) &&
- string.Equals(t.Directory, browserTex.Directory, StringComparison.OrdinalIgnoreCase))
+ string.Equals(t.Skinfile, texture.Skinfile, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(t.Directory, texture.Directory, StringComparison.OrdinalIgnoreCase))
{
vehicleListBox.SelectedItem = entry;
entry.BringIntoView();
@@ -1485,7 +1590,11 @@ public partial class Depot : UserControl
brakesPanel.Children.Clear();
loadsPanel.Children.Clear();
damagePanel.Children.Clear();
- removeVehicleButton.IsEnabled = _selected != null;
+
+ selectedVehiclePanel.Header = _selected is null
+ ? App.Loc["SelectedVehicle"]
+ : $"{App.Loc["SelectedVehicle"]}: " +
+ (_selected.Grouped ? UnitLabel(_selected) : _selected.Cars[0].Name);
// whole-consist load tools sit at the top of the Loads tab, always available
loadsPanel.Children.Add(BuildConsistLoadTools());
@@ -1542,11 +1651,6 @@ public partial class Depot : UserControl
private Control BuildConsistLoadTools()
{
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) };
- panel.Children.Add(new TextBlock
- {
- Text = App.Loc["ConsistLoadTools"], FontWeight = FontWeight.Bold, FontSize = 12
- });
-
var row = new WrapPanel { Orientation = Orientation.Horizontal };
row.Children.Add(LoadToolButton(App.Loc["ConsistRandomType"], RandomLoadTypeForConsist));
row.Children.Add(LoadToolButton(App.Loc["ConsistMaxAmount"], MaxAmountForConsist));
@@ -1681,12 +1785,6 @@ public partial class Depot : UserControl
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
};
- private static TextBlock UnitTitle(ConsistItem item) => new()
- {
- Text = item.Grouped ? UnitLabel(item) : item.Cars[0].Name,
- FontWeight = FontWeight.Bold, FontSize = 12
- };
-
// Row of "