Keybinds editor

This commit is contained in:
2026-06-24 19:24:06 +02:00
parent 5908200bc4
commit 34758bb21d
18 changed files with 2157 additions and 92 deletions

View File

@@ -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;

250
StarterNG/Classes/KeyMap.cs Normal file
View File

@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
namespace StarterNG.Classes;
/// <summary>
/// One cap on the on-screen keyboard. <see cref="Token"/> 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.
/// </summary>
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;
}
}
/// <summary>
/// Translates between physical keyboard input, simulator key tokens and the
/// on-screen keyboard layout used by the controls visualisation.
/// </summary>
public static class KeyMap
{
/// <summary>
/// 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).
/// </summary>
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
};
}
/// <summary>True for keys that only act as modifiers and never form a binding alone.</summary>
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;
/// <summary>Friendly, explicit name for a token, used in the binding list text.</summary>
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.
/// <summary>Rows of the main (alphanumeric) keyboard block.</summary>
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),
},
};
/// <summary>Rows of the numeric-keypad block, drawn to the right of the main block.</summary>
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),
},
};
/// <summary>Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks.</summary>
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"),
},
};
}

View File

@@ -0,0 +1,259 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace StarterNG.Classes;
/// <summary>
/// A single keyboard command binding parsed from <c>eu07_input-keyboard.ini</c>.
/// A binding is a simulator command name, an optional pair of modifier flags
/// (<c>shift</c> / <c>ctrl</c>, either or both) and the key it is mapped to.
/// <c>none</c> (or an empty key) means the command is intentionally unbound.
/// </summary>
public sealed class KeyBinding
{
/// <summary>Simulator command token, e.g. <c>mastercontrollerincrease</c>.</summary>
public string Command = string.Empty;
/// <summary>True when the binding requires the Shift modifier.</summary>
public bool Shift;
/// <summary>True when the binding requires the Ctrl modifier.</summary>
public bool Ctrl;
/// <summary>The key token (e.g. <c>q</c>, <c>num_+</c>) or <c>none</c> when unbound.</summary>
public string Key = "none";
/// <summary>Human description taken from the line's trailing comment (no <c>//</c>).</summary>
public string Description = string.Empty;
/// <summary>Index into the backing line list, so the original line can be rewritten in place.</summary>
internal int LineIndex = -1;
/// <summary>True when the command is mapped to an actual key.</summary>
public bool IsAssigned =>
!string.IsNullOrEmpty(Key) &&
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Reader/writer for the simulator's keyboard layout file,
/// <c>eu07_input-keyboard.ini</c>, which lives next to <c>eu07.ini</c>
/// (<c>%APPDATA%\MaSzyna\</c> on Windows, <c>~/.config/MaSzyna/</c> elsewhere).
///
/// <para>The file is a flat list of <c>command [shift] [ctrl] key // comment</c>
/// lines. Modifiers are the literal tokens <c>shift</c> and <c>ctrl</c> 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.</para>
/// </summary>
public sealed class KeyboardConfig
{
public static KeyboardConfig Instance { get; } = new();
private readonly List<string> _lines = new();
/// <summary>All recognised bindings, in file order.</summary>
public List<KeyBinding> Bindings { get; private set; } = new();
/// <summary>True when a binding has been changed since the last load/save.</summary>
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;
}
}
/// <summary>Per-user keyboard config path for the current OS (next to eu07.ini).</summary>
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");
}
/// <summary>Default file shipped in the simulator's working directory.</summary>
private static string WorkingDirDefaultPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
/// <summary>Fallback template bundled with the launcher (startercfg/).</summary>
private static string BundledDefaultPath() =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
/// <summary>Loads the per-user file, falling back to the working-dir or bundled default.</summary>
public void Load()
{
_savePath = UserConfigPath();
string source = FirstExisting(_savePath, WorkingDirDefaultPath(), BundledDefaultPath());
ParseSource(source);
Dirty = false;
}
/// <summary>Reloads the shipped defaults, discarding the user's current edits.</summary>
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<KeyBinding>();
}
}
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;
}
/// <summary>Parses raw file text into <see cref="Bindings"/>, keeping every line verbatim.</summary>
public void Parse(string text)
{
_lines.Clear();
var bindings = new List<KeyBinding>();
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;
}
/// <summary>
/// Parses a single line into a binding. Returns false for blank lines, pure
/// comments and anything without a command token.
/// </summary>
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;
}
/// <summary>Rewrites each binding's line, then serialises the file to text.</summary>
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);
}
/// <summary>Formats one binding as <c>command [shift] [ctrl] key // comment</c>.</summary>
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();
}
/// <summary>Writes the bindings back to the per-user keyboard file.</summary>
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.
}
}
}

View File

@@ -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;
/// <summary>One saved consist in the user's vehicle warehouse.</summary>
public sealed class TrainsetPreset
{
/// <summary>Display name shown in the "load from warehouse" menu.</summary>
public string Name { get; set; } = "";
/// <summary>Complete "trainset … endtrainset" scenery entry (self-contained).</summary>
public string Entry { get; set; } = "";
}
/// <summary>Root object persisted to userpresets.json.</summary>
public sealed class PresetCollection
{
public List<TrainsetPreset> Presets { get; set; } = new();
}
/// <summary>
/// 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 <see cref="Environment.SpecialFolder.ApplicationData"/>).
/// All operations are best-effort and never throw.
/// </summary>
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<PresetCollection> TypeInfo =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
/// <summary>All saved presets, sorted by name. Empty when none / unreadable.</summary>
public static IReadOnlyList<TrainsetPreset> All() => Load().Presets;
/// <summary>
/// Saves (or overwrites, by name) a consist preset. No-op on a blank name or
/// empty entry.
/// </summary>
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);
}
/// <summary>Removes a preset by name (case-insensitive).</summary>
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
}
}
}
/// <summary>
/// Helpers to convert a consist to/from its canonical scenery text - the full
/// "trainset … endtrainset" block, exactly what is placed on the clipboard.
/// </summary>
public static class ConsistText
{
/// <summary>The complete scenery entry for a trainset (incl. endtrainset).</summary>
public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
/// <summary>
/// Parses the vehicles out of a complete trainset entry. Returns null when the
/// text holds no usable node::dynamic vehicles.
/// </summary>
public static List<Dynamic>? 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
{
}

View File

@@ -19,6 +19,9 @@ public class Scenery
public string Description; // //$d - scenery description
public string ImageName; // //$i - main-window image (scenery thumbnail)
/// <summary>True when the scenery declares the //$a "archival" flag.</summary>
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)

View File

@@ -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
/// <summary>gfxrenderer tokens in the order shown by the render-engine combo.</summary>
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 ──────────────────────────────────────────────────────

View File

@@ -30,9 +30,13 @@ public class Trainset
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
public bool Parsed;
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
public bool Decor;
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
Decor = Regex.IsMatch(trainsetEntry, @"//\$decor\b", RegexOptions.IgnoreCase);
Vehicles = new List<Dynamic>();
Description = "";
try

View File

@@ -9,8 +9,8 @@
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.3</AssemblyVersion>
<FileVersion>1.1.0.3</FileVersion>
<AssemblyVersion>1.2.0.4</AssemblyVersion>
<FileVersion>1.2.0.4</FileVersion>
<AssemblyName>Starter</AssemblyName>
<Company>eu07.pl</Company>
</PropertyGroup>
@@ -59,5 +59,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>startercfg\lang\%(Filename)%(Extension)</Link>
</Content>
<!-- Default key-binding layout, used as a template when the user has no
eu07_input-keyboard.ini yet (and for "Restore defaults"). -->
<Content Include="startercfg\eu07_input-keyboard.ini">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>startercfg\eu07_input-keyboard.ini</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// 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 <c>removeAll</c> is supplied - clearing the consist (Shift+Delete).
/// </summary>
public static class ConsistContextMenu
{
/// <param name="owner">Control the menu/flyouts anchor to (for clipboard + popups).</param>
/// <param name="getTarget">Supplies the trainset to act on when the menu opens.</param>
/// <param name="applyVehicles">Replaces the target's vehicles and refreshes the view.</param>
/// <param name="removeAll">When non-null, adds a "remove all vehicles" entry.</param>
public static ContextMenu Build(
Control owner,
Func<Trainset?> getTarget,
Action<List<Dynamic>> 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<Trainset?> getTarget, Action<List<Dynamic>> 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<List<Dynamic>> 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;
}
}

View File

@@ -69,7 +69,8 @@
<TextBlock Name="mapSceneryLabel" DockPanel.Dock="Top" FontSize="12"
Opacity="0.8" Margin="0,0,0,6" TextTrimming="CharacterEllipsis" />
<ListBox Name="sceneryConsistList" Classes="Compact"
SelectionChanged="SceneryConsistList_OnSelectionChanged" />
SelectionChanged="SceneryConsistList_OnSelectionChanged"
KeyDown="SceneryConsistList_OnKeyDown" />
</DockPanel>
</HeaderedContentControl>

View File

@@ -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<Dynamic> 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<Dynamic>();
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 "#<n>" 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<string> 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

View File

@@ -21,8 +21,15 @@
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
Classes="Compact"
Header="{Binding [Sceneries], Source={x:Static starterNg:App.Loc}}">
<TreeView Name="sceneryList" Classes="Compact"
SelectionChanged="SceneryList_OnSelectionChanged" />
<DockPanel LastChildFill="True">
<ToggleSwitch DockPanel.Dock="Bottom" Name="archivalSwitch"
Margin="2,4,0,0" FontSize="11"
IsCheckedChanged="ArchivalSwitch_OnChanged"
OnContent="{Binding [ShowArchival], Source={x:Static starterNg:App.Loc}}"
OffContent="{Binding [ShowArchival], Source={x:Static starterNg:App.Loc}}" />
<TreeView Name="sceneryList" Classes="Compact"
SelectionChanged="SceneryList_OnSelectionChanged" />
</DockPanel>
</HeaderedContentControl>
<!-- Centre: trainsets list (compact, fills) + description/weather/timetable tabs -->
@@ -34,8 +41,19 @@
<HeaderedContentControl Grid.Row="0" Theme="{StaticResource GroupBox}"
Header="{Binding [Vehicles], Source={x:Static starterNg:App.Loc}}">
<ListBox Name="vehicleList" Classes="Compact"
SelectionChanged="VehicleList_OnSelectionChanged" />
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="16"
Margin="2,4,0,0">
<CheckBox Name="showAiCheck" FontSize="11"
IsCheckedChanged="VehicleFilter_OnChanged"
Content="{Binding [ShowAiVehicles], Source={x:Static starterNg:App.Loc}}" />
<CheckBox Name="drivableOnlyCheck" FontSize="11"
IsCheckedChanged="VehicleFilter_OnChanged"
Content="{Binding [ShowDrivableOnly], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
<ListBox Name="vehicleList" Classes="Compact"
SelectionChanged="VehicleList_OnSelectionChanged" />
</DockPanel>
</HeaderedContentControl>
<TabControl Grid.Row="1">

View File

@@ -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<string, TreeViewItem>();
var topLevel = new List<TreeViewItem>();
// 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<TreeViewItem>()
.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<string, TreeViewItem>();
var topLevel = new List<TreeViewItem>();
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<TreeViewItem>()
.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<Dynamic> 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).

View File

@@ -68,6 +68,14 @@
</ScrollViewer>
</TabItem>
<!-- Controls / key bindings -->
<TabItem Header="{Binding [Controls], Source={x:Static starterNg:App.Loc}}">
<Grid x:Name="ControlsHost" RowDefinitions="Auto,*,Auto" Margin="12">
<!-- Built in code-behind (BuildControlsTab): a search bar, the
scrollable binding list, and the keyboard visualisation. -->
</Grid>
</TabItem>
<!-- Others -->
<TabItem Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">

View File

@@ -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<string, Grid> _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<string> 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<string, KeyState> 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<string, KeyState> 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<string, KeyState> states)
{
states.TryGetValue(token.ToLowerInvariant(), out var state);
var colours = new List<IBrush>();
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<string> { 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<KeyBinding> 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<string> Tips = new();
}
private static Dictionary<string, KeyState> ComputeKeyStates()
{
var map = new Dictionary<string, KeyState>(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<string> ComputeConflicts()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dup = new HashSet<string>(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<string>();
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);
}

View File

@@ -0,0 +1,175 @@
aidriverenable shift q // aktywacja automechanika
aidriverdisable q // deaktywacja automechanika
mastercontrollerincrease num_+ // zwi瘯szenie nastawnika gl闚nego
mastercontrollerincreasefast shift num_+ // szybkie zwi瘯szenie nastawnika gl闚nego
mastercontrollerdecrease num_- // zmniejszenie nastawnika gl闚nego
mastercontrollerdecreasefast shift num_- // szybkie zmniejszenie nastawnika gl闚nego
secondcontrollerincrease num_/ // zwi瘯szenie nastawnika bocznik闚
secondcontrollerincreasefast shift num_/ // szybkie zwi瘯szenie nastawnika bocznik闚
secondcontrollerdecrease num_* // zmniejszenie nastawnika bocznik闚
secondcontrollerdecreasefast shift num_* // szybkie zmniejszenie nastawnika bocznik闚
mucurrentindicatorothersourceactivate shift z // zmiana cz這nu dla wska俲ika pr鉅u
independentbrakeincrease num_1 // zwi瘯szenie nastawy hamulca lokomotywy
independentbrakeincreasefast shift num_1 // szybkie zwi瘯szenie nastawy hamulca lokomotywy
independentbrakedecrease num_7 // zmniejszenie nastawy hamulca lokomotywy
independentbrakedecreasefast shift num_7 // szybkie zmniejszenie nastawy hamulca lokomotywy
independentbrakebailoff num_4 // odlu俲iacz
trainbrakeincrease num_3 // zwi瘯szenie stopnia hamowania zaworu maszynisty
trainbrakedecrease num_9 // zmniejszenie stopnia hamowania zaworu maszynisty
trainbrakecharging num_. // nape軟ianie uderzeniowe przewodu gl闚nego
trainbrakerelease num_6 // ustawienie zaworu maszynisty w pozycji jazdy
trainbrakefirstservice num_8 // pozycja pierwszego stopnia hamowania
trainbrakeservice num_5 // pozycja hamowanie pe軟ego
trainbrakefullservice num_2 // pozycja hamowania uzupe軟iaj鉍ego
trainbrakehandleoff ctrl num_5 // pozycja odci璚ia
trainbrakeemergency num_0 // hamowanie nag貫
trainbrakebasepressureincrease ctrl num_3 // zwi瘯szenie poziomu ci𦨭ienia w przewodzie gl闚nym
trainbrakebasepressuredecrease ctrl num_9 // zmniejszenie poziomu ci𦨭ienia w przewodzie gl闚nym
trainbrakebasepressurereset ctrl num_6 // domy𦧺ny poziom ci𦨭ienia w przewodzie gl闚nym
trainbrakeoperationtoggle ctrl num_4 // prze章czenie stanu hamulca pojazdu
manualbrakeincrease ctrl num_1 // zwi瘯szenie nastawy hamulca r璚znego
manualbrakedecrease ctrl num_7 // zmniejszenie nastawy hamulca r璚znego
alarmchaintoggle shift ctrl b // prze章czenie stanu hamulca bezpieczenstwa
wheelspinbrakeactivate num_enter // aktywacja podhamowania przeciwpo𦧺izgowego
sandboxactivate shift s // aktywacja piasecznicy
reverserincrease d // zwi瘯szenie 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 ty逝 nastawnika kierunku
waterpumpbreakertoggle ctrl w // prze章czenie stanu wy章cznika samoczynnego pompy wody
waterpumptoggle w // prze章czenie stanu pompy wody
waterheaterbreakertoggle ctrl shift w // prze章czenie stanu wy章cznika samoczynnego ogrzewacza wody
waterheatertoggle shift w // prze章czenie stanu ogrzewacza wody
watercircuitslinktoggle shift h // prze章czenie stanu po章czenia obieg闚 wody
fuelpumptoggle f // prze章czenie stanu pompy paliwa
oilpumptoggle shift f // prze章czenie stanu pompy oleju
linebreakertoggle m // prze章czenie stanu wy章cznika szybkiego
convertertoggle x // prze章czenie stanu przetwornicy
convertertogglelocal shift x // prze章czenie stanu przetwornicy w obsadzonym cz這nie
converteroverloadrelayreset ctrl n // odblokowanie przeka俲ika nadmiarowego przetwornicy
compressortoggle c // prze章czenie stanu spr篹arki
compressortogglelocal shift c // prze章czenie stanu spr篹arki w obsadzonym cz這nie
motoroverloadrelaythresholdtoggle ctrl f // prze章czenie rozruchu niskiego/wysokiego
motoroverloadrelayreset n // odblokowania przeka俲ika nadmiarowego silnik闚
notchingrelaytoggle g // prze章czenie stanu przeka俲ika samoczynnego rozruchu
epbrakecontroltoggle ctrl z // prze章czenie 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 // wyb鏎 szybszego trybu dzialania hamulca poci鉚u
brakeactingspeeddecrease b // wyb鏎 wolniejszego trybu dzialania hamulca poci鉚u
brakeactingspeedsetcargo none // wyb鏎 trybu hamulca dla poci鉚u towarowego
brakeactingspeedsetpassenger none // wyb鏎 trybu hamulca dla poci鉚u osobowego
brakeactingspeedsetrapid none // wyb鏎 trybu hamulca dla poci鉚u pospiesznego
brakeloadcompensationincrease shift ctrl b // wyb鏎 wy窺zego prze這瞠nia hamulca
brakeloadcompensationdecrease ctrl b // wyb鏎 ni窺zego prze這瞠nia hamulca
mubrakingindicatortoggle shift l // prze章czenie stanu wska俲ika hamowania cz這n闚 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 // prze章czenie stanu radiotelefonu
radiochannelincrease = // wyb鏎 kolejnego kana逝 radia
radiochanneldecrease - // wyb鏎 poprzedniego kana逝 radia
radiostopsend shift ctrl pause // emisja radiostopu
radiostoptest shift ctrl r // lokalny test radiostopu
radiocall3send backspace // emisja sygna逝 zew3
radiovolumeincrease ctrl = // zwi瘯szenie g這𦨭o𦣇i radia
radiovolumedecrease ctrl - // zmniejszenie g這𦨭o𦣇i radia
cabchangeforward home // przej𦣇ie do s零iedniego pomieszczenia w kierunku czo豉 poci鉚u
cabchangebackward end // przej𦣇ie do s零iedniego pomieszczenia w kierunku ty逝 poci鉚u
nearestcarcouplingincrease insert // 章czenie sprz璕闚
nearestcarcouplingdisconnect delete // roz章czenie sprz璕闚
nearestcarcoupleradapterattach ctrl insert // na這瞠nie p馧sprz璕u
nearestcarcoupleradapterremove ctrl delete // demontaz polsprzegu
occupiedcarcouplingdisconnect shift delete // roz章czenie sprz璕闚 po stronie obsadzonej kabiny
doortoggleleft , // prze章czenie stanu drzwi lewych
doortoggleright . // prze章czenie 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 , // zamkni璚ie drzwi lewych
doorcloseright ctrl . // zamkni璚ie drzwi prawych
doorcloseall ctrl / // zamkni璚ie wszystkich drzwi
doorsteptoggle none // prze章czenie stanu stopnia drzwi
doormodetoggle shift ctrl / // prze章czenie trybu dzialania drzwi
departureannounce / // sygna<6E> odjazdu
doorlocktoggle ctrl s // prze章czenie stanu blokady drzwi
pantographcompressorvalvetoggle ctrl v // prze章czenie stanu kurka tr鎩drogowego
pantographcompressoractivate shift v // aktywacja sprezarki pomocniczej
pantographtogglefront p // prze章czenie stanu pantografu przedniego
pantographtogglerear o // prze章czenie stanu pantografu tylnego
pantographlowerall ctrl p // opuszczenie wszystkich pantograf闚
pantographselectnext shift p // aktywacja kolejnej konfiguracji wyb鏎u pantograf闚
pantographselectprevious shift o // aktywacja poprzedniej konfiguracji wyb鏎u pantograf闚
pantographtoggleselected shift ctrl o // prze章czenie stanu wybranych pantograf闚
heatingtoggle h // prze章czenie stanu ogrzewania poci鉚u
lightspresetactivatenext shift t // aktywacja kolejnej konfiguracji reflektor闚
lightspresetactivateprevious t // aktywacja poprzedniej konfiguracji reflektor闚
headlighttoggleleft y // prze章czenie stanu lewego reflektora
headlighttoggleright i // prze章czenie stanu prawego reflektora
headlighttoggleupper u // prze章czenie stanu gornego reflektora
redmarkertoggleleft shift y // prze章czenie lewego 菏iat豉 czerwonego
redmarkertoggleright shift i // prze章czenie prawego 菏iat豉 czerwonego
headlighttogglerearleft ctrl y // prze章czenie stanu lewego tylnego reflektora
headlighttogglerearright ctrl i // prze章czenie stanu prawego tylnego reflektora
headlighttogglerearupper ctrl u // prze章czenie stanu gornego tylnego reflektora
redmarkertogglerearleft ctrl shift y // prze章czenie lewego tylnego 菏iat豉 czerwonego
redmarkertogglerearright ctrl shift i // prze章czenie prawego tylnego 菏iat豉 czerwonego
redmarkerstoggle shift e // prze章czenie swiatel ko鎍a poci鉚u
endsignalstoggle e // prze章czenie tabliczek ko鎍a poci鉚u
headlightsdimtoggle ctrl l // prze章czenie przyciemnienia reflektor闚
motorconnectorsopen l // roz章czenie stycznik闚 liniowych
motorconnectorsclose ctrl shift l // za章czenie stycznik闚 liniowych
motordisconnect ctrl e // od章czenie uszkodzonych silnik闚
interiorlighttoggle ' // prze章czenie stanu o菏ietlenia kabiny
interiorlightdimtoggle ctrl ' // prze章czenie przyciemnienia o菏ietlenia kabiny
compartmentlightstoggle ctrl ; // prze章czenie stanu o菏ietlenia przedzia堯w
instrumentlighttoggle ; // prze章czenie stanu o菏ietlenia przyrzadow
dashboardlighttoggle shift ; // prze章czenie stanu o菏ietlenia pulpitu
timetablelighttoggle shift ' // prze章czenie stanu o菏ietlenia rozkladu jazdy
generictoggle0 0 // prze章czenie elementu universalnego nr 0
generictoggle1 1 // prze章czenie elementu universalnego nr 1
generictoggle2 2 // prze章czenie elementu universalnego nr 2
generictoggle3 3 // prze章czenie elementu universalnego nr 3
generictoggle4 4 // prze章czenie elementu universalnego nr 4
generictoggle5 5 // prze章czenie elementu universalnego nr 5
generictoggle6 6 // prze章czenie elementu universalnego nr 6
generictoggle7 7 // prze章czenie elementu universalnego nr 7
generictoggle8 8 // prze章czenie elementu universalnego nr 8
generictoggle9 9 // prze章czenie elementu universalnego nr 9
batterytoggle j // prze章czenie stanu akumulator闚
cabactivationtoggle ctrl j // prze章czenie stanu aktywacji kabiny
motorblowerstogglefront shift n // prze章czenie stanu wentylator闚 przednich motorow trakcyjnych
motorblowerstogglerear shift m // prze章czenie stanu wentylator闚 tylnych motorow trakcyjnych
motorblowersdisableall ctrl m // wy章czenie wszystkich wentylator闚 motorow trakcyjnych
coolingfanstoggle none // prze章czenie stanu wentylator闚 opor闚 rozruchowych
tempomattoggle none // prze章czenie stanu tempomatu
springbraketoggle shift num_enter // prze章czenie stanu hamulca spr篹ynowego
springbrakeshutofftoggle ctrl shift \ // prze章czenie stanu kurka odcinajacego hamulca spr篹ynowego
springbrakerelease none // luzowanie hamulca spr篹ynowego
distancecounteractivate ctrl num_enter // za章czenie miernika przejechanego dystansu
speedcontrolincrease none // zwi瘯szenie pr璠ko𦣇i utrzymywanej przez tempomat
speedcontroldecrease none // zmniejszenie pr璠ko𦣇i utrzymywanej przez tempomat
speedcontrolpowerincrease none // zwi瘯szenie maksymalnego poziomu mocy dost瘼nej dla tempomatu
speedcontrolpowerdecrease none // zmniejszenie maksymalnego poziomu mocy dost瘼nej dla tempomatu
speedcontrolbutton0 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 0
speedcontrolbutton1 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 1
speedcontrolbutton2 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 2
speedcontrolbutton3 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 3
speedcontrolbutton4 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 4
speedcontrolbutton5 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 5
speedcontrolbutton6 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 6
speedcontrolbutton7 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 7
speedcontrolbutton8 none // ustalenie dla tempomatu zdefiniowanej pr璠ko𦣇i nr 8
speedcontrolbutton9 none // ustalenie dla tempomatu zdefiniowanej predko𦣇i nr 9
universalbrakebutton1 none // aktywacja zdefiniowanych element闚 hamulca zespolonego nr 1
universalbrakebutton2 none // aktywacja zdefiniowanych element闚 hamulca zespolonego nr 2
universalbrakebutton3 none // aktywacja zdefiniowanych element闚 hamulca zespolonego nr 3
universalrelayreset1 none // odblokowanie zdefiniowanych przeka俲ik闚 nr 1
universalrelayreset2 none // odblokowanie zdefiniowanych przeka俲ik闚 nr 2
universalrelayreset3 none // odblokowanie zdefiniowanych przeka俲ik闚 nr 3

View File

@@ -14,6 +14,9 @@
<!-- General view -->
<String key="Sceneries">Sceneries</String>
<String key="Vehicles">Vehicles</String>
<String key="ShowAiVehicles">AI vehicles</String>
<String key="ShowDrivableOnly">Drivable only</String>
<String key="ShowArchival">Archival sceneries</String>
<String key="ScenarioDescription">Scenario description</String>
<String key="SceneryDescription">Scenery description</String>
<String key="TabDescription">Description</String>
@@ -163,6 +166,12 @@
<String key="LoadCount">Amount</String>
<String key="LoadHint">Set an amount above 0 to load the vehicle.</String>
<String key="WagonNumber">Wagon number</String>
<String key="LoadCopyPrev">Copy load from previous vehicle</String>
<String key="LoadMax">Maximum possible load</String>
<String key="ConsistLoadTools">Whole-consist load</String>
<String key="ConsistRandomType">Random load</String>
<String key="ConsistMaxAmount">Maximum amount</String>
<String key="ConsistRandomAmount">Random amount</String>
<String key="Set">Set</String>
<String key="Length">Length</String>
<String key="Mass">Mass</String>
@@ -175,6 +184,16 @@
<String key="TipAddVehicle">Add this vehicle to the consist</String>
<String key="TipAddUnit">Add the whole unit to the consist</String>
<!-- Consist warehouse / clipboard (right-click menu) -->
<String key="PresetSave">Save consist</String>
<String key="PresetLoad">Load consist from warehouse</String>
<String key="PresetEmpty">(warehouse empty)</String>
<String key="PresetDelete">Remove from warehouse</String>
<String key="PresetNamePrompt">Consist name</String>
<String key="ConsistCopy">Copy consist to clipboard</String>
<String key="ConsistPaste">Paste consist from clipboard</String>
<String key="ConsistRemoveAll">Remove all vehicles</String>
<!-- Loading splash -->
<String key="LoadingVehicles">Loading vehicle database</String>
<String key="LoadingSceneries">Loading sceneries</String>
@@ -285,6 +304,26 @@
<String key="LargeThumbnails">Large thumbnails</String>
<String key="AutoExpandSceneryTree">Automatically expand scenery tree</String>
<!-- Controls / key bindings -->
<String key="Controls">Controls</String>
<String key="BindingsHint">Click a binding, then press the key (hold Shift / Ctrl for a combination):</String>
<String key="RestoreDefaults">Restore defaults</String>
<String key="PressKey">Press a key…</String>
<String key="ClearBinding">Clear binding</String>
<String key="ConflictTooltip">Conflict: this shortcut is used by more than one command</String>
<String key="KbUnassigned">Unassigned</String>
<String key="KbAssigned">Assigned</String>
<String key="KbShift">With Shift</String>
<String key="KbCtrl">With Ctrl</String>
<String key="KbReserved">Reserved by the simulator</String>
<String key="BindKeyTitle">Key:</String>
<String key="BindKeyHint">Assign a command to each modifier combination for this key.</String>
<String key="BindNone">— none —</String>
<String key="BindNoMod">No modifier</String>
<String key="BindShift">Shift</String>
<String key="BindCtrl">Ctrl</String>
<String key="BindShiftCtrl">Shift + Ctrl</String>
<!-- Settings actions -->
<String key="Save">Save</String>
<String key="Reset">Reset</String>

View File

@@ -14,6 +14,9 @@
<!-- Widok ogolny -->
<String key="Sceneries">Scenerie</String>
<String key="Vehicles">Pojazdy</String>
<String key="ShowAiVehicles">Pojazdy AI</String>
<String key="ShowDrivableOnly">Tylko do prowadzenia</String>
<String key="ShowArchival">Scenerie archiwalne</String>
<String key="ScenarioDescription">Opis scenariusza</String>
<String key="SceneryDescription">Opis scenerii</String>
<String key="TabDescription">Opis</String>
@@ -163,6 +166,12 @@
<String key="LoadCount">Ilość</String>
<String key="LoadHint">Ustaw ilość większą od 0, aby załadować pojazd.</String>
<String key="WagonNumber">Numer wagonu</String>
<String key="LoadCopyPrev">Kopiuj ładunek z poprzedniego pojazdu</String>
<String key="LoadMax">Maksymalny możliwy ładunek</String>
<String key="ConsistLoadTools">Ładunek całego składu</String>
<String key="ConsistRandomType">Losowy ładunek</String>
<String key="ConsistMaxAmount">Maksymalna ilość</String>
<String key="ConsistRandomAmount">Losowa ilość</String>
<String key="Set">Ustaw</String>
<String key="Length">Długość</String>
<String key="Mass">Masa</String>
@@ -175,6 +184,16 @@
<String key="TipAddVehicle">Dodaj ten pojazd do składu</String>
<String key="TipAddUnit">Dodaj cały zespół do składu</String>
<!-- Magazyn składów / schowek (menu pod prawym przyciskiem) -->
<String key="PresetSave">Zapisz zestawienie</String>
<String key="PresetLoad">Wczytaj zestawienie z magazynu</String>
<String key="PresetEmpty">(magazyn pusty)</String>
<String key="PresetDelete">Usuń z magazynu</String>
<String key="PresetNamePrompt">Nazwa zestawienia</String>
<String key="ConsistCopy">Skopiuj skład do schowka</String>
<String key="ConsistPaste">Wklej skład ze schowka</String>
<String key="ConsistRemoveAll">Usuń wszystkie pojazdy ze składu</String>
<!-- Ekran ładowania -->
<String key="LoadingVehicles">Wczytywanie bazy pojazdów</String>
<String key="LoadingSceneries">Wczytywanie scenerii</String>
@@ -285,6 +304,26 @@
<String key="LargeThumbnails">Duże miniaturki</String>
<String key="AutoExpandSceneryTree">Automatycznie rozwijanie drzewka scenerii</String>
<!-- Sterowanie / przypisania klawiszy -->
<String key="Controls">Sterowanie</String>
<String key="BindingsHint">Kliknij przypisanie i naciśnij klawisz (przytrzymaj Shift / Ctrl dla kombinacji):</String>
<String key="RestoreDefaults">Przywróć domyślne</String>
<String key="PressKey">Naciśnij klawisz…</String>
<String key="ClearBinding">Wyczyść przypisanie</String>
<String key="ConflictTooltip">Konflikt: ten skrót jest używany przez więcej niż jedną komendę</String>
<String key="KbUnassigned">Nieprzypisany</String>
<String key="KbAssigned">Przypisany</String>
<String key="KbShift">Z klawiszem Shift</String>
<String key="KbCtrl">Z klawiszem Ctrl</String>
<String key="KbReserved">Zarezerwowany przez symulator</String>
<String key="BindKeyTitle">Klawisz:</String>
<String key="BindKeyHint">Przypisz komendę do każdej kombinacji modyfikatorów dla tego klawisza.</String>
<String key="BindNone">— brak —</String>
<String key="BindNoMod">Bez modyfikatora</String>
<String key="BindShift">Shift</String>
<String key="BindCtrl">Ctrl</String>
<String key="BindShiftCtrl">Shift + Ctrl</String>
<!-- Settings actions -->
<String key="Save">Zapisz</String>
<String key="Reset">Przywróć</String>