mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-07-17 17:09:19 +02:00
Keybinds editor
This commit is contained in:
@@ -26,6 +26,9 @@ public partial class App : Application
|
|||||||
// fall back to the system culture only when the file has no lang entry.
|
// fall back to the system culture only when the file has no lang entry.
|
||||||
Settings.Instance.Load();
|
Settings.Instance.Load();
|
||||||
|
|
||||||
|
// Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
|
||||||
|
KeyboardConfig.Instance.Load();
|
||||||
|
|
||||||
CultureInfo ci = CultureInfo.InstalledUICulture ;
|
CultureInfo ci = CultureInfo.InstalledUICulture ;
|
||||||
|
|
||||||
var langName = Settings.Instance.LanguageWasSet
|
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).
|
// Code page 1250 is needed to parse scenery files (done during load).
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
|
||||||
// Persist settings when the launcher is closing.
|
// Persist settings when the launcher is closing. Key bindings are only
|
||||||
desktop.ShutdownRequested += (_, _) => Settings.Instance.CaptureAndSave();
|
// 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();
|
var splash = new SplashWindow();
|
||||||
desktop.MainWindow = splash;
|
desktop.MainWindow = splash;
|
||||||
|
|||||||
250
StarterNG/Classes/KeyMap.cs
Normal file
250
StarterNG/Classes/KeyMap.cs
Normal 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"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
259
StarterNG/Classes/KeyboardConfig.cs
Normal file
259
StarterNG/Classes/KeyboardConfig.cs
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
StarterNG/Classes/PresetStore.cs
Normal file
146
StarterNG/Classes/PresetStore.cs
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ public class Scenery
|
|||||||
public string Description; // //$d - scenery description
|
public string Description; // //$d - scenery description
|
||||||
public string ImageName; // //$i - main-window image (scenery thumbnail)
|
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
|
// Weather / environment. Like the original Starter, these are editable and are
|
||||||
// written into the scenery's "config" block on launch (see RewriteWeather).
|
// written into the scenery's "config" block on launch (see RewriteWeather).
|
||||||
// Defaults mirror the original (15 °C, day 0, 10:30, clear sky).
|
// 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.
|
// //$d may appear on several lines; each is one line of the description.
|
||||||
this.Description = MatchAllDirectives(content, "d");
|
this.Description = MatchAllDirectives(content, "d");
|
||||||
this.ImageName = MatchDirective(content, "i");
|
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
|
// weather/environment is read from the raw text before trainset blocks
|
||||||
// are stripped out below (the atmosphere commands live outside trainsets)
|
// are stripped out below (the atmosphere commands live outside trainsets)
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ public sealed class Settings
|
|||||||
public bool LargeThumbnails; // starter.largethumbnails
|
public bool LargeThumbnails; // starter.largethumbnails
|
||||||
public bool AutoExpandSceneryTree; // starter.expandtree
|
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>
|
/// <summary>gfxrenderer tokens in the order shown by the render-engine combo.</summary>
|
||||||
public static readonly string[] RenderEngines =
|
public static readonly string[] RenderEngines =
|
||||||
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
|
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
|
||||||
@@ -357,6 +362,9 @@ public sealed class Settings
|
|||||||
AutoCloseStarter = c.GetBool("starter.autoclose", false);
|
AutoCloseStarter = c.GetBool("starter.autoclose", false);
|
||||||
LargeThumbnails = c.GetBool("starter.largethumbnails", false);
|
LargeThumbnails = c.GetBool("starter.largethumbnails", false);
|
||||||
AutoExpandSceneryTree = c.GetBool("starter.expandtree", 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 ───────────────────────────────────────────────────
|
// ── fields → config ───────────────────────────────────────────────────
|
||||||
@@ -444,6 +452,9 @@ public sealed class Settings
|
|||||||
c.SetBool("starter.autoclose", AutoCloseStarter);
|
c.SetBool("starter.autoclose", AutoCloseStarter);
|
||||||
c.SetBool("starter.largethumbnails", LargeThumbnails);
|
c.SetBool("starter.largethumbnails", LargeThumbnails);
|
||||||
c.SetBool("starter.expandtree", AutoExpandSceneryTree);
|
c.SetBool("starter.expandtree", AutoExpandSceneryTree);
|
||||||
|
c.SetBool("starter.show.ai", ShowAiVehicles);
|
||||||
|
c.SetBool("starter.drivableonly", DrivableOnly);
|
||||||
|
c.SetBool("starter.show.archival", ShowArchivalSceneries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── small helpers ──────────────────────────────────────────────────────
|
// ── small helpers ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -30,9 +30,13 @@ public class Trainset
|
|||||||
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
|
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
|
||||||
public bool Parsed;
|
public bool Parsed;
|
||||||
|
|
||||||
|
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
|
||||||
|
public bool Decor;
|
||||||
|
|
||||||
public Trainset(string trainsetEntry)
|
public Trainset(string trainsetEntry)
|
||||||
{
|
{
|
||||||
RawEntry = trainsetEntry;
|
RawEntry = trainsetEntry;
|
||||||
|
Decor = Regex.IsMatch(trainsetEntry, @"//\$decor\b", RegexOptions.IgnoreCase);
|
||||||
Vehicles = new List<Dynamic>();
|
Vehicles = new List<Dynamic>();
|
||||||
Description = "";
|
Description = "";
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<AssemblyVersion>1.1.0.3</AssemblyVersion>
|
<AssemblyVersion>1.2.0.4</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.3</FileVersion>
|
<FileVersion>1.2.0.4</FileVersion>
|
||||||
<AssemblyName>Starter</AssemblyName>
|
<AssemblyName>Starter</AssemblyName>
|
||||||
<Company>eu07.pl</Company>
|
<Company>eu07.pl</Company>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -59,5 +59,11 @@
|
|||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<Link>startercfg\lang\%(Filename)%(Extension)</Link>
|
<Link>startercfg\lang\%(Filename)%(Extension)</Link>
|
||||||
</Content>
|
</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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
193
StarterNG/Views/ConsistContextMenu.cs
Normal file
193
StarterNG/Views/ConsistContextMenu.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,8 @@
|
|||||||
<TextBlock Name="mapSceneryLabel" DockPanel.Dock="Top" FontSize="12"
|
<TextBlock Name="mapSceneryLabel" DockPanel.Dock="Top" FontSize="12"
|
||||||
Opacity="0.8" Margin="0,0,0,6" TextTrimming="CharacterEllipsis" />
|
Opacity="0.8" Margin="0,0,0,6" TextTrimming="CharacterEllipsis" />
|
||||||
<ListBox Name="sceneryConsistList" Classes="Compact"
|
<ListBox Name="sceneryConsistList" Classes="Compact"
|
||||||
SelectionChanged="SceneryConsistList_OnSelectionChanged" />
|
SelectionChanged="SceneryConsistList_OnSelectionChanged"
|
||||||
|
KeyDown="SceneryConsistList_OnKeyDown" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</HeaderedContentControl>
|
</HeaderedContentControl>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public partial class Depot : UserControl
|
|||||||
private readonly Cursor _hand = new(StandardCursorType.Hand);
|
private readonly Cursor _hand = new(StandardCursorType.Hand);
|
||||||
private int _nameCounter;
|
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
|
// suppresses combo SelectionChanged handlers while we populate them
|
||||||
private bool _suppress;
|
private bool _suppress;
|
||||||
|
|
||||||
@@ -557,9 +560,16 @@ public partial class Depot : UserControl
|
|||||||
|
|
||||||
var entry = new ListBoxItem
|
var entry = new ListBoxItem
|
||||||
{
|
{
|
||||||
Content = ConsistText(trainset),
|
Content = ConsistLabel(trainset),
|
||||||
Tag = 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);
|
sceneryConsistList.Items.Add(entry);
|
||||||
|
|
||||||
if (ReferenceEquals(trainset, AppState.Instance.CurrentTrainset))
|
if (ReferenceEquals(trainset, AppState.Instance.CurrentTrainset))
|
||||||
@@ -581,8 +591,43 @@ public partial class Depot : UserControl
|
|||||||
LoadTrainset(trainset);
|
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).
|
// 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));
|
string text = string.Join(" + ", trainset.Vehicles.Select(v => v.SkinFile));
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
@@ -928,12 +973,9 @@ public partial class Depot : UserControl
|
|||||||
// highlight this vehicle's entry there
|
// highlight this vehicle's entry there
|
||||||
SelectInBrowser(item.Cars[0]);
|
SelectInBrowser(item.Cars[0]);
|
||||||
};
|
};
|
||||||
// right-click a vehicle to set its wagon number (node "#<n>" suffix)
|
// right-click a vehicle: wagon number + load actions (copy load from the
|
||||||
card.ContextRequested += (_, args) =>
|
// previous vehicle, fill to the maximum possible load)
|
||||||
{
|
card.ContextMenu = BuildCardMenu(card, item);
|
||||||
ShowWagonNumberFlyout(card, item.Cars[0]);
|
|
||||||
args.Handled = true;
|
|
||||||
};
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1170,9 @@ public partial class Depot : UserControl
|
|||||||
loadsPanel.Children.Clear();
|
loadsPanel.Children.Clear();
|
||||||
damagePanel.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)
|
if (_selected is null)
|
||||||
{
|
{
|
||||||
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
|
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
|
||||||
@@ -1141,6 +1186,179 @@ public partial class Depot : UserControl
|
|||||||
BuildDamageEditor(_selected);
|
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()
|
private static TextBlock DetailHint(string text) => new()
|
||||||
{
|
{
|
||||||
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
|
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
|
||||||
|
|||||||
@@ -21,8 +21,15 @@
|
|||||||
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
|
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
|
||||||
Classes="Compact"
|
Classes="Compact"
|
||||||
Header="{Binding [Sceneries], Source={x:Static starterNg:App.Loc}}">
|
Header="{Binding [Sceneries], Source={x:Static starterNg:App.Loc}}">
|
||||||
<TreeView Name="sceneryList" Classes="Compact"
|
<DockPanel LastChildFill="True">
|
||||||
SelectionChanged="SceneryList_OnSelectionChanged" />
|
<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>
|
</HeaderedContentControl>
|
||||||
|
|
||||||
<!-- Centre: trainsets list (compact, fills) + description/weather/timetable tabs -->
|
<!-- Centre: trainsets list (compact, fills) + description/weather/timetable tabs -->
|
||||||
@@ -34,8 +41,19 @@
|
|||||||
|
|
||||||
<HeaderedContentControl Grid.Row="0" Theme="{StaticResource GroupBox}"
|
<HeaderedContentControl Grid.Row="0" Theme="{StaticResource GroupBox}"
|
||||||
Header="{Binding [Vehicles], Source={x:Static starterNg:App.Loc}}">
|
Header="{Binding [Vehicles], Source={x:Static starterNg:App.Loc}}">
|
||||||
<ListBox Name="vehicleList" Classes="Compact"
|
<DockPanel LastChildFill="True">
|
||||||
SelectionChanged="VehicleList_OnSelectionChanged" />
|
<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>
|
</HeaderedContentControl>
|
||||||
|
|
||||||
<TabControl Grid.Row="1">
|
<TabControl Grid.Row="1">
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ public partial class Scenarios : UserControl
|
|||||||
// stored Day stays 0 so the scenery keeps using the live date.
|
// stored Day stays 0 so the scenery keeps using the live date.
|
||||||
private bool _todaySeason;
|
private bool _todaySeason;
|
||||||
|
|
||||||
|
// suppresses the list-filter handlers while their state is restored at startup
|
||||||
|
private bool _loadingFilters;
|
||||||
|
|
||||||
public Scenarios()
|
public Scenarios()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -34,61 +37,15 @@ public partial class Scenarios : UserControl
|
|||||||
// Sceneries are parsed once at startup (behind the splash).
|
// Sceneries are parsed once at startup (behind the splash).
|
||||||
Sceneries = GameData.Instance.Sceneries;
|
Sceneries = GameData.Instance.Sceneries;
|
||||||
|
|
||||||
var groupNodes = new Dictionary<string, TreeViewItem>();
|
// Restore the persisted list filters before building the tree/list. Guarded
|
||||||
var topLevel = new List<TreeViewItem>();
|
// 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++)
|
BuildSceneryTree();
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
// refresh the consist preview when the view is shown again (e.g. after
|
// refresh the consist preview when the view is shown again (e.g. after
|
||||||
// editing the consist in the depot). Switching tabs only toggles IsVisible
|
// editing the consist in the depot). Switching tabs only toggles IsVisible
|
||||||
@@ -110,6 +67,139 @@ public partial class Scenarios : UserControl
|
|||||||
RefreshSelectedConsist();
|
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
|
// 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.
|
// current vehicles, so a consist edited in the depot is reflected here too.
|
||||||
// Selection and order are preserved (only the text is updated).
|
// Selection and order are preserved (only the text is updated).
|
||||||
@@ -144,8 +234,6 @@ public partial class Scenarios : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Scenery selectedScn = Sceneries[tag];
|
Scenery selectedScn = Sceneries[tag];
|
||||||
|
|
||||||
vehicleList.Items.Clear();
|
|
||||||
|
|
||||||
// scenery-level info (distinct from the per-consist scenario description)
|
// scenery-level info (distinct from the per-consist scenario description)
|
||||||
ShowSceneryInfo(selectedScn);
|
ShowSceneryInfo(selectedScn);
|
||||||
@@ -155,25 +243,8 @@ public partial class Scenarios : UserControl
|
|||||||
missionDescription.Text = "";
|
missionDescription.Text = "";
|
||||||
timetableContent.Text = App.Loc["NoTimetable"];
|
timetableContent.Text = App.Loc["NoTimetable"];
|
||||||
|
|
||||||
// add all trainsets to list
|
// add the (filtered) trainsets to the Vehicles list
|
||||||
for (int i = 0; i < selectedScn.Trainsets.Count; i++)
|
PopulateVehicleList(selectedScn);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
|
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
|
||||||
|
|||||||
@@ -68,6 +68,14 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</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 -->
|
<!-- Others -->
|
||||||
<TabItem Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
|
<TabItem Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
using Avalonia.Markup.Xaml.Styling;
|
using Avalonia.Markup.Xaml.Styling;
|
||||||
|
using Avalonia.Media;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
|
// Disambiguate from Avalonia.Input.KeyBinding (a command-input binding) imported above.
|
||||||
|
using KeyBinding = StarterNG.Classes.KeyBinding;
|
||||||
|
|
||||||
namespace StarterNG.Views;
|
namespace StarterNG.Views;
|
||||||
|
|
||||||
@@ -35,6 +42,13 @@ public partial class Settings : UserControl
|
|||||||
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
|
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
|
||||||
|
|
||||||
ApplyToUi();
|
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 ──────────────────────────────────────
|
// ── Settings instance → controls ──────────────────────────────────────
|
||||||
@@ -209,6 +223,7 @@ public partial class Settings : UserControl
|
|||||||
{
|
{
|
||||||
ReadFromUi();
|
ReadFromUi();
|
||||||
StarterNG.Classes.Settings.Instance.Save();
|
StarterNG.Classes.Settings.Instance.Save();
|
||||||
|
KeyboardConfig.Instance.Save();
|
||||||
if (SaveStatus is not null)
|
if (SaveStatus is not null)
|
||||||
SaveStatus.Text = App.Loc["SettingsSaved"];
|
SaveStatus.Text = App.Loc["SettingsSaved"];
|
||||||
}
|
}
|
||||||
@@ -217,6 +232,10 @@ public partial class Settings : UserControl
|
|||||||
{
|
{
|
||||||
StarterNG.Classes.Settings.Instance.Load();
|
StarterNG.Classes.Settings.Instance.Load();
|
||||||
ApplyToUi();
|
ApplyToUi();
|
||||||
|
CancelCapture();
|
||||||
|
KeyboardConfig.Instance.Load();
|
||||||
|
RebuildBindingList();
|
||||||
|
RebuildKeyboard();
|
||||||
if (SaveStatus is not null)
|
if (SaveStatus is not null)
|
||||||
SaveStatus.Text = string.Empty;
|
SaveStatus.Text = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -311,4 +330,598 @@ public partial class Settings : UserControl
|
|||||||
return i + 1;
|
return i + 1;
|
||||||
return 4; // default → 8x
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
175
StarterNG/startercfg/eu07_input-keyboard.ini
Normal file
175
StarterNG/startercfg/eu07_input-keyboard.ini
Normal 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
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
<!-- General view -->
|
<!-- General view -->
|
||||||
<String key="Sceneries">Sceneries</String>
|
<String key="Sceneries">Sceneries</String>
|
||||||
<String key="Vehicles">Vehicles</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="ScenarioDescription">Scenario description</String>
|
||||||
<String key="SceneryDescription">Scenery description</String>
|
<String key="SceneryDescription">Scenery description</String>
|
||||||
<String key="TabDescription">Description</String>
|
<String key="TabDescription">Description</String>
|
||||||
@@ -163,6 +166,12 @@
|
|||||||
<String key="LoadCount">Amount</String>
|
<String key="LoadCount">Amount</String>
|
||||||
<String key="LoadHint">Set an amount above 0 to load the vehicle.</String>
|
<String key="LoadHint">Set an amount above 0 to load the vehicle.</String>
|
||||||
<String key="WagonNumber">Wagon number</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="Set">Set</String>
|
||||||
<String key="Length">Length</String>
|
<String key="Length">Length</String>
|
||||||
<String key="Mass">Mass</String>
|
<String key="Mass">Mass</String>
|
||||||
@@ -175,6 +184,16 @@
|
|||||||
<String key="TipAddVehicle">Add this vehicle to the consist</String>
|
<String key="TipAddVehicle">Add this vehicle to the consist</String>
|
||||||
<String key="TipAddUnit">Add the whole unit 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 -->
|
<!-- Loading splash -->
|
||||||
<String key="LoadingVehicles">Loading vehicle database</String>
|
<String key="LoadingVehicles">Loading vehicle database</String>
|
||||||
<String key="LoadingSceneries">Loading sceneries</String>
|
<String key="LoadingSceneries">Loading sceneries</String>
|
||||||
@@ -285,6 +304,26 @@
|
|||||||
<String key="LargeThumbnails">Large thumbnails</String>
|
<String key="LargeThumbnails">Large thumbnails</String>
|
||||||
<String key="AutoExpandSceneryTree">Automatically expand scenery tree</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 -->
|
<!-- Settings actions -->
|
||||||
<String key="Save">Save</String>
|
<String key="Save">Save</String>
|
||||||
<String key="Reset">Reset</String>
|
<String key="Reset">Reset</String>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<!-- Widok ogolny -->
|
<!-- Widok ogolny -->
|
||||||
<String key="Sceneries">Scenerie</String>
|
<String key="Sceneries">Scenerie</String>
|
||||||
<String key="Vehicles">Pojazdy</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="ScenarioDescription">Opis scenariusza</String>
|
||||||
<String key="SceneryDescription">Opis scenerii</String>
|
<String key="SceneryDescription">Opis scenerii</String>
|
||||||
<String key="TabDescription">Opis</String>
|
<String key="TabDescription">Opis</String>
|
||||||
@@ -163,6 +166,12 @@
|
|||||||
<String key="LoadCount">Ilość</String>
|
<String key="LoadCount">Ilość</String>
|
||||||
<String key="LoadHint">Ustaw ilość większą od 0, aby załadować pojazd.</String>
|
<String key="LoadHint">Ustaw ilość większą od 0, aby załadować pojazd.</String>
|
||||||
<String key="WagonNumber">Numer wagonu</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="Set">Ustaw</String>
|
||||||
<String key="Length">Długość</String>
|
<String key="Length">Długość</String>
|
||||||
<String key="Mass">Masa</String>
|
<String key="Mass">Masa</String>
|
||||||
@@ -175,6 +184,16 @@
|
|||||||
<String key="TipAddVehicle">Dodaj ten pojazd do składu</String>
|
<String key="TipAddVehicle">Dodaj ten pojazd do składu</String>
|
||||||
<String key="TipAddUnit">Dodaj cały zespół 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 -->
|
<!-- Ekran ładowania -->
|
||||||
<String key="LoadingVehicles">Wczytywanie bazy pojazdów</String>
|
<String key="LoadingVehicles">Wczytywanie bazy pojazdów</String>
|
||||||
<String key="LoadingSceneries">Wczytywanie scenerii</String>
|
<String key="LoadingSceneries">Wczytywanie scenerii</String>
|
||||||
@@ -285,6 +304,26 @@
|
|||||||
<String key="LargeThumbnails">Duże miniaturki</String>
|
<String key="LargeThumbnails">Duże miniaturki</String>
|
||||||
<String key="AutoExpandSceneryTree">Automatycznie rozwijanie drzewka scenerii</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 -->
|
<!-- Settings actions -->
|
||||||
<String key="Save">Zapisz</String>
|
<String key="Save">Zapisz</String>
|
||||||
<String key="Reset">Przywróć</String>
|
<String key="Reset">Przywróć</String>
|
||||||
|
|||||||
Reference in New Issue
Block a user