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; namespace StarterNG.Views; public sealed class VehicleBrowser { private readonly ComboBox categoryCombo; private readonly ComboBox classCombo; private readonly ListBox vehicleListBox; private readonly TextBox searchBox; private readonly CheckBox hideArchivalCheck; private readonly Image miniPreview; private readonly Button addVehicleButton; private readonly VehicleCatalog _db; private readonly MiniTextures _minis; private readonly Action _openTextureBase; public void SyncHideArchival() { bool hideArchival = AppServices.Current.Settings.HideArchivalVehicles; if (hideArchivalCheck.IsChecked == hideArchival) return; _suppress = true; hideArchivalCheck.IsChecked = hideArchival; _suppress = false; Rebuild(); } public VehicleTexture? Selected { get; private set; } public Action? TextureSelected { get; set; } /// Adds the vehicle to the consist the given number of times. public Action? AddMany { get; set; } private bool _suppress; private bool _syncingCombos; private DispatcherTimer? _searchTimer; private Func? _categoryFilter; private string? _classFilter; public VehicleBrowser( ComboBox categoryCombo, ComboBox classCombo, ListBox vehicleListBox, TextBox searchBox, CheckBox hideArchivalCheck, Image miniPreview, Button addVehicleButton, VehicleCatalog db, MiniTextures minis, Action openTextureBase) { this.categoryCombo = categoryCombo; this.classCombo = classCombo; this.vehicleListBox = vehicleListBox; this.searchBox = searchBox; this.hideArchivalCheck = hideArchivalCheck; this.miniPreview = miniPreview; this.addVehicleButton = addVehicleButton; _db = db; _minis = minis; _openTextureBase = openTextureBase; vehicleListBox.ContextRequested += List_OnContextRequested; _searchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) }; _searchTimer.Tick += (_, _) => { _searchTimer!.Stop(); Rebuild(); }; } private const int MaxListRows = 2000; private const int MiniPreviewHeight = 54; // In the old starter's order: traction first, then the wagons, then the rest. private static readonly (string LocKey, Func Match)[] CategoryDefs = { ("CatElectricLoco", c => c == "e"), ("CatDieselLoco", c => c == "s"), ("CatSteamLoco", c => c == "p"), ("CatRailbus", c => c == "a"), ("CatEMU", c => c == "z"), ("CatWagonsA", c => c == "A"), ("CatWagonsB", c => c == "B"), ("CatWagonsC", c => c == "C"), ("CatWagonsD", c => c == "D"), ("CatWagonsE", c => c == "E"), ("CatWagonsF", c => c == "F"), ("CatWagonsG", c => c == "G"), ("CatWagonsH", c => c == "H"), ("CatWagonsI", c => c == "I"), ("CatWagonsJ", c => c == "J"), ("CatWagonsK", c => c == "K"), ("CatWagonsL", c => c == "L"), ("CatWagonsM", c => c == "M"), ("CatWagonsN", c => c == "N"), ("CatWagonsO", c => c == "O"), ("CatWagonsP", c => c == "P"), ("CatWagonsR", c => c == "R"), ("CatWagonsS", c => c == "S"), ("CatWagonsT", c => c == "T"), ("CatWagonsU", c => c == "U"), ("CatWagonsV", c => c == "V"), ("CatWagonsW", c => c == "W"), ("CatWagonsX", c => c == "X"), ("CatWagonsY", c => c == "Y"), ("CatWagonsZ", c => c == "Z"), ("CatWork", c => c == "r"), ("CatDraisine", c => c == "d"), ("CatTram", c => c == "t"), ("CatCar", c => c == "o"), ("CatBus", c => c == "b"), ("CatTruck", c => c == "c"), ("CatPeople", c => c == "h"), ("CatAnimals", c => c == "f"), ("CatPrototype", c => c == "x"), ("CatOther", IsOtherCat), }; private static readonly HashSet NamedLowerCats = new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "h", "n", "o", "p", "r", "s", "t", "x", "z" }; private static bool IsOtherCat(string? c) => string.IsNullOrEmpty(c) || (c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c)); /// /// Only the categories something is listed in - with the archival ones hidden, the /// ones holding nothing else go too - keeping the pick and its class where they stay. /// public void PopulateCategoryCombo() { var keep = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag; string? keepClass = _classFilter; var present = _db.Textures .Where(Listable) .Select(VehicleInfo.CategoryOf) .Distinct() .ToList(); _suppress = true; categoryCombo.Items.Clear(); foreach (var (locKey, match) in CategoryDefs) if (present.Any(match)) categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = match }); categoryCombo.SelectedItem = categoryCombo.Items.OfType() .FirstOrDefault(item => ReferenceEquals(item.Tag, keep)); _suppress = false; _categoryFilter = categoryCombo.SelectedItem is ComboBoxItem { Tag: Func f } ? f : null; RebuildClassCombo(keepClass); } /// /// Refills the classes. Within a category one is always picked - the one kept, or /// else the first - so the list shows its vehicles straight away, as the old starter /// did, instead of every vehicle of the category at once. /// private void RebuildClassCombo(string? keepClass = null) { _suppress = true; FillClassCombo(); var classes = classCombo.Items.OfType().ToList(); classCombo.SelectedItem = classes.FirstOrDefault(cls => string.Equals(cls, keepClass, StringComparison.OrdinalIgnoreCase)) ?? (_categoryFilter is not null ? classes.FirstOrDefault() : null); _suppress = false; _classFilter = classCombo.SelectedItem as string; Rebuild(); } /// A texture the browser lists at all: no set follower, no hidden archival one. private bool Listable(VehicleTexture texture) => !_db.IsSetFollower(texture) && !(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived); /// /// The classes with their thumbnail above the name: in the open list at the size /// the consist cards use, called again when that size changes; in the closed box /// at a fixed modest one, so the class picked is seen, as in the old starter, /// without taking the height the vehicle list needs. /// public void InitClassComboTemplates() { int height = VehicleCardStyle.ThumbHeight; classCombo.ItemTemplate = new FuncDataTemplate( (cls, _) => ClassComboContent(cls, height, stacked: true), false); classCombo.SelectionBoxItemTemplate = new FuncDataTemplate( (cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: true), false); } private const int PickedClassThumbHeight = 30; private void FillClassCombo() { classCombo.Items.Clear(); foreach (string cls in ClassesForCategory(_categoryFilter)) classCombo.Items.Add(cls); } 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 { 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; } private string? CategoryOfClass(string cls) => _db.Textures.FirstOrDefault(t => string.Equals(VehicleInfo.ClassOf(t), cls, StringComparison.OrdinalIgnoreCase)) is { } t ? VehicleInfo.CategoryOf(t) : null; private Control ClassComboContent(string cls, int height, bool stacked) { var cell = new StackPanel { Orientation = stacked ? Orientation.Vertical : Orientation.Horizontal, Spacing = stacked ? 2 : 8, HorizontalAlignment = stacked ? HorizontalAlignment.Center : HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Center }; var bmp = _minis.Get(cls, height); if (bmp != null) cell.Children.Add(MiniTextures.Sharp(new Image { Source = bmp, Height = height, Stretch = Stretch.Uniform, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center })); cell.Children.Add(new TextBlock { Text = cls, HorizontalAlignment = stacked ? HorizontalAlignment.Center : HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }); return cell; } private IEnumerable ClassesForCategory(Func? category) => _db.Textures .Where(Listable) .Where(t => category == null || category(VehicleInfo.CategoryOf(t))) .Select(VehicleInfo.ClassOf) .Where(s => !string.IsNullOrEmpty(s)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase); public void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_suppress) return; _categoryFilter = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag as Func; RebuildClassCombo(); } public void ClassCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_suppress) return; string? cls = classCombo.SelectedItem as string; _classFilter = cls; if (cls != null && _categoryFilter == null && PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); Rebuild(); })) return; Rebuild(); } public void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e) { if (sender is not ComboBox combo || combo.IsDropDownOpen) return; int count = combo.ItemCount; if (count == 0) return; int index = combo.SelectedIndex; if (e.Delta.Y > 0) index = index <= 0 ? 0 : index - 1; else if (e.Delta.Y < 0) index = index < 0 ? 0 : Math.Min(index + 1, count - 1); else return; combo.SelectedIndex = index; e.Handled = true; } public void Rebuild() { Selected = null; miniPreview.Source = null; addVehicleButton.IsEnabled = false; vehicleListBox.Items.Clear(); string search = searchBox.Text?.Trim() ?? ""; bool hasSearch = search.Length > 0; if (_categoryFilter == null && _classFilter == null && !hasSearch) { AddNote(App.Loc["BrowserHint"]); return; } var matched = _db.Textures .Where(t => PassesFilters(t, search, hasSearch)) .OrderBy(t => Consist.Base(t.Skinfile), TextureOrder) .Take(MaxListRows) .ToList(); foreach (var texture in matched) { var row = new ListBoxItem { Content = BrowserLabel(texture, _db.ResolveSet(texture)), Tag = texture }; vehicleListBox.Items.Add(row); } if (vehicleListBox.Items.Count == 0) AddNote(hasSearch ? string.Format(App.Loc["BrowserNoMatch"], search) : App.Loc["NoVehicles"]); } private void AddNote(string text) => vehicleListBox.Items.Add(new ListBoxItem { Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, Opacity = 0.7 }, IsEnabled = false }); public void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture }) { Selected = texture; miniPreview.Source = _minis.Get(_db.ResolveMiniName(texture), MiniPreviewHeight); addVehicleButton.IsEnabled = true; SyncCombosTo(texture); // Clicking a card in the consist also selects its texture here. The // detail panels then belong to that car, not to the catalogue entry. if (!_syncingCombos) TextureSelected?.Invoke(texture); } else { Selected = null; miniPreview.Source = null; addVehicleButton.IsEnabled = false; } } private void List_OnContextRequested(object? sender, ContextRequestedEventArgs e) { if ((e.Source as Visual).FindAncestorOfType(includeSelf: true) is not { Tag: VehicleTexture texture } hit) return; BuildBrowserMenu(texture, hit).Open(hit); e.Handled = true; } private ContextMenu BuildBrowserMenu(VehicleTexture texture, Control anchor) { 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); 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); menu.Items.Add(new Separator()); var baseItem = new MenuItem { Header = App.Loc["OpenTextureBase"] }; baseItem.Click += (_, _) => _openTextureBase(); menu.Items.Add(baseItem); return menu; } /// /// Fills a detail panel with what is known about a livery: the skin itself, /// then whatever its credit line carries. /// /// /// Built from the same as the general tab, so the /// labels line up in a column instead of each line being its own sentence. /// Fields the credit line leaves out are dropped rather than shown as /// blanks - most liveries fill in only a few. /// public void ShowTextureInfo(VehicleTexture texture, StackPanel target) { target.Children.Clear(); var tooltip = new List(); void Reading(string label, string? value) { if (string.IsNullOrWhiteSpace(value)) return; target.Children.Add(DetailRows.Reading(label, value)); tooltip.Add($"{label}: {value}"); } Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile)); Reading(App.Loc["Model"], texture.Directory + texture.Model); if (texture.Meta is { } meta) { Reading(App.Loc["TexOperator"], meta.Operator); Reading(App.Loc["TexStation"], meta.Depot); Reading(App.Loc["TexRevision"], meta.RevisionDate); Reading(App.Loc["TexWorks"], meta.RevisionPlace); Reading(App.Loc["TexAuthor"], meta.TextureAuthor); Reading(App.Loc["TexPhoto"], meta.PhotoAuthor); } ToolTip.SetTip(target, string.Join("\n", tooltip)); } public List CurrentTextures() { string search = searchBox.Text?.Trim() ?? ""; bool hasSearch = search.Length > 0; if (_categoryFilter == null && _classFilter == null && !hasSearch) return new List(); return _db.Textures .Where(t => PassesFilters(t, search, hasSearch)) .OrderBy(t => Consist.Base(t.Skinfile), TextureOrder) .ToList(); } private bool PassesFilters(VehicleTexture t, string search, bool hasSearch) { if (!Listable(t)) return false; // A search looks through everything; the category and the class narrow the // browsing, not the finding. if (hasSearch) return Matches(t, search); if (_categoryFilter != null && !_categoryFilter(VehicleInfo.CategoryOf(t))) return false; return _classFilter == null || string.Equals(VehicleInfo.ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase); } private bool Matches(VehicleTexture t, string f) { bool C(string? s) => !string.IsNullOrEmpty(s) && s.Contains(f, StringComparison.OrdinalIgnoreCase); return C(t.Skinfile) || C(t.Model) || C(t.TextureMini) || C(t.MiniRef) || C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(VehicleInfo.ClassOf(t)); } // The old starter listed textures by file name and left the ordering to // Delphi's Sorted, which is AnsiCompareText: case-insensitive and locale // aware. Ordinal comparison puts digits, underscores and accented letters // somewhere else entirely, which is why the list looked reshuffled. private static readonly StringComparer TextureOrder = StringComparer.CurrentCultureIgnoreCase; // The texture's own file name, as the old starter listed it and as the detail // panel names it. The thumbnail name went here before, which meant the list // 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. private static Control BrowserLabel(VehicleTexture texture, IReadOnlyList? set) { var name = new TextBlock { Text = BrowserName(texture) }; if (set is not { Count: > 1 }) return name; return new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } } }; } public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e) { if (_suppress || _searchTimer is null) return; _searchTimer.Stop(); _searchTimer.Start(); } public void HideArchivalCheck_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { if (_suppress) return; AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true; AppServices.Current.SettingsStore.Save(); PopulateCategoryCombo(); } public void SelectInBrowser(Dynamic car) { var texture = _db.TextureForSkin(car.SkinFile); if (texture is null) return; var set = _db.ResolveSet(texture); var browserTex = set is { Count: > 0 } ? set[0] : texture; WhileSyncing(() => { ApplyFilters(VehicleInfo.CategoryOf(browserTex), VehicleInfo.ClassOf(browserTex)); Rebuild(); SelectListEntry(browserTex); }); } private void SyncCombosTo(VehicleTexture texture) { if (_syncingCombos || !ApplyFilters(VehicleInfo.CategoryOf(texture), VehicleInfo.ClassOf(texture))) return; PostSyncing(() => { Rebuild(); SelectListEntry(texture); }); } private void WhileSyncing(Action work) { _syncingCombos = true; try { work(); } finally { _syncingCombos = false; } } 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, texture.Skinfile, StringComparison.OrdinalIgnoreCase) && string.Equals(t.Directory, texture.Directory, StringComparison.OrdinalIgnoreCase)) { vehicleListBox.SelectedItem = entry; entry.BringIntoView(); break; } } }