diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs
index 9df1cc6..fa255b6 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -26,6 +26,9 @@ public partial class App : Application
// fall back to the system culture only when the file has no lang entry.
Settings.Instance.Load();
+ // Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
+ KeyboardConfig.Instance.Load();
+
CultureInfo ci = CultureInfo.InstalledUICulture ;
var langName = Settings.Instance.LanguageWasSet
@@ -62,8 +65,14 @@ public partial class App : Application
// Code page 1250 is needed to parse scenery files (done during load).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
- // Persist settings when the launcher is closing.
- desktop.ShutdownRequested += (_, _) => Settings.Instance.CaptureAndSave();
+ // Persist settings when the launcher is closing. Key bindings are only
+ // rewritten when actually edited, so an untouched file keeps its layout.
+ desktop.ShutdownRequested += (_, _) =>
+ {
+ Settings.Instance.CaptureAndSave();
+ if (KeyboardConfig.Instance.Dirty)
+ KeyboardConfig.Instance.Save();
+ };
var splash = new SplashWindow();
desktop.MainWindow = splash;
diff --git a/StarterNG/Classes/KeyMap.cs b/StarterNG/Classes/KeyMap.cs
new file mode 100644
index 0000000..7fdba7b
--- /dev/null
+++ b/StarterNG/Classes/KeyMap.cs
@@ -0,0 +1,250 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Input;
+
+namespace StarterNG.Classes;
+
+///
+/// One cap on the on-screen keyboard. is the simulator key
+/// token a binding would reference; when null the cap is a non-bindable filler
+/// (Shift, Ctrl, Tab …) drawn only to keep the layout recognisable.
+///
+public sealed class KeyCap
+{
+ public string Label = string.Empty;
+ public string? Token; // null => filler / not bindable
+ public double Width = 1.0; // relative width (1 == a standard key)
+
+ public KeyCap(string label, string? token = null, double width = 1.0)
+ {
+ Label = label;
+ Token = token;
+ Width = width;
+ }
+}
+
+///
+/// Translates between physical keyboard input, simulator key tokens and the
+/// on-screen keyboard layout used by the controls visualisation.
+///
+public static class KeyMap
+{
+ ///
+ /// Resolves a pressed key to its simulator token, using the physical key to
+ /// disambiguate the numeric keypad. Returns null for keys with no token
+ /// (e.g. bare modifiers).
+ ///
+ public static string? FromInput(Key key, PhysicalKey physical)
+ {
+ // Numeric keypad first - distinguished only by the physical key.
+ switch (physical)
+ {
+ case PhysicalKey.NumPad0: return "num_0";
+ case PhysicalKey.NumPad1: return "num_1";
+ case PhysicalKey.NumPad2: return "num_2";
+ case PhysicalKey.NumPad3: return "num_3";
+ case PhysicalKey.NumPad4: return "num_4";
+ case PhysicalKey.NumPad5: return "num_5";
+ case PhysicalKey.NumPad6: return "num_6";
+ case PhysicalKey.NumPad7: return "num_7";
+ case PhysicalKey.NumPad8: return "num_8";
+ case PhysicalKey.NumPad9: return "num_9";
+ case PhysicalKey.NumPadAdd: return "num_+";
+ case PhysicalKey.NumPadSubtract: return "num_-";
+ case PhysicalKey.NumPadMultiply: return "num_*";
+ case PhysicalKey.NumPadDivide: return "num_/";
+ case PhysicalKey.NumPadDecimal: return "num_.";
+ case PhysicalKey.NumPadEnter: return "num_enter";
+ }
+
+ // Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown).
+ if (key >= Key.NumPad0 && key <= Key.NumPad9)
+ return "num_" + (char)('0' + (key - Key.NumPad0));
+
+ // Letters.
+ if (key >= Key.A && key <= Key.Z)
+ return ((char)('a' + (key - Key.A))).ToString();
+
+ // Top-row digits.
+ if (key >= Key.D0 && key <= Key.D9)
+ return ((char)('0' + (key - Key.D0))).ToString();
+
+ return key switch
+ {
+ Key.Add => "num_+",
+ Key.Subtract => "num_-",
+ Key.Multiply => "num_*",
+ Key.Divide => "num_/",
+ Key.Decimal => "num_.",
+ Key.Space => "space",
+ Key.Home => "home",
+ Key.End => "end",
+ Key.Insert => "insert",
+ Key.Delete => "delete",
+ Key.Back => "backspace",
+ Key.Pause => "pause",
+ Key.OemPlus => "=",
+ Key.OemMinus => "-",
+ Key.OemComma => ",",
+ Key.OemPeriod => ".",
+ Key.OemQuestion => "/",
+ Key.OemSemicolon => ";",
+ Key.OemQuotes => "'",
+ Key.OemBackslash or Key.OemPipe => "\\",
+ Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4",
+ Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8",
+ Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12",
+ _ => null
+ };
+ }
+
+ /// True for keys that only act as modifiers and never form a binding alone.
+ public static bool IsModifierKey(Key key) => key is
+ Key.LeftShift or Key.RightShift or
+ Key.LeftCtrl or Key.RightCtrl or
+ Key.LeftAlt or Key.RightAlt or
+ Key.LWin or Key.RWin;
+
+ /// Friendly, explicit name for a token, used in the binding list text.
+ public static string DisplayName(string? token)
+ {
+ if (string.IsNullOrEmpty(token) ||
+ token.Equals("none", StringComparison.OrdinalIgnoreCase))
+ return "—";
+
+ if (token.StartsWith("num_", StringComparison.OrdinalIgnoreCase))
+ {
+ string rest = token.Substring(4);
+ return rest switch
+ {
+ "enter" => "Num Enter",
+ "+" => "Num +",
+ "-" => "Num -",
+ "*" => "Num *",
+ "/" => "Num /",
+ "." => "Num .",
+ _ => "Num " + rest
+ };
+ }
+
+ return token switch
+ {
+ "space" => "Space",
+ "home" => "Home",
+ "end" => "End",
+ "insert" => "Insert",
+ "delete" => "Delete",
+ "pageup" => "Page Up",
+ "pagedown" => "Page Down",
+ "backspace" => "Backspace",
+ "pause" => "Pause",
+ "\\" => "\\",
+ _ when token.Length == 1 => token.ToUpperInvariant(),
+ _ => char.ToUpperInvariant(token[0]) + token.Substring(1)
+ };
+ }
+
+ // ── On-screen keyboard layout ──────────────────────────────────────────
+ // Filler caps (Token == null) keep the shape familiar; only caps with a
+ // token are coloured by assignment state.
+
+ /// Rows of the main (alphanumeric) keyboard block.
+ public static readonly KeyCap[][] MainBlock =
+ {
+ // F-row keys are reserved by the simulator, so they carry no token.
+ new[]
+ {
+ new KeyCap("Esc", null, 1.4),
+ new KeyCap("F1"), new KeyCap("F2"), new KeyCap("F3"), new KeyCap("F4"),
+ new KeyCap("F5"), new KeyCap("F6"), new KeyCap("F7"), new KeyCap("F8"),
+ new KeyCap("F9"), new KeyCap("F10"), new KeyCap("F11"), new KeyCap("F12"),
+ },
+ new[]
+ {
+ new KeyCap("`", null),
+ new KeyCap("1", "1"), new KeyCap("2", "2"), new KeyCap("3", "3"), new KeyCap("4", "4"),
+ new KeyCap("5", "5"), new KeyCap("6", "6"), new KeyCap("7", "7"), new KeyCap("8", "8"),
+ new KeyCap("9", "9"), new KeyCap("0", "0"),
+ new KeyCap("-", "-"), new KeyCap("=", "="),
+ new KeyCap("Backspace", "backspace", 2.0),
+ },
+ new[]
+ {
+ new KeyCap("Tab", null, 1.5),
+ new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"),
+ new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"),
+ new KeyCap("O", "o"), new KeyCap("P", "p"),
+ new KeyCap("[", null), new KeyCap("]", null),
+ new KeyCap("\\", "\\", 1.5),
+ },
+ new[]
+ {
+ new KeyCap("Caps", null, 1.8),
+ new KeyCap("A", "a"), new KeyCap("S", "s"), new KeyCap("D", "d"), new KeyCap("F", "f"),
+ new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"),
+ new KeyCap("L", "l"),
+ new KeyCap(";", ";"), new KeyCap("'", "'"),
+ new KeyCap("Enter", null, 2.2),
+ },
+ new[]
+ {
+ new KeyCap("Shift", null, 2.3),
+ new KeyCap("Z", "z"), new KeyCap("X", "x"), new KeyCap("C", "c"), new KeyCap("V", "v"),
+ new KeyCap("B", "b"), new KeyCap("N", "n"), new KeyCap("M", "m"),
+ new KeyCap(",", ","), new KeyCap(".", "."), new KeyCap("/", "/"),
+ new KeyCap("Shift", null, 2.7),
+ },
+ new[]
+ {
+ new KeyCap("Ctrl", null, 1.5),
+ new KeyCap("Win", null, 1.3),
+ new KeyCap("Alt", null, 1.3),
+ new KeyCap("Space", "space", 6.0),
+ new KeyCap("Alt", null, 1.3),
+ new KeyCap("Ctrl", null, 1.5),
+ },
+ };
+
+ /// Rows of the numeric-keypad block, drawn to the right of the main block.
+ public static readonly KeyCap[][] NumpadBlock =
+ {
+ new[]
+ {
+ new KeyCap("Num", null), new KeyCap("/", "num_/"),
+ new KeyCap("*", "num_*"), new KeyCap("-", "num_-"),
+ },
+ new[]
+ {
+ new KeyCap("7", "num_7"), new KeyCap("8", "num_8"),
+ new KeyCap("9", "num_9"), new KeyCap("+", "num_+"),
+ },
+ new[]
+ {
+ new KeyCap("4", "num_4"), new KeyCap("5", "num_5"),
+ new KeyCap("6", "num_6"), new KeyCap("", null),
+ },
+ new[]
+ {
+ new KeyCap("1", "num_1"), new KeyCap("2", "num_2"),
+ new KeyCap("3", "num_3"), new KeyCap("Ent", "num_enter"),
+ },
+ new[]
+ {
+ new KeyCap("0", "num_0", 2.0), new KeyCap(".", "num_."), new KeyCap("", null),
+ },
+ };
+
+ /// Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks.
+ public static readonly KeyCap[][] NavBlock =
+ {
+ // PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter.
+ new[]
+ {
+ new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),
+ },
+ new[]
+ {
+ new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn"),
+ },
+ };
+}
diff --git a/StarterNG/Classes/KeyboardConfig.cs b/StarterNG/Classes/KeyboardConfig.cs
new file mode 100644
index 0000000..f6a8c5e
--- /dev/null
+++ b/StarterNG/Classes/KeyboardConfig.cs
@@ -0,0 +1,259 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace StarterNG.Classes;
+
+///
+/// A single keyboard command binding parsed from eu07_input-keyboard.ini.
+/// A binding is a simulator command name, an optional pair of modifier flags
+/// (shift / ctrl, either or both) and the key it is mapped to.
+/// none (or an empty key) means the command is intentionally unbound.
+///
+public sealed class KeyBinding
+{
+ /// Simulator command token, e.g. mastercontrollerincrease.
+ public string Command = string.Empty;
+
+ /// True when the binding requires the Shift modifier.
+ public bool Shift;
+
+ /// True when the binding requires the Ctrl modifier.
+ public bool Ctrl;
+
+ /// The key token (e.g. q, num_+) or none when unbound.
+ public string Key = "none";
+
+ /// Human description taken from the line's trailing comment (no //).
+ public string Description = string.Empty;
+
+ /// Index into the backing line list, so the original line can be rewritten in place.
+ internal int LineIndex = -1;
+
+ /// True when the command is mapped to an actual key.
+ public bool IsAssigned =>
+ !string.IsNullOrEmpty(Key) &&
+ !Key.Equals("none", StringComparison.OrdinalIgnoreCase);
+}
+
+///
+/// Reader/writer for the simulator's keyboard layout file,
+/// eu07_input-keyboard.ini, which lives next to eu07.ini
+/// (%APPDATA%\MaSzyna\ on Windows, ~/.config/MaSzyna/ elsewhere).
+///
+/// The file is a flat list of command [shift] [ctrl] key // comment
+/// lines. Modifiers are the literal tokens shift and ctrl appearing
+/// in any order between the command and the key; the last value token is the key.
+/// Blank lines, pure comments and any lines the launcher does not recognise are
+/// preserved verbatim on save - only the binding lines the user actually edits
+/// are rewritten.
+///
+public sealed class KeyboardConfig
+{
+ public static KeyboardConfig Instance { get; } = new();
+
+ private readonly List _lines = new();
+
+ /// All recognised bindings, in file order.
+ public List Bindings { get; private set; } = new();
+
+ /// True when a binding has been changed since the last load/save.
+ public bool Dirty { get; set; }
+
+ private string _savePath = string.Empty;
+
+ private static readonly Encoding FileEncoding = ResolveEncoding();
+
+ private static Encoding ResolveEncoding()
+ {
+ // Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them.
+ try
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ return Encoding.GetEncoding(1250);
+ }
+ catch
+ {
+ return Encoding.UTF8;
+ }
+ }
+
+ /// Per-user keyboard config path for the current OS (next to eu07.ini).
+ public static string UserConfigPath()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ string? appData = Environment.GetEnvironmentVariable("APPDATA");
+ if (!string.IsNullOrEmpty(appData))
+ return Path.Combine(appData, "MaSzyna", "eu07_input-keyboard.ini");
+ }
+ else
+ {
+ string? home = Environment.GetEnvironmentVariable("HOME");
+ if (!string.IsNullOrEmpty(home))
+ return Path.Combine(home, ".config", "MaSzyna", "eu07_input-keyboard.ini");
+ }
+ return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
+ }
+
+ /// Default file shipped in the simulator's working directory.
+ private static string WorkingDirDefaultPath() =>
+ Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
+
+ /// Fallback template bundled with the launcher (startercfg/).
+ private static string BundledDefaultPath() =>
+ Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
+
+ /// Loads the per-user file, falling back to the working-dir or bundled default.
+ public void Load()
+ {
+ _savePath = UserConfigPath();
+ string source = FirstExisting(_savePath, WorkingDirDefaultPath(), BundledDefaultPath());
+ ParseSource(source);
+ Dirty = false;
+ }
+
+ /// Reloads the shipped defaults, discarding the user's current edits.
+ public void LoadDefaults()
+ {
+ if (string.IsNullOrEmpty(_savePath))
+ _savePath = UserConfigPath();
+ string source = FirstExisting(WorkingDirDefaultPath(), BundledDefaultPath());
+ ParseSource(source);
+ }
+
+ private void ParseSource(string source)
+ {
+ try
+ {
+ string text = File.Exists(source) ? File.ReadAllText(source, FileEncoding) : string.Empty;
+ Parse(text);
+ }
+ catch
+ {
+ _lines.Clear();
+ Bindings = new List();
+ }
+ }
+
+ private static string FirstExisting(params string[] paths)
+ {
+ foreach (var p in paths)
+ if (!string.IsNullOrEmpty(p) && File.Exists(p))
+ return p;
+ return paths.Length > 0 ? paths[^1] : string.Empty;
+ }
+
+ /// Parses raw file text into , keeping every line verbatim.
+ public void Parse(string text)
+ {
+ _lines.Clear();
+ var bindings = new List();
+
+ text = text.Replace("\r\n", "\n").Replace('\r', '\n');
+ foreach (var rawLine in text.Split('\n'))
+ {
+ int idx = _lines.Count;
+ _lines.Add(rawLine);
+
+ if (TryParseBinding(rawLine, idx, out var binding))
+ bindings.Add(binding);
+ }
+
+ Bindings = bindings;
+ }
+
+ ///
+ /// Parses a single line into a binding. Returns false for blank lines, pure
+ /// comments and anything without a command token.
+ ///
+ private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding)
+ {
+ binding = new KeyBinding { LineIndex = lineIndex };
+
+ string body = raw;
+ string description = string.Empty;
+
+ // Split off a trailing "// comment".
+ int commentAt = body.IndexOf("//", StringComparison.Ordinal);
+ if (commentAt >= 0)
+ {
+ description = body.Substring(commentAt + 2).Trim();
+ body = body.Substring(0, commentAt);
+ }
+
+ body = body.Trim();
+ if (body.Length == 0)
+ return false; // blank or pure-comment line
+
+ var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length == 0)
+ return false;
+
+ binding.Command = tokens[0];
+ binding.Description = description;
+
+ // Everything after the command is modifiers + (at most) one key.
+ bool shift = false, ctrl = false;
+ string key = "none";
+ for (int i = 1; i < tokens.Length; i++)
+ {
+ string t = tokens[i];
+ if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
+ else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true;
+ else key = t; // last non-modifier token wins as the key
+ }
+
+ binding.Shift = shift;
+ binding.Ctrl = ctrl;
+ binding.Key = key;
+ return true;
+ }
+
+ /// Rewrites each binding's line, then serialises the file to text.
+ public string ToText()
+ {
+ foreach (var b in Bindings)
+ {
+ if (b.LineIndex < 0 || b.LineIndex >= _lines.Count)
+ continue;
+ _lines[b.LineIndex] = FormatLine(b);
+ }
+ return string.Join(Environment.NewLine, _lines);
+ }
+
+ /// Formats one binding as command [shift] [ctrl] key // comment.
+ private static string FormatLine(KeyBinding b)
+ {
+ var sb = new StringBuilder();
+ sb.Append(b.Command);
+ sb.Append(' ');
+ if (b.Shift) sb.Append("shift ");
+ if (b.Ctrl) sb.Append("ctrl ");
+ sb.Append(b.IsAssigned ? b.Key : "none");
+ if (!string.IsNullOrEmpty(b.Description))
+ sb.Append(" // ").Append(b.Description);
+ return sb.ToString();
+ }
+
+ /// Writes the bindings back to the per-user keyboard file.
+ public void Save()
+ {
+ if (string.IsNullOrEmpty(_savePath))
+ _savePath = UserConfigPath();
+
+ try
+ {
+ string? dir = Path.GetDirectoryName(_savePath);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(_savePath, ToText(), FileEncoding);
+ Dirty = false;
+ }
+ catch
+ {
+ // Could not write (permissions / path). Leave values in memory.
+ }
+ }
+}
diff --git a/StarterNG/Classes/PresetStore.cs b/StarterNG/Classes/PresetStore.cs
new file mode 100644
index 0000000..629905c
--- /dev/null
+++ b/StarterNG/Classes/PresetStore.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace StarterNG.Classes;
+
+/// One saved consist in the user's vehicle warehouse.
+public sealed class TrainsetPreset
+{
+ /// Display name shown in the "load from warehouse" menu.
+ public string Name { get; set; } = "";
+
+ /// Complete "trainset … endtrainset" scenery entry (self-contained).
+ public string Entry { get; set; } = "";
+}
+
+/// Root object persisted to userpresets.json.
+public sealed class PresetCollection
+{
+ public List Presets { get; set; } = new();
+}
+
+///
+/// The user's vehicle/consist "warehouse": named trainset presets persisted as JSON.
+/// Stored under the per-user config directory so it survives reinstalls:
+/// Windows: %appdata%\MaSzyna\starter\userpresets.json
+/// Unix: ~/.config/MaSzyna/starter/userpresets.json
+/// (both resolved via ).
+/// All operations are best-effort and never throw.
+///
+public static class PresetStore
+{
+ public static string FilePath { get; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "MaSzyna", "starter", "userpresets.json");
+
+ // Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the
+ // approach used for the vehicle database.
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ TypeInfoResolver = PresetJsonContext.Default,
+ };
+
+ private static JsonTypeInfo TypeInfo =>
+ (JsonTypeInfo)Options.GetTypeInfo(typeof(PresetCollection));
+
+ /// All saved presets, sorted by name. Empty when none / unreadable.
+ public static IReadOnlyList All() => Load().Presets;
+
+ ///
+ /// Saves (or overwrites, by name) a consist preset. No-op on a blank name or
+ /// empty entry.
+ ///
+ public static void Save(string? name, string? entry)
+ {
+ name = name?.Trim() ?? "";
+ if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
+ return;
+
+ var col = Load();
+ col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+ col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
+ col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+ Persist(col);
+ }
+
+ /// Removes a preset by name (case-insensitive).
+ public static void Delete(string name)
+ {
+ var col = Load();
+ if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
+ Persist(col);
+ }
+
+ private static PresetCollection Load()
+ {
+ try
+ {
+ if (File.Exists(FilePath))
+ {
+ var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo);
+ if (col?.Presets != null)
+ return col;
+ }
+ }
+ catch
+ {
+ // unreadable / malformed - start from an empty warehouse
+ }
+ return new PresetCollection();
+ }
+
+ private static void Persist(PresetCollection col)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
+ File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
+ }
+ catch
+ {
+ // best-effort: a failed save just means the warehouse isn't updated
+ }
+ }
+}
+
+///
+/// Helpers to convert a consist to/from its canonical scenery text - the full
+/// "trainset … endtrainset" block, exactly what is placed on the clipboard.
+///
+public static class ConsistText
+{
+ /// The complete scenery entry for a trainset (incl. endtrainset).
+ public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
+
+ ///
+ /// Parses the vehicles out of a complete trainset entry. Returns null when the
+ /// text holds no usable node::dynamic vehicles.
+ ///
+ public static List? VehiclesFrom(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return null;
+ try
+ {
+ var trainset = new Trainset(text);
+ return trainset.Vehicles is { Count: > 0 } ? trainset.Vehicles : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
+
+// Source-generated JSON metadata (AOT/trim-safe; no runtime reflection).
+[JsonSerializable(typeof(PresetCollection))]
+[JsonSerializable(typeof(TrainsetPreset))]
+internal partial class PresetJsonContext : JsonSerializerContext
+{
+}
diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs
index 1c949d8..e4502c7 100644
--- a/StarterNG/Classes/Scenery.cs
+++ b/StarterNG/Classes/Scenery.cs
@@ -19,6 +19,9 @@ public class Scenery
public string Description; // //$d - scenery description
public string ImageName; // //$i - main-window image (scenery thumbnail)
+ /// True when the scenery declares the //$a "archival" flag.
+ public bool Archival;
+
// Weather / environment. Like the original Starter, these are editable and are
// written into the scenery's "config" block on launch (see RewriteWeather).
// Defaults mirror the original (15 °C, day 0, 10:30, clear sky).
@@ -59,6 +62,8 @@ public class Scenery
// //$d may appear on several lines; each is one line of the description.
this.Description = MatchAllDirectives(content, "d");
this.ImageName = MatchDirective(content, "i");
+ // //$a marks an archival scenery (present = archival, regardless of value).
+ this.Archival = MatchDirective(content, "a") != null;
// weather/environment is read from the raw text before trainset blocks
// are stripped out below (the atmosphere commands live outside trainsets)
diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs
index 4e4ccaa..4f57612 100644
--- a/StarterNG/Classes/Settings.cs
+++ b/StarterNG/Classes/Settings.cs
@@ -112,6 +112,11 @@ public sealed class Settings
public bool LargeThumbnails; // starter.largethumbnails
public bool AutoExpandSceneryTree; // starter.expandtree
+ // List filters (launcher-only). Default on.
+ public bool ShowAiVehicles = true; // starter.show.ai (show //$o "-" consists)
+ public bool DrivableOnly = true; // starter.drivableonly (hide //$decor consists)
+ public bool ShowArchivalSceneries = true; // starter.show.archival
+
/// gfxrenderer tokens in the order shown by the render-engine combo.
public static readonly string[] RenderEngines =
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
@@ -357,6 +362,9 @@ public sealed class Settings
AutoCloseStarter = c.GetBool("starter.autoclose", false);
LargeThumbnails = c.GetBool("starter.largethumbnails", false);
AutoExpandSceneryTree = c.GetBool("starter.expandtree", false);
+ ShowAiVehicles = c.GetBool("starter.show.ai", true);
+ DrivableOnly = c.GetBool("starter.drivableonly", true);
+ ShowArchivalSceneries = c.GetBool("starter.show.archival", true);
}
// ── fields → config ───────────────────────────────────────────────────
@@ -444,6 +452,9 @@ public sealed class Settings
c.SetBool("starter.autoclose", AutoCloseStarter);
c.SetBool("starter.largethumbnails", LargeThumbnails);
c.SetBool("starter.expandtree", AutoExpandSceneryTree);
+ c.SetBool("starter.show.ai", ShowAiVehicles);
+ c.SetBool("starter.drivableonly", DrivableOnly);
+ c.SetBool("starter.show.archival", ShowArchivalSceneries);
}
// ── small helpers ──────────────────────────────────────────────────────
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index 73f19cb..c1554d5 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -30,9 +30,13 @@ public class Trainset
/// True if the block parsed cleanly; false blocks are exported verbatim.
public bool Parsed;
+ /// True when the block carries the //$decor flag (decoration, not drivable).
+ public bool Decor;
+
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
+ Decor = Regex.IsMatch(trainsetEntry, @"//\$decor\b", RegexOptions.IgnoreCase);
Vehicles = new List();
Description = "";
try
diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj
index 19c128e..2ef049e 100644
--- a/StarterNG/StarterNG.csproj
+++ b/StarterNG/StarterNG.csproj
@@ -9,8 +9,8 @@
true
app.manifest
true
- 1.1.0.3
- 1.1.0.3
+ 1.2.0.4
+ 1.2.0.4
Starter
eu07.pl
@@ -59,5 +59,11 @@
PreserveNewest
startercfg\lang\%(Filename)%(Extension)
+
+
+ PreserveNewest
+ startercfg\eu07_input-keyboard.ini
+
diff --git a/StarterNG/Views/ConsistContextMenu.cs b/StarterNG/Views/ConsistContextMenu.cs
new file mode 100644
index 0000000..db01485
--- /dev/null
+++ b/StarterNG/Views/ConsistContextMenu.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Layout;
+using Avalonia.Media;
+using StarterNG.Classes;
+
+namespace StarterNG.Views;
+
+///
+/// Right-click menu shared by the consist lists (the "Vehicles" list in Scenarios
+/// and the "Map consists" list in the depot). Offers: save to warehouse, load from
+/// warehouse (each entry deletable via an "✕"), copy/paste via the clipboard, and -
+/// when removeAll is supplied - clearing the consist (Shift+Delete).
+///
+public static class ConsistContextMenu
+{
+ /// Control the menu/flyouts anchor to (for clipboard + popups).
+ /// Supplies the trainset to act on when the menu opens.
+ /// Replaces the target's vehicles and refreshes the view.
+ /// When non-null, adds a "remove all vehicles" entry.
+ public static ContextMenu Build(
+ Control owner,
+ Func getTarget,
+ Action> applyVehicles,
+ Action? removeAll = null)
+ {
+ var menu = new ContextMenu();
+ // Rebuild the entries each time it opens so the warehouse list stays fresh.
+ menu.Opening += (_, _) => Populate(menu, owner, getTarget, applyVehicles, removeAll);
+ return menu;
+ }
+
+ private static void Populate(ContextMenu menu, Control owner,
+ Func getTarget, Action> applyVehicles, Action? removeAll)
+ {
+ menu.Items.Clear();
+ var target = getTarget();
+ bool hasVehicles = target is { Vehicles.Count: > 0 };
+
+ // ── Save current consist to the warehouse ──────────────────────────────
+ var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles };
+ save.Click += (_, _) => ShowSaveFlyout(owner, target);
+ menu.Items.Add(save);
+
+ // ── Load from the warehouse (submenu; each entry deletable) ────────────
+ var load = new MenuItem { Header = App.Loc["PresetLoad"] };
+ var presets = PresetStore.All();
+ if (presets.Count == 0)
+ {
+ load.Items.Add(new MenuItem { Header = App.Loc["PresetEmpty"], IsEnabled = false });
+ }
+ else
+ {
+ foreach (var preset in presets)
+ load.Items.Add(BuildPresetItem(menu, preset, applyVehicles));
+ }
+ menu.Items.Add(load);
+
+ menu.Items.Add(new Separator());
+
+ // ── Clipboard (full trainset text) ─────────────────────────────────────
+ var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles };
+ copy.Click += async (_, _) =>
+ {
+ if (target != null && TopLevel.GetTopLevel(owner)?.Clipboard is { } cb)
+ await cb.SetTextAsync(ConsistText.Serialize(target));
+ };
+ menu.Items.Add(copy);
+
+ var paste = new MenuItem { Header = App.Loc["ConsistPaste"] };
+ paste.Click += 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);
+
+ // ── Remove all vehicles (depot only) ───────────────────────────────────
+ 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);
+ }
+ }
+
+ // A warehouse entry: the name loads the preset on click, the trailing "✕"
+ // deletes it from the warehouse.
+ private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset,
+ Action> applyVehicles)
+ {
+ 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; // don't let the click also load the preset
+ PresetStore.Delete(preset.Name);
+ menu.Close(); // reopen to see the updated warehouse
+ };
+
+ var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center };
+
+ var dock = new DockPanel { MinWidth = 180 };
+ DockPanel.SetDock(del, Dock.Right);
+ dock.Children.Add(del);
+ dock.Children.Add(label);
+
+ var item = new MenuItem { Header = dock };
+ item.Click += (_, _) =>
+ {
+ var vehicles = ConsistText.VehiclesFrom(preset.Entry);
+ if (vehicles != null)
+ applyVehicles(vehicles);
+ };
+ return item;
+ }
+
+ // Small popup to name the preset before saving it to the warehouse.
+ private static void ShowSaveFlyout(Control owner, Trainset? target)
+ {
+ if (target is null || target.Vehicles.Count == 0)
+ return;
+
+ var nameBox = new TextBox
+ {
+ Watermark = App.Loc["PresetNamePrompt"],
+ Text = DefaultName(target),
+ MinWidth = 220
+ };
+
+ var ok = new Button { Content = App.Loc["PresetSave"], HorizontalAlignment = HorizontalAlignment.Right };
+ ok.Classes.Add("Flat");
+ ok.Classes.Add("Accent");
+
+ var panel = new StackPanel { Spacing = 6, Margin = new Thickness(8), MinWidth = 240 };
+ panel.Children.Add(new TextBlock
+ {
+ Text = App.Loc["PresetNamePrompt"], FontWeight = FontWeight.Bold, FontSize = 12
+ });
+ panel.Children.Add(nameBox);
+ panel.Children.Add(ok);
+
+ var flyout = new Flyout { Content = panel };
+
+ void Commit()
+ {
+ PresetStore.Save(nameBox.Text, ConsistText.Serialize(target));
+ flyout.Hide();
+ }
+
+ ok.Click += (_, _) => Commit();
+ nameBox.KeyDown += (_, e) =>
+ {
+ if (e.Key == Key.Enter)
+ {
+ e.Handled = true;
+ Commit();
+ }
+ };
+
+ flyout.ShowAt(owner, showAtPointer: true);
+ nameBox.Focus();
+ nameBox.SelectAll();
+ }
+
+ private static string DefaultName(Trainset trainset)
+ {
+ string joined = string.Join(" + ", trainset.Vehicles.Select(v => v.Name));
+ return joined.Length > 40 ? joined[..40] : joined;
+ }
+}
diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml
index 5ebd669..04d6870 100644
--- a/StarterNG/Views/Depot.axaml
+++ b/StarterNG/Views/Depot.axaml
@@ -69,7 +69,8 @@
+ SelectionChanged="SceneryConsistList_OnSelectionChanged"
+ KeyDown="SceneryConsistList_OnKeyDown" />
diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs
index 6390767..9bfbf0e 100644
--- a/StarterNG/Views/Depot.axaml.cs
+++ b/StarterNG/Views/Depot.axaml.cs
@@ -35,6 +35,9 @@ public partial class Depot : UserControl
private readonly Cursor _hand = new(StandardCursorType.Hand);
private int _nameCounter;
+ // shared RNG for the "random load / random amount" consist tools
+ private static readonly Random _rng = new();
+
// suppresses combo SelectionChanged handlers while we populate them
private bool _suppress;
@@ -557,9 +560,16 @@ public partial class Depot : UserControl
var entry = new ListBoxItem
{
- Content = ConsistText(trainset),
+ Content = ConsistLabel(trainset),
Tag = trainset
};
+ // right-click: warehouse save/load, clipboard copy/paste, clear-all
+ var ts = trainset;
+ entry.ContextMenu = ConsistContextMenu.Build(
+ entry,
+ () => ts,
+ vehicles => ApplyConsist(ts, vehicles),
+ () => RemoveAllVehicles(ts));
sceneryConsistList.Items.Add(entry);
if (ReferenceEquals(trainset, AppState.Instance.CurrentTrainset))
@@ -581,8 +591,43 @@ public partial class Depot : UserControl
LoadTrainset(trainset);
}
+ // Shift+Delete clears the selected scenery consist (same as the context-menu
+ // "remove all vehicles" entry).
+ private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
+ sceneryConsistList.SelectedItem is ListBoxItem { Tag: Trainset trainset } &&
+ trainset.Vehicles.Count > 0)
+ {
+ RemoveAllVehicles(trainset);
+ e.Handled = true;
+ }
+ }
+
+ // Replaces a scenery consist's vehicles (from a warehouse preset or a pasted
+ // clipboard consist), keeping the consist's map slot (name/track/offset), then
+ // loads it into the editor and refreshes the list.
+ private void ApplyConsist(Trainset target, List vehicles)
+ {
+ target.Vehicles = vehicles;
+ AppState.Instance.CurrentScenery = _listScenery;
+ AppState.Instance.CurrentTrainset = target;
+ LoadTrainset(target);
+ PopulateSceneryConsists();
+ }
+
+ // Clears every vehicle from a scenery consist.
+ private void RemoveAllVehicles(Trainset target)
+ {
+ target.Vehicles = new List();
+ AppState.Instance.CurrentScenery = _listScenery;
+ AppState.Instance.CurrentTrainset = target;
+ LoadTrainset(target);
+ PopulateSceneryConsists();
+ }
+
// Plain-text label for a scenery consist (no thumbnails).
- private static string ConsistText(Trainset trainset)
+ private static string ConsistLabel(Trainset trainset)
{
string text = string.Join(" + ", trainset.Vehicles.Select(v => v.SkinFile));
if (string.IsNullOrWhiteSpace(text))
@@ -928,12 +973,9 @@ public partial class Depot : UserControl
// highlight this vehicle's entry there
SelectInBrowser(item.Cars[0]);
};
- // right-click a vehicle to set its wagon number (node "#" suffix)
- card.ContextRequested += (_, args) =>
- {
- ShowWagonNumberFlyout(card, item.Cars[0]);
- args.Handled = true;
- };
+ // right-click a vehicle: wagon number + load actions (copy load from the
+ // previous vehicle, fill to the maximum possible load)
+ card.ContextMenu = BuildCardMenu(card, item);
return card;
}
@@ -1128,6 +1170,9 @@ public partial class Depot : UserControl
loadsPanel.Children.Clear();
damagePanel.Children.Clear();
+ // whole-consist load tools sit at the top of the Loads tab, always available
+ loadsPanel.Children.Add(BuildConsistLoadTools());
+
if (_selected is null)
{
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
@@ -1141,6 +1186,179 @@ public partial class Depot : UserControl
BuildDamageEditor(_selected);
}
+ // ---------------------------------------------------------------- load tools
+
+ // The per-vehicle right-click menu: wagon number plus the two load shortcuts.
+ private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
+ {
+ var menu = new ContextMenu();
+ menu.Opening += (_, _) =>
+ {
+ menu.Items.Clear();
+
+ var num = new MenuItem { Header = App.Loc["WagonNumber"] };
+ num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
+ menu.Items.Add(num);
+
+ menu.Items.Add(new Separator());
+
+ var copyPrev = new MenuItem
+ {
+ Header = App.Loc["LoadCopyPrev"],
+ IsEnabled = _consist.IndexOf(item) > 0
+ };
+ copyPrev.Click += (_, _) => CopyLoadFromPrevious(item);
+ menu.Items.Add(copyPrev);
+
+ var max = new MenuItem
+ {
+ Header = App.Loc["LoadMax"],
+ IsEnabled = IsLoadable(item)
+ };
+ max.Click += (_, _) => SetUnitMaxLoad(item);
+ menu.Items.Add(max);
+ };
+ return menu;
+ }
+
+ // Button bar that applies a load operation to every vehicle in the consist.
+ 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));
+ row.Children.Add(LoadToolButton(App.Loc["ConsistRandomAmount"], RandomAmountForConsist));
+ panel.Children.Add(row);
+ return panel;
+ }
+
+ private Button LoadToolButton(string text, Action onClick)
+ {
+ var b = new Button
+ {
+ Content = text, FontSize = 11, Margin = new Thickness(0, 0, 4, 4),
+ Padding = new Thickness(8, 3), Cursor = _hand,
+ IsEnabled = _consist.Count > 0
+ };
+ b.Classes.Add("Flat");
+ b.Click += (_, _) => onClick();
+ return b;
+ }
+
+ // True when the unit's lead car can actually carry cargo.
+ private bool IsLoadable(ConsistItem item)
+ {
+ var p = PhysicsFor(item.Cars[0]);
+ return p != null && (p.MaxLoad > 0 || !string.IsNullOrWhiteSpace(p.LoadAccepted));
+ }
+
+ // The largest cargo this car accepts (.fiz MaxLoad), 1000 as a fallback for a
+ // car that lists accepted cargo but no explicit maximum, 0 when it can't load.
+ private int MaxLoadFor(Dynamic car)
+ {
+ var p = PhysicsFor(car);
+ if (p == null) return 0;
+ if (p.MaxLoad > 0) return p.MaxLoad;
+ return string.IsNullOrWhiteSpace(p.LoadAccepted) ? 0 : 1000;
+ }
+
+ // Cargo types this car accepts (.fiz LoadAccepted), else the global list.
+ private List AcceptedTypesFor(Dynamic car)
+ {
+ var p = PhysicsFor(car);
+ if (p != null && !string.IsNullOrWhiteSpace(p.LoadAccepted))
+ return p.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
+ return LoadTypes().ToList();
+ }
+
+ // Copies the previous unit's load (type + amount) onto this unit, clamped to
+ // each car's own maximum.
+ private void CopyLoadFromPrevious(ConsistItem item)
+ {
+ int idx = _consist.IndexOf(item);
+ if (idx <= 0) return;
+
+ var source = _consist[idx - 1].Cars[0];
+ foreach (var c in item.Cars)
+ {
+ int max = MaxLoadFor(c);
+ c.LoadType = max > 0 ? source.LoadType : null;
+ c.LoadCount = max > 0 ? Math.Min(source.LoadCount, max) : 0;
+ if (c.LoadCount > 0) c.HasVelocity = true;
+ }
+ RebuildConsist();
+ }
+
+ // Fills one unit's cars to their maximum load (keeping the cargo type, or
+ // assigning the first accepted one when none is set yet).
+ private void SetUnitMaxLoad(ConsistItem item)
+ {
+ foreach (var c in item.Cars)
+ FillToMax(c);
+ RebuildConsist();
+ }
+
+ // Random cargo type for every loadable car in the consist, filled to the max.
+ private void RandomLoadTypeForConsist()
+ {
+ foreach (var c in _consist.SelectMany(u => u.Cars))
+ {
+ int max = MaxLoadFor(c);
+ if (max <= 0) continue;
+ var types = AcceptedTypesFor(c);
+ if (types.Count == 0) continue;
+ c.LoadType = types[_rng.Next(types.Count)];
+ c.LoadCount = max;
+ c.HasVelocity = true;
+ }
+ RebuildConsist();
+ }
+
+ // Every loadable car in the consist filled to its maximum.
+ private void MaxAmountForConsist()
+ {
+ foreach (var c in _consist.SelectMany(u => u.Cars))
+ FillToMax(c);
+ RebuildConsist();
+ }
+
+ // Random amount (per car) for every loadable car in the consist, keeping the
+ // cargo type (assigning the first accepted one when none is set). Lets you roll
+ // a different tonnage for each wagon with one click.
+ private void RandomAmountForConsist()
+ {
+ foreach (var c in _consist.SelectMany(u => u.Cars))
+ {
+ int max = MaxLoadFor(c);
+ if (max <= 0) continue;
+ if (string.IsNullOrEmpty(c.LoadType))
+ c.LoadType = AcceptedTypesFor(c).FirstOrDefault();
+ if (string.IsNullOrEmpty(c.LoadType)) continue;
+ int min = Math.Max(1, max / 10);
+ c.LoadCount = _rng.Next(min, max + 1);
+ c.HasVelocity = true;
+ }
+ RebuildConsist();
+ }
+
+ // Fills a single car to its maximum load (no-op for non-loadable cars).
+ private void FillToMax(Dynamic car)
+ {
+ int max = MaxLoadFor(car);
+ if (max <= 0) return;
+ if (string.IsNullOrEmpty(car.LoadType))
+ car.LoadType = AcceptedTypesFor(car).FirstOrDefault();
+ if (string.IsNullOrEmpty(car.LoadType)) return;
+ car.LoadCount = max;
+ car.HasVelocity = true;
+ }
+
private static TextBlock DetailHint(string text) => new()
{
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
diff --git a/StarterNG/Views/Scenarios.axaml b/StarterNG/Views/Scenarios.axaml
index f1d7e37..615c94e 100644
--- a/StarterNG/Views/Scenarios.axaml
+++ b/StarterNG/Views/Scenarios.axaml
@@ -21,8 +21,15 @@
-
+
+
+
+
@@ -34,8 +41,19 @@
-
+
+
+
+
+
+
+
diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs
index e47c0e3..1d18627 100644
--- a/StarterNG/Views/Scenarios.axaml.cs
+++ b/StarterNG/Views/Scenarios.axaml.cs
@@ -27,6 +27,9 @@ public partial class Scenarios : UserControl
// stored Day stays 0 so the scenery keeps using the live date.
private bool _todaySeason;
+ // suppresses the list-filter handlers while their state is restored at startup
+ private bool _loadingFilters;
+
public Scenarios()
{
InitializeComponent();
@@ -34,61 +37,15 @@ public partial class Scenarios : UserControl
// Sceneries are parsed once at startup (behind the splash).
Sceneries = GameData.Instance.Sceneries;
- var groupNodes = new Dictionary();
- var topLevel = new List();
+ // Restore the persisted list filters before building the tree/list. Guarded
+ // so assigning IsChecked here doesn't run the rebuild handlers prematurely.
+ _loadingFilters = true;
+ showAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
+ drivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
+ archivalSwitch.IsChecked = StarterNG.Classes.Settings.Instance.ShowArchivalSceneries;
+ _loadingFilters = false;
- for (int i = 0; i < Sceneries.Count; i++)
- {
- var scenery = Sceneries[i];
-
- // case 1: no group - put without parent
- if (string.IsNullOrEmpty(scenery.Group))
- {
- topLevel.Add(new TreeViewItem
- {
- Header = Path.GetFileNameWithoutExtension(scenery.Path),
- Tag = i
- });
-
- continue;
- }
-
- // case 2 - group scenery with others
- if (!groupNodes.TryGetValue(scenery.Group, out var groupNode))
- {
- groupNode = new TreeViewItem
- {
- Header = scenery.Group,
- IsExpanded = false
- };
-
- groupNodes[scenery.Group] = groupNode;
- topLevel.Add(groupNode);
- }
-
- groupNode.Items.Add(new TreeViewItem
- {
- Header = Path.GetFileNameWithoutExtension(scenery.Path),
- Tag = i
- });
- }
-
- // Sort the scenery tree alphabetically: group folders' inner sceneries
- // first, then the top-level (group folders + ungrouped sceneries). The Tag
- // keeps each node's original scenery index, so sorting only reorders the
- // display - the vehicles/trainsets list stays in scenery order.
- foreach (var node in groupNodes.Values)
- {
- var children = node.Items.Cast()
- .OrderBy(c => c.Header as string, StringComparer.OrdinalIgnoreCase)
- .ToList();
- node.Items.Clear();
- foreach (var child in children)
- node.Items.Add(child);
- }
-
- foreach (var item in topLevel.OrderBy(t => t.Header as string, StringComparer.OrdinalIgnoreCase))
- sceneryList.Items.Add(item);
+ BuildSceneryTree();
// refresh the consist preview when the view is shown again (e.g. after
// editing the consist in the depot). Switching tabs only toggles IsVisible
@@ -110,6 +67,139 @@ public partial class Scenarios : UserControl
RefreshSelectedConsist();
}
+ // (Re)builds the scenery tree, honouring the "show archival" switch. Archival
+ // sceneries (those declaring //$a) are skipped when the switch is off; group
+ // folders that end up empty are omitted. The Tag keeps each node's original
+ // scenery index, so the vehicles list stays aligned. Top-level nodes and group
+ // children are sorted alphabetically.
+ private void BuildSceneryTree()
+ {
+ sceneryList.Items.Clear();
+
+ bool showArchival = archivalSwitch.IsChecked ?? true;
+ var groupNodes = new Dictionary();
+ var topLevel = new List();
+
+ for (int i = 0; i < Sceneries.Count; i++)
+ {
+ var scenery = Sceneries[i];
+ if (scenery.Archival && !showArchival)
+ continue;
+
+ if (string.IsNullOrEmpty(scenery.Group))
+ {
+ topLevel.Add(new TreeViewItem
+ {
+ Header = Path.GetFileNameWithoutExtension(scenery.Path),
+ Tag = i
+ });
+ continue;
+ }
+
+ if (!groupNodes.TryGetValue(scenery.Group, out var groupNode))
+ {
+ groupNode = new TreeViewItem { Header = scenery.Group, IsExpanded = false };
+ groupNodes[scenery.Group] = groupNode;
+ topLevel.Add(groupNode);
+ }
+
+ groupNode.Items.Add(new TreeViewItem
+ {
+ Header = Path.GetFileNameWithoutExtension(scenery.Path),
+ Tag = i
+ });
+ }
+
+ foreach (var node in groupNodes.Values)
+ {
+ var children = node.Items.Cast()
+ .OrderBy(c => c.Header as string, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ node.Items.Clear();
+ foreach (var child in children)
+ node.Items.Add(child);
+ }
+
+ foreach (var item in topLevel.OrderBy(t => t.Header as string, StringComparer.OrdinalIgnoreCase))
+ sceneryList.Items.Add(item);
+ }
+
+ // Fills the Vehicles list for a scenery, honouring the two filters:
+ // - "AI vehicles" off hides consists whose //$o description starts with '-';
+ // - "Drivable only" on hides decoration consists (the //$decor flag).
+ private void PopulateVehicleList(Scenery scn)
+ {
+ vehicleList.Items.Clear();
+
+ bool showAi = showAiCheck.IsChecked ?? true;
+ bool drivableOnly = drivableOnlyCheck.IsChecked ?? true;
+
+ for (int i = 0; i < scn.Trainsets.Count; i++)
+ {
+ var trainset = scn.Trainsets[i];
+ string trainsetName = string.Join(" + ", trainset.Vehicles.Select(dyn => dyn.Name));
+ if (string.IsNullOrWhiteSpace(trainsetName)) continue; // skip empty
+
+ if (IsAiTrainset(trainset) && !showAi) continue;
+ if (trainset.Decor && drivableOnly) continue;
+
+ var listItem = new ListBoxItem
+ {
+ Content = trainsetName,
+ Tag = i
+ };
+ // right-click: save/load to the warehouse + clipboard copy/paste
+ listItem.ContextMenu = ConsistContextMenu.Build(
+ listItem,
+ () => trainset,
+ vehicles => ApplyConsist(listItem, trainset, vehicles));
+ vehicleList.Items.Add(listItem);
+ }
+
+ if (vehicleList.Items.Count > 0)
+ vehicleList.SelectedIndex = 0;
+ }
+
+ // A computer-driven consist marks its //$o description with a leading '-'.
+ private static bool IsAiTrainset(Trainset trainset) =>
+ !string.IsNullOrEmpty(trainset.Description) &&
+ trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
+
+ // AI/player vehicle filter toggled: persist the choice and rebuild the list
+ // for the currently selected scenery.
+ private void VehicleFilter_OnChanged(object? sender, RoutedEventArgs e)
+ {
+ if (_loadingFilters) return;
+
+ StarterNG.Classes.Settings.Instance.ShowAiVehicles = showAiCheck.IsChecked ?? true;
+ StarterNG.Classes.Settings.Instance.DrivableOnly = drivableOnlyCheck.IsChecked ?? true;
+ StarterNG.Classes.Settings.Instance.Save();
+
+ if (sceneryList.SelectedItem is TreeViewItem { Tag: int tag } && tag < Sceneries.Count)
+ PopulateVehicleList(Sceneries[tag]);
+ }
+
+ // Archival-scenery switch toggled: persist the choice and rebuild the tree.
+ private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
+ {
+ if (_loadingFilters) return;
+
+ StarterNG.Classes.Settings.Instance.ShowArchivalSceneries = archivalSwitch.IsChecked ?? true;
+ StarterNG.Classes.Settings.Instance.Save();
+
+ BuildSceneryTree();
+ }
+
+ // Replaces a consist's vehicles (from a loaded warehouse preset or a pasted
+ // clipboard consist) and refreshes the row caption / consist preview.
+ private void ApplyConsist(ListBoxItem item, Trainset trainset, List vehicles)
+ {
+ trainset.Vehicles = vehicles;
+ item.Content = string.Join(" + ", trainset.Vehicles.Select(d => d.Name));
+ if (ReferenceEquals(AppState.Instance.CurrentTrainset, trainset))
+ ShowConsist(trainset);
+ }
+
// Rebuilds the caption of each entry in the Vehicles list from its trainset's
// current vehicles, so a consist edited in the depot is reflected here too.
// Selection and order are preserved (only the text is updated).
@@ -144,8 +234,6 @@ public partial class Scenarios : UserControl
return;
}
Scenery selectedScn = Sceneries[tag];
-
- vehicleList.Items.Clear();
// scenery-level info (distinct from the per-consist scenario description)
ShowSceneryInfo(selectedScn);
@@ -155,25 +243,8 @@ public partial class Scenarios : UserControl
missionDescription.Text = "";
timetableContent.Text = App.Loc["NoTimetable"];
- // add all trainsets to list
- for (int i = 0; i < selectedScn.Trainsets.Count; i++)
- {
- string trainsetName = string.Join(
- " + ",
- selectedScn.Trainsets[i].Vehicles.Select(dyn => dyn.Name)
- );
- if (string.IsNullOrWhiteSpace(trainsetName)) continue; // skip empty
- vehicleList.Items.Add(new ListBoxItem
- {
- Content = trainsetName,
- Tag = i
- });
- }
-
- if (vehicleList.Items.Count > 0)
- {
- vehicleList.SelectedIndex = 0;
- }
+ // add the (filtered) trainsets to the Vehicles list
+ PopulateVehicleList(selectedScn);
}
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
diff --git a/StarterNG/Views/Settings.axaml b/StarterNG/Views/Settings.axaml
index 6ce02ff..e830cd4 100644
--- a/StarterNG/Views/Settings.axaml
+++ b/StarterNG/Views/Settings.axaml
@@ -68,6 +68,14 @@
+
+
+
+
+
+
+
diff --git a/StarterNG/Views/Settings.axaml.cs b/StarterNG/Views/Settings.axaml.cs
index 0a521bd..e30aae5 100644
--- a/StarterNG/Views/Settings.axaml.cs
+++ b/StarterNG/Views/Settings.axaml.cs
@@ -1,11 +1,18 @@
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.Interactivity;
+using Avalonia.Layout;
using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Media;
using StarterNG.Classes;
+// Disambiguate from Avalonia.Input.KeyBinding (a command-input binding) imported above.
+using KeyBinding = StarterNG.Classes.KeyBinding;
namespace StarterNG.Views;
@@ -35,6 +42,13 @@ public partial class Settings : UserControl
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
ApplyToUi();
+
+ // Controls tab: load the key bindings and build the editor + on-screen
+ // keyboard. The tunnelling handler lets us capture a key press before the
+ // focused button consumes it (Space/Enter would otherwise click it).
+ KeyboardConfig.Instance.Load();
+ BuildControlsTab();
+ AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel);
}
// ── Settings instance → controls ──────────────────────────────────────
@@ -209,6 +223,7 @@ public partial class Settings : UserControl
{
ReadFromUi();
StarterNG.Classes.Settings.Instance.Save();
+ KeyboardConfig.Instance.Save();
if (SaveStatus is not null)
SaveStatus.Text = App.Loc["SettingsSaved"];
}
@@ -217,6 +232,10 @@ public partial class Settings : UserControl
{
StarterNG.Classes.Settings.Instance.Load();
ApplyToUi();
+ CancelCapture();
+ KeyboardConfig.Instance.Load();
+ RebuildBindingList();
+ RebuildKeyboard();
if (SaveStatus is not null)
SaveStatus.Text = string.Empty;
}
@@ -311,4 +330,598 @@ public partial class Settings : UserControl
return i + 1;
return 4; // default → 8x
}
+
+ // ══════════════════════════════════════════════════════════════════════
+ // Controls tab — key-binding editor and on-screen keyboard
+ // ══════════════════════════════════════════════════════════════════════
+
+ private TextBox? _controlsSearch;
+ private StackPanel? _bindingListPanel;
+ private StackPanel? _keyboardPanel;
+
+ // Binding currently waiting for a key press, and the button that launched it.
+ private KeyBinding? _capturing;
+ private Button? _capturingButton;
+
+ // On-screen key cells, keyed by token, so colours can be refreshed in place
+ // (rebuilding the whole keyboard would detach an open assignment flyout).
+ private readonly Dictionary _keyCells = new(StringComparer.OrdinalIgnoreCase);
+
+ // Guards the assignment flyout's combo boxes against feedback during programmatic updates.
+ private bool _flyoutLoading;
+
+ // State colours (match the legend): unassigned/filler, plain, +shift, +ctrl.
+ private static readonly IBrush KeyUnassignedBrush = new SolidColorBrush(Color.Parse("#2A3036"));
+ private static readonly IBrush KeyFillerBrush = new SolidColorBrush(Color.Parse("#21262B"));
+ private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
+ private static readonly IBrush KeyShiftBrush = new SolidColorBrush(Color.Parse("#C9A227"));
+ private static readonly IBrush KeyCtrlBrush = new SolidColorBrush(Color.Parse("#2D7FD3"));
+ private static readonly IBrush KeyBorderBrush = new SolidColorBrush(Color.Parse("#3A424A"));
+ private static readonly IBrush FgBrush = new SolidColorBrush(Color.Parse("#E6E8EA"));
+ private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
+ private static readonly IBrush ConflictBrush = new SolidColorBrush(Color.Parse("#E06C5A"));
+
+ private const double KeyUnit = 30; // px per relative width unit
+ private const double KeyHeight = 34;
+ private const double KeyGap = 4;
+
+ private void BuildControlsTab()
+ {
+ if (ControlsHost is null)
+ return;
+
+ // Row 0 — search / restore bar.
+ var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 8) };
+ topBar.Children.Add(new TextBlock
+ {
+ Text = App.Loc["BindingsHint"],
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = FgDimBrush
+ });
+ _controlsSearch = new TextBox { Width = 240, Watermark = App.Loc["Search"] };
+ _controlsSearch.TextChanged += (_, _) => RebuildBindingList();
+ topBar.Children.Add(_controlsSearch);
+
+ var restoreBtn = new Button { Content = App.Loc["RestoreDefaults"] };
+ restoreBtn.Classes.Add("Flat");
+ restoreBtn.Click += (_, _) =>
+ {
+ CancelCapture();
+ KeyboardConfig.Instance.LoadDefaults();
+ KeyboardConfig.Instance.Dirty = true;
+ RebuildBindingList();
+ RebuildKeyboard();
+ };
+ topBar.Children.Add(restoreBtn);
+ Grid.SetRow(topBar, 0);
+ ControlsHost.Children.Add(topBar);
+
+ // Row 1 — scrollable binding list.
+ _bindingListPanel = new StackPanel { Spacing = 3 };
+ var listScroll = new ScrollViewer
+ {
+ Content = _bindingListPanel,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto
+ };
+ Grid.SetRow(listScroll, 1);
+ ControlsHost.Children.Add(listScroll);
+
+ // Row 2 — legend + on-screen keyboard.
+ var bottom = new StackPanel { Spacing = 8, Margin = new Thickness(0, 10, 0, 0) };
+ bottom.Children.Add(BuildLegend());
+ _keyboardPanel = new StackPanel();
+ bottom.Children.Add(new ScrollViewer
+ {
+ Content = _keyboardPanel,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
+ });
+ Grid.SetRow(bottom, 2);
+ ControlsHost.Children.Add(bottom);
+
+ RebuildBindingList();
+ RebuildKeyboard();
+ }
+
+ // ── binding list ───────────────────────────────────────────────────────
+ private void RebuildBindingList()
+ {
+ if (_bindingListPanel is null)
+ return;
+
+ _bindingListPanel.Children.Clear();
+ string filter = _controlsSearch?.Text?.Trim().ToLowerInvariant() ?? string.Empty;
+ var conflicts = ComputeConflicts();
+
+ foreach (var b in KeyboardConfig.Instance.Bindings)
+ {
+ if (filter.Length > 0 && !MatchesFilter(b, filter))
+ continue;
+ _bindingListPanel.Children.Add(BuildBindingRow(b, conflicts));
+ }
+ }
+
+ private static bool MatchesFilter(KeyBinding b, string filter) =>
+ b.Command.ToLowerInvariant().Contains(filter) ||
+ b.Description.ToLowerInvariant().Contains(filter);
+
+ private Control BuildBindingRow(KeyBinding b, HashSet conflicts)
+ {
+ var grid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto"),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ var label = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
+ label.Children.Add(new TextBlock
+ {
+ Text = CommandLabel(b),
+ Foreground = FgBrush,
+ FontSize = 13,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ });
+ label.Children.Add(new TextBlock
+ {
+ Text = b.Command,
+ Foreground = FgDimBrush,
+ FontSize = 11,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ });
+ Grid.SetColumn(label, 0);
+ grid.Children.Add(label);
+
+ bool conflict = b.IsAssigned && conflicts.Contains(ComboKey(b));
+ var comboButton = new Button
+ {
+ Content = _capturing == b ? App.Loc["PressKey"] : ComboText(b),
+ MinWidth = 140,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = conflict ? ConflictBrush : FgBrush
+ };
+ comboButton.Classes.Add("Flat");
+ if (conflict)
+ ToolTip.SetTip(comboButton, App.Loc["ConflictTooltip"]);
+ comboButton.Click += (_, _) => StartCapture(b, comboButton);
+ Grid.SetColumn(comboButton, 1);
+ grid.Children.Add(comboButton);
+
+ var clearButton = new Button
+ {
+ Content = "✕",
+ Margin = new Thickness(6, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ clearButton.Classes.Add("Basic");
+ ToolTip.SetTip(clearButton, App.Loc["ClearBinding"]);
+ clearButton.Click += (_, _) =>
+ {
+ CancelCapture();
+ b.Shift = b.Ctrl = false;
+ b.Key = "none";
+ KeyboardConfig.Instance.Dirty = true;
+ RebuildBindingList();
+ RebuildKeyboard();
+ };
+ Grid.SetColumn(clearButton, 2);
+ grid.Children.Add(clearButton);
+
+ return new Border
+ {
+ Background = KeyUnassignedBrush,
+ BorderBrush = KeyBorderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(2),
+ Padding = new Thickness(8, 4),
+ Child = grid
+ };
+ }
+
+ // ── key capture ──────────────────────────────────────────────────────--
+ private void StartCapture(KeyBinding b, Button button)
+ {
+ // Restore the previously listening button, if any, before switching.
+ if (_capturing is not null && _capturingButton is not null)
+ _capturingButton.Content = ComboText(_capturing);
+
+ _capturing = b;
+ _capturingButton = button;
+ button.Content = App.Loc["PressKey"];
+ button.Focus(); // keep focus inside the view so the tunnel handler sees keys
+ }
+
+ private void CancelCapture()
+ {
+ if (_capturing is not null && _capturingButton is not null)
+ _capturingButton.Content = ComboText(_capturing);
+ _capturing = null;
+ _capturingButton = null;
+ }
+
+ private void OnControlsKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (_capturing is null)
+ return;
+
+ if (e.Key == Key.Escape)
+ {
+ CancelCapture();
+ e.Handled = true;
+ return;
+ }
+
+ // Wait for a real key while only modifiers are held.
+ if (KeyMap.IsModifierKey(e.Key))
+ return;
+
+ string? token = KeyMap.FromInput(e.Key, e.PhysicalKey);
+ if (token is null)
+ {
+ e.Handled = true; // swallow unsupported keys instead of clicking the button
+ return;
+ }
+
+ _capturing.Shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
+ _capturing.Ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
+ _capturing.Key = token;
+ KeyboardConfig.Instance.Dirty = true;
+ e.Handled = true;
+
+ _capturing = null;
+ _capturingButton = null;
+ RebuildBindingList();
+ RebuildKeyboard();
+ }
+
+ // ── on-screen keyboard ───────────────────────────────────────────────--
+ private void RebuildKeyboard()
+ {
+ if (_keyboardPanel is null)
+ return;
+
+ _keyCells.Clear();
+ _keyboardPanel.Children.Clear();
+ var states = ComputeKeyStates();
+
+ var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 16 };
+ row.Children.Add(BuildKeyBlock(KeyMap.MainBlock, states));
+ row.Children.Add(BuildKeyBlock(KeyMap.NavBlock, states));
+ row.Children.Add(BuildKeyBlock(KeyMap.NumpadBlock, states));
+ _keyboardPanel.Children.Add(row);
+ }
+
+ // Recolours every key in place without rebuilding the tree, so an open
+ // assignment flyout keeps its anchor.
+ private void UpdateKeyboardColors()
+ {
+ var states = ComputeKeyStates();
+ foreach (var (token, content) in _keyCells)
+ {
+ if (content.Children.Count > 0)
+ content.Children.RemoveAt(0); // drop the old background, keep the label
+ content.Children.Insert(0, BuildKeyBackground(token, states));
+ states.TryGetValue(token, out var state);
+ ToolTip.SetTip(content, BuildKeyTooltip(token, state));
+ }
+ }
+
+ private Control BuildKeyBlock(KeyCap[][] rows, Dictionary states)
+ {
+ var block = new StackPanel { Spacing = KeyGap, VerticalAlignment = VerticalAlignment.Top };
+ foreach (var rowCaps in rows)
+ {
+ var rowPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = KeyGap };
+ foreach (var cap in rowCaps)
+ rowPanel.Children.Add(BuildKeyCap(cap, states));
+ block.Children.Add(rowPanel);
+ }
+ return block;
+ }
+
+ private Control BuildKeyCap(KeyCap cap, Dictionary states)
+ {
+ double width = cap.Width * KeyUnit + (cap.Width - 1) * KeyGap;
+ var content = new Grid { Width = width, Height = KeyHeight };
+
+ var keyBorder = new Border
+ {
+ BorderBrush = KeyBorderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(3),
+ ClipToBounds = true,
+ Child = content
+ };
+
+ if (cap.Token is null)
+ {
+ // Reserved key (Esc, F1-F12, modifiers …): greyed out and not editable.
+ content.Children.Add(new Border { Background = KeyFillerBrush });
+ content.Children.Add(new TextBlock
+ {
+ Text = cap.Label,
+ Foreground = FgDimBrush,
+ FontSize = 9,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ keyBorder.Opacity = 0.55;
+ if (!string.IsNullOrEmpty(cap.Label))
+ ToolTip.SetTip(keyBorder, App.Loc["KbReserved"]);
+ return keyBorder;
+ }
+
+ string token = cap.Token;
+ states.TryGetValue(token.ToLowerInvariant(), out var st);
+ content.Children.Add(BuildKeyBackground(token, states));
+ content.Children.Add(new TextBlock
+ {
+ Text = cap.Label,
+ Foreground = FgBrush,
+ FontSize = 10,
+ FontWeight = FontWeight.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ ToolTip.SetTip(content, BuildKeyTooltip(token, st));
+
+ keyBorder.Cursor = new Cursor(StandardCursorType.Hand);
+ keyBorder.PointerPressed += (_, _) => ShowKeyFlyout(token, keyBorder);
+ _keyCells[token] = content;
+ return keyBorder;
+ }
+
+ private Control BuildKeyBackground(string token, Dictionary states)
+ {
+ states.TryGetValue(token.ToLowerInvariant(), out var state);
+ var colours = new List();
+ if (state is { Plain: true }) colours.Add(KeyPlainBrush);
+ if (state is { Shift: true }) colours.Add(KeyShiftBrush);
+ if (state is { Ctrl: true }) colours.Add(KeyCtrlBrush);
+
+ if (colours.Count == 0)
+ return new Border { Background = KeyUnassignedBrush };
+
+ var stripes = new UniformGrid { Rows = 1, Columns = colours.Count };
+ foreach (var c in colours)
+ stripes.Children.Add(new Border { Background = c });
+ return stripes;
+ }
+
+ private string BuildKeyTooltip(string token, KeyState? state)
+ {
+ string head = KeyMap.DisplayName(token);
+ if (state is null || state.Tips.Count == 0)
+ return $"{head}: {App.Loc["KbUnassigned"]}";
+ return head + "\n" + string.Join("\n", state.Tips);
+ }
+
+ // ── key assignment flyout (one combo per modifier combination) ──────────
+ private void ShowKeyFlyout(string token, Control anchor)
+ {
+ CancelCapture();
+ var commands = KeyboardConfig.Instance.Bindings;
+
+ var items = new List { App.Loc["BindNone"] };
+ foreach (var c in commands)
+ items.Add(CommandLabel(c));
+
+ var slots = new (bool shift, bool ctrl, string labelKey)[]
+ {
+ (false, false, "BindNoMod"),
+ (true, false, "BindShift"),
+ (false, true, "BindCtrl"),
+ (true, true, "BindShiftCtrl"),
+ };
+
+ var combos = new ComboBox[slots.Length];
+ var grid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto")
+ };
+
+ for (int i = 0; i < slots.Length; i++)
+ {
+ var (shift, ctrl, labelKey) = slots[i];
+
+ var lbl = new TextBlock
+ {
+ Text = App.Loc[labelKey],
+ Foreground = FgBrush,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 4, 12, 4)
+ };
+ Grid.SetRow(lbl, i);
+ Grid.SetColumn(lbl, 0);
+ grid.Children.Add(lbl);
+
+ var cb = new ComboBox
+ {
+ ItemsSource = items,
+ MinWidth = 280,
+ MaxDropDownHeight = 320,
+ Margin = new Thickness(0, 4),
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+ cb.SelectionChanged += (_, _) =>
+ {
+ if (_flyoutLoading)
+ return;
+ AssignSlot(token, shift, ctrl, cb.SelectedIndex, commands);
+ SyncFlyoutCombos(token, combos);
+ UpdateKeyboardColors();
+ RebuildBindingList();
+ };
+ Grid.SetRow(cb, i);
+ Grid.SetColumn(cb, 1);
+ grid.Children.Add(cb);
+ combos[i] = cb;
+ }
+
+ SyncFlyoutCombos(token, combos);
+
+ var panel = new StackPanel { Spacing = 8, Margin = new Thickness(12), MinWidth = 360 };
+ panel.Children.Add(new TextBlock
+ {
+ Text = $"{App.Loc["BindKeyTitle"]} {KeyMap.DisplayName(token)}",
+ FontWeight = FontWeight.SemiBold,
+ Foreground = FgBrush
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = App.Loc["BindKeyHint"],
+ Foreground = FgDimBrush,
+ FontSize = 11,
+ TextWrapping = TextWrapping.Wrap
+ });
+ panel.Children.Add(grid);
+
+ var flyout = new Flyout { Content = panel };
+ flyout.ShowAt(anchor);
+ }
+
+ private void SyncFlyoutCombos(string token, ComboBox[] combos)
+ {
+ var commands = KeyboardConfig.Instance.Bindings;
+ var slots = new (bool shift, bool ctrl)[] { (false, false), (true, false), (false, true), (true, true) };
+ _flyoutLoading = true;
+ for (int i = 0; i < combos.Length; i++)
+ {
+ var cmd = CommandInSlot(token, slots[i].shift, slots[i].ctrl);
+ combos[i].SelectedIndex = cmd is null ? 0 : commands.IndexOf(cmd) + 1;
+ }
+ _flyoutLoading = false;
+ }
+
+ private void AssignSlot(string token, bool shift, bool ctrl, int selectedIndex, List commands)
+ {
+ KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
+
+ // Free the slot: unbind any other command currently sitting on it.
+ foreach (var b in KeyboardConfig.Instance.Bindings)
+ {
+ if (!ReferenceEquals(b, chosen) && b.IsAssigned &&
+ string.Equals(b.Key, token, StringComparison.OrdinalIgnoreCase) &&
+ b.Shift == shift && b.Ctrl == ctrl)
+ {
+ b.Shift = b.Ctrl = false;
+ b.Key = "none";
+ }
+ }
+
+ if (chosen is not null)
+ {
+ chosen.Key = token;
+ chosen.Shift = shift;
+ chosen.Ctrl = ctrl;
+ }
+
+ KeyboardConfig.Instance.Dirty = true;
+ }
+
+ private static KeyBinding? CommandInSlot(string token, bool shift, bool ctrl) =>
+ KeyboardConfig.Instance.Bindings.FirstOrDefault(b =>
+ b.IsAssigned &&
+ string.Equals(b.Key, token, StringComparison.OrdinalIgnoreCase) &&
+ b.Shift == shift && b.Ctrl == ctrl);
+
+ private Control BuildLegend()
+ {
+ var legend = new WrapPanel { Orientation = Orientation.Horizontal };
+ legend.Children.Add(LegendItem(KeyUnassignedBrush, App.Loc["KbUnassigned"]));
+ legend.Children.Add(LegendItem(KeyPlainBrush, App.Loc["KbAssigned"]));
+ legend.Children.Add(LegendItem(KeyShiftBrush, App.Loc["KbShift"]));
+ legend.Children.Add(LegendItem(KeyCtrlBrush, App.Loc["KbCtrl"]));
+ return legend;
+ }
+
+ private Control LegendItem(IBrush brush, string text)
+ {
+ var sp = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 6,
+ Margin = new Thickness(0, 0, 16, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ sp.Children.Add(new Border
+ {
+ Width = 16,
+ Height = 16,
+ Background = brush,
+ BorderBrush = KeyBorderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(2),
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ sp.Children.Add(new TextBlock { Text = text, Foreground = FgDimBrush, VerticalAlignment = VerticalAlignment.Center });
+ return sp;
+ }
+
+ // ── shared helpers ─────────────────────────────────────────────────────
+ private sealed class KeyState
+ {
+ public bool Plain;
+ public bool Shift;
+ public bool Ctrl;
+ public readonly List Tips = new();
+ }
+
+ private static Dictionary ComputeKeyStates()
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var b in KeyboardConfig.Instance.Bindings)
+ {
+ if (!b.IsAssigned)
+ continue;
+ string key = b.Key.ToLowerInvariant();
+ if (!map.TryGetValue(key, out var state))
+ map[key] = state = new KeyState();
+
+ if (!b.Shift && !b.Ctrl) state.Plain = true;
+ if (b.Shift) state.Shift = true;
+ if (b.Ctrl) state.Ctrl = true;
+
+ state.Tips.Add($"{ComboText(b)} — {CommandLabel(b)}");
+ }
+ return map;
+ }
+
+ private static HashSet ComputeConflicts()
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var dup = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var b in KeyboardConfig.Instance.Bindings)
+ {
+ if (!b.IsAssigned)
+ continue;
+ string k = ComboKey(b);
+ if (!seen.Add(k))
+ dup.Add(k);
+ }
+ return dup;
+ }
+
+ private static string ComboKey(KeyBinding b) =>
+ $"{(b.Ctrl ? 1 : 0)}|{(b.Shift ? 1 : 0)}|{b.Key.ToLowerInvariant()}";
+
+ private static string ComboText(KeyBinding b)
+ {
+ if (!b.IsAssigned)
+ return "—";
+ var parts = new List();
+ if (b.Ctrl) parts.Add("Ctrl");
+ if (b.Shift) parts.Add("Shift");
+ parts.Add(KeyMap.DisplayName(b.Key));
+ return string.Join(" + ", parts);
+ }
+
+ // Display label for a command: its (capitalised) description, or the raw
+ // command token when the file has no comment for it.
+ private static string CommandLabel(KeyBinding b) =>
+ string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description);
+
+ private static string Capitalize(string s) =>
+ string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
}
diff --git a/StarterNG/startercfg/eu07_input-keyboard.ini b/StarterNG/startercfg/eu07_input-keyboard.ini
new file mode 100644
index 0000000..a760769
--- /dev/null
+++ b/StarterNG/startercfg/eu07_input-keyboard.ini
@@ -0,0 +1,175 @@
+aidriverenable shift q // aktywacja automechanika
+aidriverdisable q // deaktywacja automechanika
+mastercontrollerincrease num_+ // zwikszenie nastawnika glwnego
+mastercontrollerincreasefast shift num_+ // szybkie zwikszenie nastawnika glwnego
+mastercontrollerdecrease num_- // zmniejszenie nastawnika glwnego
+mastercontrollerdecreasefast shift num_- // szybkie zmniejszenie nastawnika glwnego
+secondcontrollerincrease num_/ // zwikszenie nastawnika bocznikw
+secondcontrollerincreasefast shift num_/ // szybkie zwikszenie nastawnika bocznikw
+secondcontrollerdecrease num_* // zmniejszenie nastawnika bocznikw
+secondcontrollerdecreasefast shift num_* // szybkie zmniejszenie nastawnika bocznikw
+mucurrentindicatorothersourceactivate shift z // zmiana czonu dla wskanika prdu
+independentbrakeincrease num_1 // zwikszenie nastawy hamulca lokomotywy
+independentbrakeincreasefast shift num_1 // szybkie zwikszenie nastawy hamulca lokomotywy
+independentbrakedecrease num_7 // zmniejszenie nastawy hamulca lokomotywy
+independentbrakedecreasefast shift num_7 // szybkie zmniejszenie nastawy hamulca lokomotywy
+independentbrakebailoff num_4 // odluniacz
+trainbrakeincrease num_3 // zwikszenie stopnia hamowania zaworu maszynisty
+trainbrakedecrease num_9 // zmniejszenie stopnia hamowania zaworu maszynisty
+trainbrakecharging num_. // napenianie uderzeniowe przewodu glwnego
+trainbrakerelease num_6 // ustawienie zaworu maszynisty w pozycji jazdy
+trainbrakefirstservice num_8 // pozycja pierwszego stopnia hamowania
+trainbrakeservice num_5 // pozycja hamowanie penego
+trainbrakefullservice num_2 // pozycja hamowania uzupeniajcego
+trainbrakehandleoff ctrl num_5 // pozycja odcicia
+trainbrakeemergency num_0 // hamowanie nage
+trainbrakebasepressureincrease ctrl num_3 // zwikszenie poziomu cinienia w przewodzie glwnym
+trainbrakebasepressuredecrease ctrl num_9 // zmniejszenie poziomu cinienia w przewodzie glwnym
+trainbrakebasepressurereset ctrl num_6 // domylny poziom cinienia w przewodzie glwnym
+trainbrakeoperationtoggle ctrl num_4 // przeczenie stanu hamulca pojazdu
+manualbrakeincrease ctrl num_1 // zwikszenie nastawy hamulca rcznego
+manualbrakedecrease ctrl num_7 // zmniejszenie nastawy hamulca rcznego
+alarmchaintoggle shift ctrl b // przeczenie stanu hamulca bezpieczenstwa
+wheelspinbrakeactivate num_enter // aktywacja podhamowania przeciwpolizgowego
+sandboxactivate shift s // aktywacja piasecznicy
+reverserincrease d // zwikszenie pozycji nastawnika kierunku
+reverserdecrease r // zmniejszenie pozycji nastawnika kierunku
+reverserforwardhigh none // pozycja jazdy do przodu II nastawnika kierunku
+reverserforward none // pozycja jazdy do przodu nastawnika kierunku
+reverserneutral none // pozycja neutralna nastawnika kierunku
+reverserbackward none // pozycja jazdy do tyu nastawnika kierunku
+waterpumpbreakertoggle ctrl w // przeczenie stanu wycznika samoczynnego pompy wody
+waterpumptoggle w // przeczenie stanu pompy wody
+waterheaterbreakertoggle ctrl shift w // przeczenie stanu wycznika samoczynnego ogrzewacza wody
+waterheatertoggle shift w // przeczenie stanu ogrzewacza wody
+watercircuitslinktoggle shift h // przeczenie stanu poczenia obiegw wody
+fuelpumptoggle f // przeczenie stanu pompy paliwa
+oilpumptoggle shift f // przeczenie stanu pompy oleju
+linebreakertoggle m // przeczenie stanu wycznika szybkiego
+convertertoggle x // przeczenie stanu przetwornicy
+convertertogglelocal shift x // przeczenie stanu przetwornicy w obsadzonym czonie
+converteroverloadrelayreset ctrl n // odblokowanie przekanika nadmiarowego przetwornicy
+compressortoggle c // przeczenie stanu sprarki
+compressortogglelocal shift c // przeczenie stanu sprarki w obsadzonym czonie
+motoroverloadrelaythresholdtoggle ctrl f // przeczenie rozruchu niskiego/wysokiego
+motoroverloadrelayreset n // odblokowania przekanika nadmiarowego silnikw
+notchingrelaytoggle g // przeczenie stanu przekanika samoczynnego rozruchu
+epbrakecontroltoggle ctrl z // przeczenie stanu hamulca elektro-pneumatycznego
+trainbrakeoperationmodeincrease ctrl num_2 // zmiana trybu pracy hamulca na bardziej efektywny
+trainbrakeoperationmodedecrease ctrl num_8 // zmiana trybu pracy hamulca na prostszy
+brakeactingspeedincrease shift b // wybr szybszego trybu dzialania hamulca pocigu
+brakeactingspeeddecrease b // wybr wolniejszego trybu dzialania hamulca pocigu
+brakeactingspeedsetcargo none // wybr trybu hamulca dla pocigu towarowego
+brakeactingspeedsetpassenger none // wybr trybu hamulca dla pocigu osobowego
+brakeactingspeedsetrapid none // wybr trybu hamulca dla pocigu pospiesznego
+brakeloadcompensationincrease shift ctrl b // wybr wyszego przeoenia hamulca
+brakeloadcompensationdecrease ctrl b // wybr niszego przeoenia hamulca
+mubrakingindicatortoggle shift l // przeczenie stanu wskanika hamowania czonw EZT
+alerteracknowledge space // wygaszenie czuwaka i shp
+cabsignalacknowledge shift space // wygaszenie shp
+hornlowactivate a // aktywacja syreny niskotonowej
+hornhighactivate s // aktywacja syreny wysokotonowej
+whistleactivate z // aktywacja gwizdka
+radiotoggle ctrl r // przeczenie stanu radiotelefonu
+radiochannelincrease = // wybr kolejnego kanau radia
+radiochanneldecrease - // wybr poprzedniego kanau radia
+radiostopsend shift ctrl pause // emisja radiostopu
+radiostoptest shift ctrl r // lokalny test radiostopu
+radiocall3send backspace // emisja sygnau zew3
+radiovolumeincrease ctrl = // zwikszenie gonoci radia
+radiovolumedecrease ctrl - // zmniejszenie gonoci radia
+cabchangeforward home // przejcie do ssiedniego pomieszczenia w kierunku czoa pocigu
+cabchangebackward end // przejcie do ssiedniego pomieszczenia w kierunku tyu pocigu
+nearestcarcouplingincrease insert // czenie sprzgw
+nearestcarcouplingdisconnect delete // rozczenie sprzgw
+nearestcarcoupleradapterattach ctrl insert // naoenie psprzgu
+nearestcarcoupleradapterremove ctrl delete // demontaz polsprzegu
+occupiedcarcouplingdisconnect shift delete // rozczenie sprzgw po stronie obsadzonej kabiny
+doortoggleleft , // przeczenie stanu drzwi lewych
+doortoggleright . // przeczenie stanu drzwi prawych
+doorpermitleft shift , // zezwolenie na otwarcie drzwi lewych
+doorpermitright shift . // zezwolenie na otwarcie drzwi prawych
+doorpermitpresetactivatenext shift ctrl . // aktywacja kolejnej konfiguracji zezwolenia na otwarcie drzwi
+doorpermitpresetactivateprevious shift ctrl , // aktywacja poprzedniej konfiguracji zezwolenia na otwarcie drzwi
+dooropenall shift / // otwarcie drzwi z zezwoleniem
+doorcloseleft ctrl , // zamknicie drzwi lewych
+doorcloseright ctrl . // zamknicie drzwi prawych
+doorcloseall ctrl / // zamknicie wszystkich drzwi
+doorsteptoggle none // przeczenie stanu stopnia drzwi
+doormodetoggle shift ctrl / // przeczenie trybu dzialania drzwi
+departureannounce / // sygna odjazdu
+doorlocktoggle ctrl s // przeczenie stanu blokady drzwi
+pantographcompressorvalvetoggle ctrl v // przeczenie stanu kurka trjdrogowego
+pantographcompressoractivate shift v // aktywacja sprezarki pomocniczej
+pantographtogglefront p // przeczenie stanu pantografu przedniego
+pantographtogglerear o // przeczenie stanu pantografu tylnego
+pantographlowerall ctrl p // opuszczenie wszystkich pantografw
+pantographselectnext shift p // aktywacja kolejnej konfiguracji wybru pantografw
+pantographselectprevious shift o // aktywacja poprzedniej konfiguracji wybru pantografw
+pantographtoggleselected shift ctrl o // przeczenie stanu wybranych pantografw
+heatingtoggle h // przeczenie stanu ogrzewania pocigu
+lightspresetactivatenext shift t // aktywacja kolejnej konfiguracji reflektorw
+lightspresetactivateprevious t // aktywacja poprzedniej konfiguracji reflektorw
+headlighttoggleleft y // przeczenie stanu lewego reflektora
+headlighttoggleright i // przeczenie stanu prawego reflektora
+headlighttoggleupper u // przeczenie stanu gornego reflektora
+redmarkertoggleleft shift y // przeczenie lewego wiata czerwonego
+redmarkertoggleright shift i // przeczenie prawego wiata czerwonego
+headlighttogglerearleft ctrl y // przeczenie stanu lewego tylnego reflektora
+headlighttogglerearright ctrl i // przeczenie stanu prawego tylnego reflektora
+headlighttogglerearupper ctrl u // przeczenie stanu gornego tylnego reflektora
+redmarkertogglerearleft ctrl shift y // przeczenie lewego tylnego wiata czerwonego
+redmarkertogglerearright ctrl shift i // przeczenie prawego tylnego wiata czerwonego
+redmarkerstoggle shift e // przeczenie swiatel koca pocigu
+endsignalstoggle e // przeczenie tabliczek koca pocigu
+headlightsdimtoggle ctrl l // przeczenie przyciemnienia reflektorw
+motorconnectorsopen l // rozczenie stycznikw liniowych
+motorconnectorsclose ctrl shift l // zaczenie stycznikw liniowych
+motordisconnect ctrl e // odczenie uszkodzonych silnikw
+interiorlighttoggle ' // przeczenie stanu owietlenia kabiny
+interiorlightdimtoggle ctrl ' // przeczenie przyciemnienia owietlenia kabiny
+compartmentlightstoggle ctrl ; // przeczenie stanu owietlenia przedziaw
+instrumentlighttoggle ; // przeczenie stanu owietlenia przyrzadow
+dashboardlighttoggle shift ; // przeczenie stanu owietlenia pulpitu
+timetablelighttoggle shift ' // przeczenie stanu owietlenia rozkladu jazdy
+generictoggle0 0 // przeczenie elementu universalnego nr 0
+generictoggle1 1 // przeczenie elementu universalnego nr 1
+generictoggle2 2 // przeczenie elementu universalnego nr 2
+generictoggle3 3 // przeczenie elementu universalnego nr 3
+generictoggle4 4 // przeczenie elementu universalnego nr 4
+generictoggle5 5 // przeczenie elementu universalnego nr 5
+generictoggle6 6 // przeczenie elementu universalnego nr 6
+generictoggle7 7 // przeczenie elementu universalnego nr 7
+generictoggle8 8 // przeczenie elementu universalnego nr 8
+generictoggle9 9 // przeczenie elementu universalnego nr 9
+batterytoggle j // przeczenie stanu akumulatorw
+cabactivationtoggle ctrl j // przeczenie stanu aktywacji kabiny
+motorblowerstogglefront shift n // przeczenie stanu wentylatorw przednich motorow trakcyjnych
+motorblowerstogglerear shift m // przeczenie stanu wentylatorw tylnych motorow trakcyjnych
+motorblowersdisableall ctrl m // wyczenie wszystkich wentylatorw motorow trakcyjnych
+coolingfanstoggle none // przeczenie stanu wentylatorw oporw rozruchowych
+tempomattoggle none // przeczenie stanu tempomatu
+springbraketoggle shift num_enter // przeczenie stanu hamulca sprynowego
+springbrakeshutofftoggle ctrl shift \ // przeczenie stanu kurka odcinajacego hamulca sprynowego
+springbrakerelease none // luzowanie hamulca sprynowego
+distancecounteractivate ctrl num_enter // zaczenie miernika przejechanego dystansu
+speedcontrolincrease none // zwikszenie prdkoci utrzymywanej przez tempomat
+speedcontroldecrease none // zmniejszenie prdkoci utrzymywanej przez tempomat
+speedcontrolpowerincrease none // zwikszenie maksymalnego poziomu mocy dostpnej dla tempomatu
+speedcontrolpowerdecrease none // zmniejszenie maksymalnego poziomu mocy dostpnej dla tempomatu
+speedcontrolbutton0 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 0
+speedcontrolbutton1 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 1
+speedcontrolbutton2 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 2
+speedcontrolbutton3 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 3
+speedcontrolbutton4 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 4
+speedcontrolbutton5 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 5
+speedcontrolbutton6 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 6
+speedcontrolbutton7 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 7
+speedcontrolbutton8 none // ustalenie dla tempomatu zdefiniowanej prdkoci nr 8
+speedcontrolbutton9 none // ustalenie dla tempomatu zdefiniowanej predkoci nr 9
+universalbrakebutton1 none // aktywacja zdefiniowanych elementw hamulca zespolonego nr 1
+universalbrakebutton2 none // aktywacja zdefiniowanych elementw hamulca zespolonego nr 2
+universalbrakebutton3 none // aktywacja zdefiniowanych elementw hamulca zespolonego nr 3
+universalrelayreset1 none // odblokowanie zdefiniowanych przekanikw nr 1
+universalrelayreset2 none // odblokowanie zdefiniowanych przekanikw nr 2
+universalrelayreset3 none // odblokowanie zdefiniowanych przekanikw nr 3
diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml
index bd8a255..4837991 100644
--- a/StarterNG/startercfg/lang/en.xml
+++ b/StarterNG/startercfg/lang/en.xml
@@ -14,6 +14,9 @@
Sceneries
Vehicles
+ AI vehicles
+ Drivable only
+ Archival sceneries
Scenario description
Scenery description
Description
@@ -163,6 +166,12 @@
Amount
Set an amount above 0 to load the vehicle.
Wagon number
+ Copy load from previous vehicle
+ Maximum possible load
+ Whole-consist load
+ Random load
+ Maximum amount
+ Random amount
Set
Length
Mass
@@ -175,6 +184,16 @@
Add this vehicle to the consist
Add the whole unit to the consist
+
+ Save consist
+ Load consist from warehouse
+ (warehouse empty)
+ Remove from warehouse
+ Consist name
+ Copy consist to clipboard
+ Paste consist from clipboard
+ Remove all vehicles
+
Loading vehicle database
Loading sceneries
@@ -285,6 +304,26 @@
Large thumbnails
Automatically expand scenery tree
+
+ Controls
+ Click a binding, then press the key (hold Shift / Ctrl for a combination):
+ Restore defaults
+ Press a key…
+ Clear binding
+ Conflict: this shortcut is used by more than one command
+ Unassigned
+ Assigned
+ With Shift
+ With Ctrl
+ Reserved by the simulator
+ Key:
+ Assign a command to each modifier combination for this key.
+ — none —
+ No modifier
+ Shift
+ Ctrl
+ Shift + Ctrl
+
Save
Reset
diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml
index 8d75d08..bf3fde1 100644
--- a/StarterNG/startercfg/lang/pl.xml
+++ b/StarterNG/startercfg/lang/pl.xml
@@ -14,6 +14,9 @@
Scenerie
Pojazdy
+ Pojazdy AI
+ Tylko do prowadzenia
+ Scenerie archiwalne
Opis scenariusza
Opis scenerii
Opis
@@ -163,6 +166,12 @@
Ilość
Ustaw ilość większą od 0, aby załadować pojazd.
Numer wagonu
+ Kopiuj ładunek z poprzedniego pojazdu
+ Maksymalny możliwy ładunek
+ Ładunek całego składu
+ Losowy ładunek
+ Maksymalna ilość
+ Losowa ilość
Ustaw
Długość
Masa
@@ -175,6 +184,16 @@
Dodaj ten pojazd do składu
Dodaj cały zespół do składu
+
+ Zapisz zestawienie
+ Wczytaj zestawienie z magazynu
+ (magazyn pusty)
+ Usuń z magazynu
+ Nazwa zestawienia
+ Skopiuj skład do schowka
+ Wklej skład ze schowka
+ Usuń wszystkie pojazdy ze składu
+
Wczytywanie bazy pojazdów
Wczytywanie scenerii
@@ -285,6 +304,26 @@
Duże miniaturki
Automatycznie rozwijanie drzewka scenerii
+
+ Sterowanie
+ Kliknij przypisanie i naciśnij klawisz (przytrzymaj Shift / Ctrl dla kombinacji):
+ Przywróć domyślne
+ Naciśnij klawisz…
+ Wyczyść przypisanie
+ Konflikt: ten skrót jest używany przez więcej niż jedną komendę
+ Nieprzypisany
+ Przypisany
+ Z klawiszem Shift
+ Z klawiszem Ctrl
+ Zarezerwowany przez symulator
+ Klawisz:
+ Przypisz komendę do każdej kombinacji modyfikatorów dla tego klawisza.
+ — brak —
+ Bez modyfikatora
+ Shift
+ Ctrl
+ Shift + Ctrl
+
Zapisz
Przywróć