From 34758bb21dcb995515ba10f18cc966ed8a9c76f2 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Wed, 24 Jun 2026 19:24:06 +0200 Subject: [PATCH] Keybinds editor --- StarterNG/App.axaml.cs | 13 +- StarterNG/Classes/KeyMap.cs | 250 ++++++++ StarterNG/Classes/KeyboardConfig.cs | 259 ++++++++ StarterNG/Classes/PresetStore.cs | 146 +++++ StarterNG/Classes/Scenery.cs | 5 + StarterNG/Classes/Settings.cs | 11 + StarterNG/Classes/Trainset.cs | 4 + StarterNG/StarterNG.csproj | 10 +- StarterNG/Views/ConsistContextMenu.cs | 193 ++++++ StarterNG/Views/Depot.axaml | 3 +- StarterNG/Views/Depot.axaml.cs | 234 ++++++- StarterNG/Views/Scenarios.axaml | 26 +- StarterNG/Views/Scenarios.axaml.cs | 221 ++++--- StarterNG/Views/Settings.axaml | 8 + StarterNG/Views/Settings.axaml.cs | 613 +++++++++++++++++++ StarterNG/startercfg/eu07_input-keyboard.ini | 175 ++++++ StarterNG/startercfg/lang/en.xml | 39 ++ StarterNG/startercfg/lang/pl.xml | 39 ++ 18 files changed, 2157 insertions(+), 92 deletions(-) create mode 100644 StarterNG/Classes/KeyMap.cs create mode 100644 StarterNG/Classes/KeyboardConfig.cs create mode 100644 StarterNG/Classes/PresetStore.cs create mode 100644 StarterNG/Views/ConsistContextMenu.cs create mode 100644 StarterNG/startercfg/eu07_input-keyboard.ini 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óć