mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-03 21:02:06 +01:00
Keybinds editor
This commit is contained in:
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"
|
||||
Opacity="0.8" Margin="0,0,0,6" TextTrimming="CharacterEllipsis" />
|
||||
<ListBox Name="sceneryConsistList" Classes="Compact"
|
||||
SelectionChanged="SceneryConsistList_OnSelectionChanged" />
|
||||
SelectionChanged="SceneryConsistList_OnSelectionChanged"
|
||||
KeyDown="SceneryConsistList_OnKeyDown" />
|
||||
</DockPanel>
|
||||
</HeaderedContentControl>
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ public partial class Depot : UserControl
|
||||
private readonly Cursor _hand = new(StandardCursorType.Hand);
|
||||
private int _nameCounter;
|
||||
|
||||
// shared RNG for the "random load / random amount" consist tools
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
// suppresses combo SelectionChanged handlers while we populate them
|
||||
private bool _suppress;
|
||||
|
||||
@@ -557,9 +560,16 @@ public partial class Depot : UserControl
|
||||
|
||||
var entry = new ListBoxItem
|
||||
{
|
||||
Content = ConsistText(trainset),
|
||||
Content = ConsistLabel(trainset),
|
||||
Tag = trainset
|
||||
};
|
||||
// right-click: warehouse save/load, clipboard copy/paste, clear-all
|
||||
var ts = trainset;
|
||||
entry.ContextMenu = ConsistContextMenu.Build(
|
||||
entry,
|
||||
() => ts,
|
||||
vehicles => ApplyConsist(ts, vehicles),
|
||||
() => RemoveAllVehicles(ts));
|
||||
sceneryConsistList.Items.Add(entry);
|
||||
|
||||
if (ReferenceEquals(trainset, AppState.Instance.CurrentTrainset))
|
||||
@@ -581,8 +591,43 @@ public partial class Depot : UserControl
|
||||
LoadTrainset(trainset);
|
||||
}
|
||||
|
||||
// Shift+Delete clears the selected scenery consist (same as the context-menu
|
||||
// "remove all vehicles" entry).
|
||||
private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
||||
sceneryConsistList.SelectedItem is ListBoxItem { Tag: Trainset trainset } &&
|
||||
trainset.Vehicles.Count > 0)
|
||||
{
|
||||
RemoveAllVehicles(trainset);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces a scenery consist's vehicles (from a warehouse preset or a pasted
|
||||
// clipboard consist), keeping the consist's map slot (name/track/offset), then
|
||||
// loads it into the editor and refreshes the list.
|
||||
private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
|
||||
{
|
||||
target.Vehicles = vehicles;
|
||||
AppState.Instance.CurrentScenery = _listScenery;
|
||||
AppState.Instance.CurrentTrainset = target;
|
||||
LoadTrainset(target);
|
||||
PopulateSceneryConsists();
|
||||
}
|
||||
|
||||
// Clears every vehicle from a scenery consist.
|
||||
private void RemoveAllVehicles(Trainset target)
|
||||
{
|
||||
target.Vehicles = new List<Dynamic>();
|
||||
AppState.Instance.CurrentScenery = _listScenery;
|
||||
AppState.Instance.CurrentTrainset = target;
|
||||
LoadTrainset(target);
|
||||
PopulateSceneryConsists();
|
||||
}
|
||||
|
||||
// Plain-text label for a scenery consist (no thumbnails).
|
||||
private static string ConsistText(Trainset trainset)
|
||||
private static string ConsistLabel(Trainset trainset)
|
||||
{
|
||||
string text = string.Join(" + ", trainset.Vehicles.Select(v => v.SkinFile));
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
@@ -928,12 +973,9 @@ public partial class Depot : UserControl
|
||||
// highlight this vehicle's entry there
|
||||
SelectInBrowser(item.Cars[0]);
|
||||
};
|
||||
// right-click a vehicle to set its wagon number (node "#<n>" suffix)
|
||||
card.ContextRequested += (_, args) =>
|
||||
{
|
||||
ShowWagonNumberFlyout(card, item.Cars[0]);
|
||||
args.Handled = true;
|
||||
};
|
||||
// right-click a vehicle: wagon number + load actions (copy load from the
|
||||
// previous vehicle, fill to the maximum possible load)
|
||||
card.ContextMenu = BuildCardMenu(card, item);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -1128,6 +1170,9 @@ public partial class Depot : UserControl
|
||||
loadsPanel.Children.Clear();
|
||||
damagePanel.Children.Clear();
|
||||
|
||||
// whole-consist load tools sit at the top of the Loads tab, always available
|
||||
loadsPanel.Children.Add(BuildConsistLoadTools());
|
||||
|
||||
if (_selected is null)
|
||||
{
|
||||
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
|
||||
@@ -1141,6 +1186,179 @@ public partial class Depot : UserControl
|
||||
BuildDamageEditor(_selected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- load tools
|
||||
|
||||
// The per-vehicle right-click menu: wagon number plus the two load shortcuts.
|
||||
private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
menu.Opening += (_, _) =>
|
||||
{
|
||||
menu.Items.Clear();
|
||||
|
||||
var num = new MenuItem { Header = App.Loc["WagonNumber"] };
|
||||
num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]);
|
||||
menu.Items.Add(num);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
var copyPrev = new MenuItem
|
||||
{
|
||||
Header = App.Loc["LoadCopyPrev"],
|
||||
IsEnabled = _consist.IndexOf(item) > 0
|
||||
};
|
||||
copyPrev.Click += (_, _) => CopyLoadFromPrevious(item);
|
||||
menu.Items.Add(copyPrev);
|
||||
|
||||
var max = new MenuItem
|
||||
{
|
||||
Header = App.Loc["LoadMax"],
|
||||
IsEnabled = IsLoadable(item)
|
||||
};
|
||||
max.Click += (_, _) => SetUnitMaxLoad(item);
|
||||
menu.Items.Add(max);
|
||||
};
|
||||
return menu;
|
||||
}
|
||||
|
||||
// Button bar that applies a load operation to every vehicle in the consist.
|
||||
private Control BuildConsistLoadTools()
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = App.Loc["ConsistLoadTools"], FontWeight = FontWeight.Bold, FontSize = 12
|
||||
});
|
||||
|
||||
var row = new WrapPanel { Orientation = Orientation.Horizontal };
|
||||
row.Children.Add(LoadToolButton(App.Loc["ConsistRandomType"], RandomLoadTypeForConsist));
|
||||
row.Children.Add(LoadToolButton(App.Loc["ConsistMaxAmount"], MaxAmountForConsist));
|
||||
row.Children.Add(LoadToolButton(App.Loc["ConsistRandomAmount"], RandomAmountForConsist));
|
||||
panel.Children.Add(row);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Button LoadToolButton(string text, Action onClick)
|
||||
{
|
||||
var b = new Button
|
||||
{
|
||||
Content = text, FontSize = 11, Margin = new Thickness(0, 0, 4, 4),
|
||||
Padding = new Thickness(8, 3), Cursor = _hand,
|
||||
IsEnabled = _consist.Count > 0
|
||||
};
|
||||
b.Classes.Add("Flat");
|
||||
b.Click += (_, _) => onClick();
|
||||
return b;
|
||||
}
|
||||
|
||||
// True when the unit's lead car can actually carry cargo.
|
||||
private bool IsLoadable(ConsistItem item)
|
||||
{
|
||||
var p = PhysicsFor(item.Cars[0]);
|
||||
return p != null && (p.MaxLoad > 0 || !string.IsNullOrWhiteSpace(p.LoadAccepted));
|
||||
}
|
||||
|
||||
// The largest cargo this car accepts (.fiz MaxLoad), 1000 as a fallback for a
|
||||
// car that lists accepted cargo but no explicit maximum, 0 when it can't load.
|
||||
private int MaxLoadFor(Dynamic car)
|
||||
{
|
||||
var p = PhysicsFor(car);
|
||||
if (p == null) return 0;
|
||||
if (p.MaxLoad > 0) return p.MaxLoad;
|
||||
return string.IsNullOrWhiteSpace(p.LoadAccepted) ? 0 : 1000;
|
||||
}
|
||||
|
||||
// Cargo types this car accepts (.fiz LoadAccepted), else the global list.
|
||||
private List<string> AcceptedTypesFor(Dynamic car)
|
||||
{
|
||||
var p = PhysicsFor(car);
|
||||
if (p != null && !string.IsNullOrWhiteSpace(p.LoadAccepted))
|
||||
return p.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
|
||||
return LoadTypes().ToList();
|
||||
}
|
||||
|
||||
// Copies the previous unit's load (type + amount) onto this unit, clamped to
|
||||
// each car's own maximum.
|
||||
private void CopyLoadFromPrevious(ConsistItem item)
|
||||
{
|
||||
int idx = _consist.IndexOf(item);
|
||||
if (idx <= 0) return;
|
||||
|
||||
var source = _consist[idx - 1].Cars[0];
|
||||
foreach (var c in item.Cars)
|
||||
{
|
||||
int max = MaxLoadFor(c);
|
||||
c.LoadType = max > 0 ? source.LoadType : null;
|
||||
c.LoadCount = max > 0 ? Math.Min(source.LoadCount, max) : 0;
|
||||
if (c.LoadCount > 0) c.HasVelocity = true;
|
||||
}
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// Fills one unit's cars to their maximum load (keeping the cargo type, or
|
||||
// assigning the first accepted one when none is set yet).
|
||||
private void SetUnitMaxLoad(ConsistItem item)
|
||||
{
|
||||
foreach (var c in item.Cars)
|
||||
FillToMax(c);
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// Random cargo type for every loadable car in the consist, filled to the max.
|
||||
private void RandomLoadTypeForConsist()
|
||||
{
|
||||
foreach (var c in _consist.SelectMany(u => u.Cars))
|
||||
{
|
||||
int max = MaxLoadFor(c);
|
||||
if (max <= 0) continue;
|
||||
var types = AcceptedTypesFor(c);
|
||||
if (types.Count == 0) continue;
|
||||
c.LoadType = types[_rng.Next(types.Count)];
|
||||
c.LoadCount = max;
|
||||
c.HasVelocity = true;
|
||||
}
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// Every loadable car in the consist filled to its maximum.
|
||||
private void MaxAmountForConsist()
|
||||
{
|
||||
foreach (var c in _consist.SelectMany(u => u.Cars))
|
||||
FillToMax(c);
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// Random amount (per car) for every loadable car in the consist, keeping the
|
||||
// cargo type (assigning the first accepted one when none is set). Lets you roll
|
||||
// a different tonnage for each wagon with one click.
|
||||
private void RandomAmountForConsist()
|
||||
{
|
||||
foreach (var c in _consist.SelectMany(u => u.Cars))
|
||||
{
|
||||
int max = MaxLoadFor(c);
|
||||
if (max <= 0) continue;
|
||||
if (string.IsNullOrEmpty(c.LoadType))
|
||||
c.LoadType = AcceptedTypesFor(c).FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(c.LoadType)) continue;
|
||||
int min = Math.Max(1, max / 10);
|
||||
c.LoadCount = _rng.Next(min, max + 1);
|
||||
c.HasVelocity = true;
|
||||
}
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// Fills a single car to its maximum load (no-op for non-loadable cars).
|
||||
private void FillToMax(Dynamic car)
|
||||
{
|
||||
int max = MaxLoadFor(car);
|
||||
if (max <= 0) return;
|
||||
if (string.IsNullOrEmpty(car.LoadType))
|
||||
car.LoadType = AcceptedTypesFor(car).FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(car.LoadType)) return;
|
||||
car.LoadCount = max;
|
||||
car.HasVelocity = true;
|
||||
}
|
||||
|
||||
private static TextBlock DetailHint(string text) => new()
|
||||
{
|
||||
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
|
||||
|
||||
@@ -21,8 +21,15 @@
|
||||
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
|
||||
Classes="Compact"
|
||||
Header="{Binding [Sceneries], Source={x:Static starterNg:App.Loc}}">
|
||||
<TreeView Name="sceneryList" Classes="Compact"
|
||||
SelectionChanged="SceneryList_OnSelectionChanged" />
|
||||
<DockPanel LastChildFill="True">
|
||||
<ToggleSwitch DockPanel.Dock="Bottom" Name="archivalSwitch"
|
||||
Margin="2,4,0,0" FontSize="11"
|
||||
IsCheckedChanged="ArchivalSwitch_OnChanged"
|
||||
OnContent="{Binding [ShowArchival], Source={x:Static starterNg:App.Loc}}"
|
||||
OffContent="{Binding [ShowArchival], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TreeView Name="sceneryList" Classes="Compact"
|
||||
SelectionChanged="SceneryList_OnSelectionChanged" />
|
||||
</DockPanel>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<!-- Centre: trainsets list (compact, fills) + description/weather/timetable tabs -->
|
||||
@@ -34,8 +41,19 @@
|
||||
|
||||
<HeaderedContentControl Grid.Row="0" Theme="{StaticResource GroupBox}"
|
||||
Header="{Binding [Vehicles], Source={x:Static starterNg:App.Loc}}">
|
||||
<ListBox Name="vehicleList" Classes="Compact"
|
||||
SelectionChanged="VehicleList_OnSelectionChanged" />
|
||||
<DockPanel LastChildFill="True">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="16"
|
||||
Margin="2,4,0,0">
|
||||
<CheckBox Name="showAiCheck" FontSize="11"
|
||||
IsCheckedChanged="VehicleFilter_OnChanged"
|
||||
Content="{Binding [ShowAiVehicles], Source={x:Static starterNg:App.Loc}}" />
|
||||
<CheckBox Name="drivableOnlyCheck" FontSize="11"
|
||||
IsCheckedChanged="VehicleFilter_OnChanged"
|
||||
Content="{Binding [ShowDrivableOnly], Source={x:Static starterNg:App.Loc}}" />
|
||||
</StackPanel>
|
||||
<ListBox Name="vehicleList" Classes="Compact"
|
||||
SelectionChanged="VehicleList_OnSelectionChanged" />
|
||||
</DockPanel>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<TabControl Grid.Row="1">
|
||||
|
||||
@@ -27,6 +27,9 @@ public partial class Scenarios : UserControl
|
||||
// stored Day stays 0 so the scenery keeps using the live date.
|
||||
private bool _todaySeason;
|
||||
|
||||
// suppresses the list-filter handlers while their state is restored at startup
|
||||
private bool _loadingFilters;
|
||||
|
||||
public Scenarios()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -34,61 +37,15 @@ public partial class Scenarios : UserControl
|
||||
// Sceneries are parsed once at startup (behind the splash).
|
||||
Sceneries = GameData.Instance.Sceneries;
|
||||
|
||||
var groupNodes = new Dictionary<string, TreeViewItem>();
|
||||
var topLevel = new List<TreeViewItem>();
|
||||
// Restore the persisted list filters before building the tree/list. Guarded
|
||||
// so assigning IsChecked here doesn't run the rebuild handlers prematurely.
|
||||
_loadingFilters = true;
|
||||
showAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
|
||||
drivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
|
||||
archivalSwitch.IsChecked = StarterNG.Classes.Settings.Instance.ShowArchivalSceneries;
|
||||
_loadingFilters = false;
|
||||
|
||||
for (int i = 0; i < Sceneries.Count; i++)
|
||||
{
|
||||
var scenery = Sceneries[i];
|
||||
|
||||
// case 1: no group - put without parent
|
||||
if (string.IsNullOrEmpty(scenery.Group))
|
||||
{
|
||||
topLevel.Add(new TreeViewItem
|
||||
{
|
||||
Header = Path.GetFileNameWithoutExtension(scenery.Path),
|
||||
Tag = i
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// case 2 - group scenery with others
|
||||
if (!groupNodes.TryGetValue(scenery.Group, out var groupNode))
|
||||
{
|
||||
groupNode = new TreeViewItem
|
||||
{
|
||||
Header = scenery.Group,
|
||||
IsExpanded = false
|
||||
};
|
||||
|
||||
groupNodes[scenery.Group] = groupNode;
|
||||
topLevel.Add(groupNode);
|
||||
}
|
||||
|
||||
groupNode.Items.Add(new TreeViewItem
|
||||
{
|
||||
Header = Path.GetFileNameWithoutExtension(scenery.Path),
|
||||
Tag = i
|
||||
});
|
||||
}
|
||||
|
||||
// Sort the scenery tree alphabetically: group folders' inner sceneries
|
||||
// first, then the top-level (group folders + ungrouped sceneries). The Tag
|
||||
// keeps each node's original scenery index, so sorting only reorders the
|
||||
// display - the vehicles/trainsets list stays in scenery order.
|
||||
foreach (var node in groupNodes.Values)
|
||||
{
|
||||
var children = node.Items.Cast<TreeViewItem>()
|
||||
.OrderBy(c => c.Header as string, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
node.Items.Clear();
|
||||
foreach (var child in children)
|
||||
node.Items.Add(child);
|
||||
}
|
||||
|
||||
foreach (var item in topLevel.OrderBy(t => t.Header as string, StringComparer.OrdinalIgnoreCase))
|
||||
sceneryList.Items.Add(item);
|
||||
BuildSceneryTree();
|
||||
|
||||
// refresh the consist preview when the view is shown again (e.g. after
|
||||
// editing the consist in the depot). Switching tabs only toggles IsVisible
|
||||
@@ -110,6 +67,139 @@ public partial class Scenarios : UserControl
|
||||
RefreshSelectedConsist();
|
||||
}
|
||||
|
||||
// (Re)builds the scenery tree, honouring the "show archival" switch. Archival
|
||||
// sceneries (those declaring //$a) are skipped when the switch is off; group
|
||||
// folders that end up empty are omitted. The Tag keeps each node's original
|
||||
// scenery index, so the vehicles list stays aligned. Top-level nodes and group
|
||||
// children are sorted alphabetically.
|
||||
private void BuildSceneryTree()
|
||||
{
|
||||
sceneryList.Items.Clear();
|
||||
|
||||
bool showArchival = archivalSwitch.IsChecked ?? true;
|
||||
var groupNodes = new Dictionary<string, TreeViewItem>();
|
||||
var topLevel = new List<TreeViewItem>();
|
||||
|
||||
for (int i = 0; i < Sceneries.Count; i++)
|
||||
{
|
||||
var scenery = Sceneries[i];
|
||||
if (scenery.Archival && !showArchival)
|
||||
continue;
|
||||
|
||||
if (string.IsNullOrEmpty(scenery.Group))
|
||||
{
|
||||
topLevel.Add(new TreeViewItem
|
||||
{
|
||||
Header = Path.GetFileNameWithoutExtension(scenery.Path),
|
||||
Tag = i
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groupNodes.TryGetValue(scenery.Group, out var groupNode))
|
||||
{
|
||||
groupNode = new TreeViewItem { Header = scenery.Group, IsExpanded = false };
|
||||
groupNodes[scenery.Group] = groupNode;
|
||||
topLevel.Add(groupNode);
|
||||
}
|
||||
|
||||
groupNode.Items.Add(new TreeViewItem
|
||||
{
|
||||
Header = Path.GetFileNameWithoutExtension(scenery.Path),
|
||||
Tag = i
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var node in groupNodes.Values)
|
||||
{
|
||||
var children = node.Items.Cast<TreeViewItem>()
|
||||
.OrderBy(c => c.Header as string, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
node.Items.Clear();
|
||||
foreach (var child in children)
|
||||
node.Items.Add(child);
|
||||
}
|
||||
|
||||
foreach (var item in topLevel.OrderBy(t => t.Header as string, StringComparer.OrdinalIgnoreCase))
|
||||
sceneryList.Items.Add(item);
|
||||
}
|
||||
|
||||
// Fills the Vehicles list for a scenery, honouring the two filters:
|
||||
// - "AI vehicles" off hides consists whose //$o description starts with '-';
|
||||
// - "Drivable only" on hides decoration consists (the //$decor flag).
|
||||
private void PopulateVehicleList(Scenery scn)
|
||||
{
|
||||
vehicleList.Items.Clear();
|
||||
|
||||
bool showAi = showAiCheck.IsChecked ?? true;
|
||||
bool drivableOnly = drivableOnlyCheck.IsChecked ?? true;
|
||||
|
||||
for (int i = 0; i < scn.Trainsets.Count; i++)
|
||||
{
|
||||
var trainset = scn.Trainsets[i];
|
||||
string trainsetName = string.Join(" + ", trainset.Vehicles.Select(dyn => dyn.Name));
|
||||
if (string.IsNullOrWhiteSpace(trainsetName)) continue; // skip empty
|
||||
|
||||
if (IsAiTrainset(trainset) && !showAi) continue;
|
||||
if (trainset.Decor && drivableOnly) continue;
|
||||
|
||||
var listItem = new ListBoxItem
|
||||
{
|
||||
Content = trainsetName,
|
||||
Tag = i
|
||||
};
|
||||
// right-click: save/load to the warehouse + clipboard copy/paste
|
||||
listItem.ContextMenu = ConsistContextMenu.Build(
|
||||
listItem,
|
||||
() => trainset,
|
||||
vehicles => ApplyConsist(listItem, trainset, vehicles));
|
||||
vehicleList.Items.Add(listItem);
|
||||
}
|
||||
|
||||
if (vehicleList.Items.Count > 0)
|
||||
vehicleList.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
// A computer-driven consist marks its //$o description with a leading '-'.
|
||||
private static bool IsAiTrainset(Trainset trainset) =>
|
||||
!string.IsNullOrEmpty(trainset.Description) &&
|
||||
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||
|
||||
// AI/player vehicle filter toggled: persist the choice and rebuild the list
|
||||
// for the currently selected scenery.
|
||||
private void VehicleFilter_OnChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_loadingFilters) return;
|
||||
|
||||
StarterNG.Classes.Settings.Instance.ShowAiVehicles = showAiCheck.IsChecked ?? true;
|
||||
StarterNG.Classes.Settings.Instance.DrivableOnly = drivableOnlyCheck.IsChecked ?? true;
|
||||
StarterNG.Classes.Settings.Instance.Save();
|
||||
|
||||
if (sceneryList.SelectedItem is TreeViewItem { Tag: int tag } && tag < Sceneries.Count)
|
||||
PopulateVehicleList(Sceneries[tag]);
|
||||
}
|
||||
|
||||
// Archival-scenery switch toggled: persist the choice and rebuild the tree.
|
||||
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_loadingFilters) return;
|
||||
|
||||
StarterNG.Classes.Settings.Instance.ShowArchivalSceneries = archivalSwitch.IsChecked ?? true;
|
||||
StarterNG.Classes.Settings.Instance.Save();
|
||||
|
||||
BuildSceneryTree();
|
||||
}
|
||||
|
||||
// Replaces a consist's vehicles (from a loaded warehouse preset or a pasted
|
||||
// clipboard consist) and refreshes the row caption / consist preview.
|
||||
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
|
||||
{
|
||||
trainset.Vehicles = vehicles;
|
||||
item.Content = string.Join(" + ", trainset.Vehicles.Select(d => d.Name));
|
||||
if (ReferenceEquals(AppState.Instance.CurrentTrainset, trainset))
|
||||
ShowConsist(trainset);
|
||||
}
|
||||
|
||||
// Rebuilds the caption of each entry in the Vehicles list from its trainset's
|
||||
// current vehicles, so a consist edited in the depot is reflected here too.
|
||||
// Selection and order are preserved (only the text is updated).
|
||||
@@ -144,8 +234,6 @@ public partial class Scenarios : UserControl
|
||||
return;
|
||||
}
|
||||
Scenery selectedScn = Sceneries[tag];
|
||||
|
||||
vehicleList.Items.Clear();
|
||||
|
||||
// scenery-level info (distinct from the per-consist scenario description)
|
||||
ShowSceneryInfo(selectedScn);
|
||||
@@ -155,25 +243,8 @@ public partial class Scenarios : UserControl
|
||||
missionDescription.Text = "";
|
||||
timetableContent.Text = App.Loc["NoTimetable"];
|
||||
|
||||
// add all trainsets to list
|
||||
for (int i = 0; i < selectedScn.Trainsets.Count; i++)
|
||||
{
|
||||
string trainsetName = string.Join(
|
||||
" + ",
|
||||
selectedScn.Trainsets[i].Vehicles.Select(dyn => dyn.Name)
|
||||
);
|
||||
if (string.IsNullOrWhiteSpace(trainsetName)) continue; // skip empty
|
||||
vehicleList.Items.Add(new ListBoxItem
|
||||
{
|
||||
Content = trainsetName,
|
||||
Tag = i
|
||||
});
|
||||
}
|
||||
|
||||
if (vehicleList.Items.Count > 0)
|
||||
{
|
||||
vehicleList.SelectedIndex = 0;
|
||||
}
|
||||
// add the (filtered) trainsets to the Vehicles list
|
||||
PopulateVehicleList(selectedScn);
|
||||
}
|
||||
|
||||
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- Controls / key bindings -->
|
||||
<TabItem Header="{Binding [Controls], Source={x:Static starterNg:App.Loc}}">
|
||||
<Grid x:Name="ControlsHost" RowDefinitions="Auto,*,Auto" Margin="12">
|
||||
<!-- Built in code-behind (BuildControlsTab): a search bar, the
|
||||
scrollable binding list, and the keyboard visualisation. -->
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Others -->
|
||||
<TabItem Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml.Styling;
|
||||
using Avalonia.Media;
|
||||
using StarterNG.Classes;
|
||||
// Disambiguate from Avalonia.Input.KeyBinding (a command-input binding) imported above.
|
||||
using KeyBinding = StarterNG.Classes.KeyBinding;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -35,6 +42,13 @@ public partial class Settings : UserControl
|
||||
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
|
||||
|
||||
ApplyToUi();
|
||||
|
||||
// Controls tab: load the key bindings and build the editor + on-screen
|
||||
// keyboard. The tunnelling handler lets us capture a key press before the
|
||||
// focused button consumes it (Space/Enter would otherwise click it).
|
||||
KeyboardConfig.Instance.Load();
|
||||
BuildControlsTab();
|
||||
AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel);
|
||||
}
|
||||
|
||||
// ── Settings instance → controls ──────────────────────────────────────
|
||||
@@ -209,6 +223,7 @@ public partial class Settings : UserControl
|
||||
{
|
||||
ReadFromUi();
|
||||
StarterNG.Classes.Settings.Instance.Save();
|
||||
KeyboardConfig.Instance.Save();
|
||||
if (SaveStatus is not null)
|
||||
SaveStatus.Text = App.Loc["SettingsSaved"];
|
||||
}
|
||||
@@ -217,6 +232,10 @@ public partial class Settings : UserControl
|
||||
{
|
||||
StarterNG.Classes.Settings.Instance.Load();
|
||||
ApplyToUi();
|
||||
CancelCapture();
|
||||
KeyboardConfig.Instance.Load();
|
||||
RebuildBindingList();
|
||||
RebuildKeyboard();
|
||||
if (SaveStatus is not null)
|
||||
SaveStatus.Text = string.Empty;
|
||||
}
|
||||
@@ -311,4 +330,598 @@ public partial class Settings : UserControl
|
||||
return i + 1;
|
||||
return 4; // default → 8x
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Controls tab — key-binding editor and on-screen keyboard
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private TextBox? _controlsSearch;
|
||||
private StackPanel? _bindingListPanel;
|
||||
private StackPanel? _keyboardPanel;
|
||||
|
||||
// Binding currently waiting for a key press, and the button that launched it.
|
||||
private KeyBinding? _capturing;
|
||||
private Button? _capturingButton;
|
||||
|
||||
// On-screen key cells, keyed by token, so colours can be refreshed in place
|
||||
// (rebuilding the whole keyboard would detach an open assignment flyout).
|
||||
private readonly Dictionary<string, Grid> _keyCells = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Guards the assignment flyout's combo boxes against feedback during programmatic updates.
|
||||
private bool _flyoutLoading;
|
||||
|
||||
// State colours (match the legend): unassigned/filler, plain, +shift, +ctrl.
|
||||
private static readonly IBrush KeyUnassignedBrush = new SolidColorBrush(Color.Parse("#2A3036"));
|
||||
private static readonly IBrush KeyFillerBrush = new SolidColorBrush(Color.Parse("#21262B"));
|
||||
private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
|
||||
private static readonly IBrush KeyShiftBrush = new SolidColorBrush(Color.Parse("#C9A227"));
|
||||
private static readonly IBrush KeyCtrlBrush = new SolidColorBrush(Color.Parse("#2D7FD3"));
|
||||
private static readonly IBrush KeyBorderBrush = new SolidColorBrush(Color.Parse("#3A424A"));
|
||||
private static readonly IBrush FgBrush = new SolidColorBrush(Color.Parse("#E6E8EA"));
|
||||
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
|
||||
private static readonly IBrush ConflictBrush = new SolidColorBrush(Color.Parse("#E06C5A"));
|
||||
|
||||
private const double KeyUnit = 30; // px per relative width unit
|
||||
private const double KeyHeight = 34;
|
||||
private const double KeyGap = 4;
|
||||
|
||||
private void BuildControlsTab()
|
||||
{
|
||||
if (ControlsHost is null)
|
||||
return;
|
||||
|
||||
// Row 0 — search / restore bar.
|
||||
var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 8) };
|
||||
topBar.Children.Add(new TextBlock
|
||||
{
|
||||
Text = App.Loc["BindingsHint"],
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Foreground = FgDimBrush
|
||||
});
|
||||
_controlsSearch = new TextBox { Width = 240, Watermark = App.Loc["Search"] };
|
||||
_controlsSearch.TextChanged += (_, _) => RebuildBindingList();
|
||||
topBar.Children.Add(_controlsSearch);
|
||||
|
||||
var restoreBtn = new Button { Content = App.Loc["RestoreDefaults"] };
|
||||
restoreBtn.Classes.Add("Flat");
|
||||
restoreBtn.Click += (_, _) =>
|
||||
{
|
||||
CancelCapture();
|
||||
KeyboardConfig.Instance.LoadDefaults();
|
||||
KeyboardConfig.Instance.Dirty = true;
|
||||
RebuildBindingList();
|
||||
RebuildKeyboard();
|
||||
};
|
||||
topBar.Children.Add(restoreBtn);
|
||||
Grid.SetRow(topBar, 0);
|
||||
ControlsHost.Children.Add(topBar);
|
||||
|
||||
// Row 1 — scrollable binding list.
|
||||
_bindingListPanel = new StackPanel { Spacing = 3 };
|
||||
var listScroll = new ScrollViewer
|
||||
{
|
||||
Content = _bindingListPanel,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto
|
||||
};
|
||||
Grid.SetRow(listScroll, 1);
|
||||
ControlsHost.Children.Add(listScroll);
|
||||
|
||||
// Row 2 — legend + on-screen keyboard.
|
||||
var bottom = new StackPanel { Spacing = 8, Margin = new Thickness(0, 10, 0, 0) };
|
||||
bottom.Children.Add(BuildLegend());
|
||||
_keyboardPanel = new StackPanel();
|
||||
bottom.Children.Add(new ScrollViewer
|
||||
{
|
||||
Content = _keyboardPanel,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
|
||||
});
|
||||
Grid.SetRow(bottom, 2);
|
||||
ControlsHost.Children.Add(bottom);
|
||||
|
||||
RebuildBindingList();
|
||||
RebuildKeyboard();
|
||||
}
|
||||
|
||||
// ── binding list ───────────────────────────────────────────────────────
|
||||
private void RebuildBindingList()
|
||||
{
|
||||
if (_bindingListPanel is null)
|
||||
return;
|
||||
|
||||
_bindingListPanel.Children.Clear();
|
||||
string filter = _controlsSearch?.Text?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
var conflicts = ComputeConflicts();
|
||||
|
||||
foreach (var b in KeyboardConfig.Instance.Bindings)
|
||||
{
|
||||
if (filter.Length > 0 && !MatchesFilter(b, filter))
|
||||
continue;
|
||||
_bindingListPanel.Children.Add(BuildBindingRow(b, conflicts));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesFilter(KeyBinding b, string filter) =>
|
||||
b.Command.ToLowerInvariant().Contains(filter) ||
|
||||
b.Description.ToLowerInvariant().Contains(filter);
|
||||
|
||||
private Control BuildBindingRow(KeyBinding b, HashSet<string> conflicts)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto"),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
var label = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||||
label.Children.Add(new TextBlock
|
||||
{
|
||||
Text = CommandLabel(b),
|
||||
Foreground = FgBrush,
|
||||
FontSize = 13,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
});
|
||||
label.Children.Add(new TextBlock
|
||||
{
|
||||
Text = b.Command,
|
||||
Foreground = FgDimBrush,
|
||||
FontSize = 11,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
});
|
||||
Grid.SetColumn(label, 0);
|
||||
grid.Children.Add(label);
|
||||
|
||||
bool conflict = b.IsAssigned && conflicts.Contains(ComboKey(b));
|
||||
var comboButton = new Button
|
||||
{
|
||||
Content = _capturing == b ? App.Loc["PressKey"] : ComboText(b),
|
||||
MinWidth = 140,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Foreground = conflict ? ConflictBrush : FgBrush
|
||||
};
|
||||
comboButton.Classes.Add("Flat");
|
||||
if (conflict)
|
||||
ToolTip.SetTip(comboButton, App.Loc["ConflictTooltip"]);
|
||||
comboButton.Click += (_, _) => StartCapture(b, comboButton);
|
||||
Grid.SetColumn(comboButton, 1);
|
||||
grid.Children.Add(comboButton);
|
||||
|
||||
var clearButton = new Button
|
||||
{
|
||||
Content = "✕",
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
clearButton.Classes.Add("Basic");
|
||||
ToolTip.SetTip(clearButton, App.Loc["ClearBinding"]);
|
||||
clearButton.Click += (_, _) =>
|
||||
{
|
||||
CancelCapture();
|
||||
b.Shift = b.Ctrl = false;
|
||||
b.Key = "none";
|
||||
KeyboardConfig.Instance.Dirty = true;
|
||||
RebuildBindingList();
|
||||
RebuildKeyboard();
|
||||
};
|
||||
Grid.SetColumn(clearButton, 2);
|
||||
grid.Children.Add(clearButton);
|
||||
|
||||
return new Border
|
||||
{
|
||||
Background = KeyUnassignedBrush,
|
||||
BorderBrush = KeyBorderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(2),
|
||||
Padding = new Thickness(8, 4),
|
||||
Child = grid
|
||||
};
|
||||
}
|
||||
|
||||
// ── key capture ──────────────────────────────────────────────────────--
|
||||
private void StartCapture(KeyBinding b, Button button)
|
||||
{
|
||||
// Restore the previously listening button, if any, before switching.
|
||||
if (_capturing is not null && _capturingButton is not null)
|
||||
_capturingButton.Content = ComboText(_capturing);
|
||||
|
||||
_capturing = b;
|
||||
_capturingButton = button;
|
||||
button.Content = App.Loc["PressKey"];
|
||||
button.Focus(); // keep focus inside the view so the tunnel handler sees keys
|
||||
}
|
||||
|
||||
private void CancelCapture()
|
||||
{
|
||||
if (_capturing is not null && _capturingButton is not null)
|
||||
_capturingButton.Content = ComboText(_capturing);
|
||||
_capturing = null;
|
||||
_capturingButton = null;
|
||||
}
|
||||
|
||||
private void OnControlsKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (_capturing is null)
|
||||
return;
|
||||
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
CancelCapture();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for a real key while only modifiers are held.
|
||||
if (KeyMap.IsModifierKey(e.Key))
|
||||
return;
|
||||
|
||||
string? token = KeyMap.FromInput(e.Key, e.PhysicalKey);
|
||||
if (token is null)
|
||||
{
|
||||
e.Handled = true; // swallow unsupported keys instead of clicking the button
|
||||
return;
|
||||
}
|
||||
|
||||
_capturing.Shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
|
||||
_capturing.Ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
_capturing.Key = token;
|
||||
KeyboardConfig.Instance.Dirty = true;
|
||||
e.Handled = true;
|
||||
|
||||
_capturing = null;
|
||||
_capturingButton = null;
|
||||
RebuildBindingList();
|
||||
RebuildKeyboard();
|
||||
}
|
||||
|
||||
// ── on-screen keyboard ───────────────────────────────────────────────--
|
||||
private void RebuildKeyboard()
|
||||
{
|
||||
if (_keyboardPanel is null)
|
||||
return;
|
||||
|
||||
_keyCells.Clear();
|
||||
_keyboardPanel.Children.Clear();
|
||||
var states = ComputeKeyStates();
|
||||
|
||||
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 16 };
|
||||
row.Children.Add(BuildKeyBlock(KeyMap.MainBlock, states));
|
||||
row.Children.Add(BuildKeyBlock(KeyMap.NavBlock, states));
|
||||
row.Children.Add(BuildKeyBlock(KeyMap.NumpadBlock, states));
|
||||
_keyboardPanel.Children.Add(row);
|
||||
}
|
||||
|
||||
// Recolours every key in place without rebuilding the tree, so an open
|
||||
// assignment flyout keeps its anchor.
|
||||
private void UpdateKeyboardColors()
|
||||
{
|
||||
var states = ComputeKeyStates();
|
||||
foreach (var (token, content) in _keyCells)
|
||||
{
|
||||
if (content.Children.Count > 0)
|
||||
content.Children.RemoveAt(0); // drop the old background, keep the label
|
||||
content.Children.Insert(0, BuildKeyBackground(token, states));
|
||||
states.TryGetValue(token, out var state);
|
||||
ToolTip.SetTip(content, BuildKeyTooltip(token, state));
|
||||
}
|
||||
}
|
||||
|
||||
private Control BuildKeyBlock(KeyCap[][] rows, Dictionary<string, KeyState> states)
|
||||
{
|
||||
var block = new StackPanel { Spacing = KeyGap, VerticalAlignment = VerticalAlignment.Top };
|
||||
foreach (var rowCaps in rows)
|
||||
{
|
||||
var rowPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = KeyGap };
|
||||
foreach (var cap in rowCaps)
|
||||
rowPanel.Children.Add(BuildKeyCap(cap, states));
|
||||
block.Children.Add(rowPanel);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
private Control BuildKeyCap(KeyCap cap, Dictionary<string, KeyState> states)
|
||||
{
|
||||
double width = cap.Width * KeyUnit + (cap.Width - 1) * KeyGap;
|
||||
var content = new Grid { Width = width, Height = KeyHeight };
|
||||
|
||||
var keyBorder = new Border
|
||||
{
|
||||
BorderBrush = KeyBorderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(3),
|
||||
ClipToBounds = true,
|
||||
Child = content
|
||||
};
|
||||
|
||||
if (cap.Token is null)
|
||||
{
|
||||
// Reserved key (Esc, F1-F12, modifiers …): greyed out and not editable.
|
||||
content.Children.Add(new Border { Background = KeyFillerBrush });
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = cap.Label,
|
||||
Foreground = FgDimBrush,
|
||||
FontSize = 9,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
keyBorder.Opacity = 0.55;
|
||||
if (!string.IsNullOrEmpty(cap.Label))
|
||||
ToolTip.SetTip(keyBorder, App.Loc["KbReserved"]);
|
||||
return keyBorder;
|
||||
}
|
||||
|
||||
string token = cap.Token;
|
||||
states.TryGetValue(token.ToLowerInvariant(), out var st);
|
||||
content.Children.Add(BuildKeyBackground(token, states));
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = cap.Label,
|
||||
Foreground = FgBrush,
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
ToolTip.SetTip(content, BuildKeyTooltip(token, st));
|
||||
|
||||
keyBorder.Cursor = new Cursor(StandardCursorType.Hand);
|
||||
keyBorder.PointerPressed += (_, _) => ShowKeyFlyout(token, keyBorder);
|
||||
_keyCells[token] = content;
|
||||
return keyBorder;
|
||||
}
|
||||
|
||||
private Control BuildKeyBackground(string token, Dictionary<string, KeyState> states)
|
||||
{
|
||||
states.TryGetValue(token.ToLowerInvariant(), out var state);
|
||||
var colours = new List<IBrush>();
|
||||
if (state is { Plain: true }) colours.Add(KeyPlainBrush);
|
||||
if (state is { Shift: true }) colours.Add(KeyShiftBrush);
|
||||
if (state is { Ctrl: true }) colours.Add(KeyCtrlBrush);
|
||||
|
||||
if (colours.Count == 0)
|
||||
return new Border { Background = KeyUnassignedBrush };
|
||||
|
||||
var stripes = new UniformGrid { Rows = 1, Columns = colours.Count };
|
||||
foreach (var c in colours)
|
||||
stripes.Children.Add(new Border { Background = c });
|
||||
return stripes;
|
||||
}
|
||||
|
||||
private string BuildKeyTooltip(string token, KeyState? state)
|
||||
{
|
||||
string head = KeyMap.DisplayName(token);
|
||||
if (state is null || state.Tips.Count == 0)
|
||||
return $"{head}: {App.Loc["KbUnassigned"]}";
|
||||
return head + "\n" + string.Join("\n", state.Tips);
|
||||
}
|
||||
|
||||
// ── key assignment flyout (one combo per modifier combination) ──────────
|
||||
private void ShowKeyFlyout(string token, Control anchor)
|
||||
{
|
||||
CancelCapture();
|
||||
var commands = KeyboardConfig.Instance.Bindings;
|
||||
|
||||
var items = new List<string> { App.Loc["BindNone"] };
|
||||
foreach (var c in commands)
|
||||
items.Add(CommandLabel(c));
|
||||
|
||||
var slots = new (bool shift, bool ctrl, string labelKey)[]
|
||||
{
|
||||
(false, false, "BindNoMod"),
|
||||
(true, false, "BindShift"),
|
||||
(false, true, "BindCtrl"),
|
||||
(true, true, "BindShiftCtrl"),
|
||||
};
|
||||
|
||||
var combos = new ComboBox[slots.Length];
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto")
|
||||
};
|
||||
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
{
|
||||
var (shift, ctrl, labelKey) = slots[i];
|
||||
|
||||
var lbl = new TextBlock
|
||||
{
|
||||
Text = App.Loc[labelKey],
|
||||
Foreground = FgBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 4, 12, 4)
|
||||
};
|
||||
Grid.SetRow(lbl, i);
|
||||
Grid.SetColumn(lbl, 0);
|
||||
grid.Children.Add(lbl);
|
||||
|
||||
var cb = new ComboBox
|
||||
{
|
||||
ItemsSource = items,
|
||||
MinWidth = 280,
|
||||
MaxDropDownHeight = 320,
|
||||
Margin = new Thickness(0, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
cb.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_flyoutLoading)
|
||||
return;
|
||||
AssignSlot(token, shift, ctrl, cb.SelectedIndex, commands);
|
||||
SyncFlyoutCombos(token, combos);
|
||||
UpdateKeyboardColors();
|
||||
RebuildBindingList();
|
||||
};
|
||||
Grid.SetRow(cb, i);
|
||||
Grid.SetColumn(cb, 1);
|
||||
grid.Children.Add(cb);
|
||||
combos[i] = cb;
|
||||
}
|
||||
|
||||
SyncFlyoutCombos(token, combos);
|
||||
|
||||
var panel = new StackPanel { Spacing = 8, Margin = new Thickness(12), MinWidth = 360 };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{App.Loc["BindKeyTitle"]} {KeyMap.DisplayName(token)}",
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = FgBrush
|
||||
});
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = App.Loc["BindKeyHint"],
|
||||
Foreground = FgDimBrush,
|
||||
FontSize = 11,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
panel.Children.Add(grid);
|
||||
|
||||
var flyout = new Flyout { Content = panel };
|
||||
flyout.ShowAt(anchor);
|
||||
}
|
||||
|
||||
private void SyncFlyoutCombos(string token, ComboBox[] combos)
|
||||
{
|
||||
var commands = KeyboardConfig.Instance.Bindings;
|
||||
var slots = new (bool shift, bool ctrl)[] { (false, false), (true, false), (false, true), (true, true) };
|
||||
_flyoutLoading = true;
|
||||
for (int i = 0; i < combos.Length; i++)
|
||||
{
|
||||
var cmd = CommandInSlot(token, slots[i].shift, slots[i].ctrl);
|
||||
combos[i].SelectedIndex = cmd is null ? 0 : commands.IndexOf(cmd) + 1;
|
||||
}
|
||||
_flyoutLoading = false;
|
||||
}
|
||||
|
||||
private void AssignSlot(string token, bool shift, bool ctrl, int selectedIndex, List<KeyBinding> commands)
|
||||
{
|
||||
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
|
||||
|
||||
// Free the slot: unbind any other command currently sitting on it.
|
||||
foreach (var b in KeyboardConfig.Instance.Bindings)
|
||||
{
|
||||
if (!ReferenceEquals(b, chosen) && b.IsAssigned &&
|
||||
string.Equals(b.Key, token, StringComparison.OrdinalIgnoreCase) &&
|
||||
b.Shift == shift && b.Ctrl == ctrl)
|
||||
{
|
||||
b.Shift = b.Ctrl = false;
|
||||
b.Key = "none";
|
||||
}
|
||||
}
|
||||
|
||||
if (chosen is not null)
|
||||
{
|
||||
chosen.Key = token;
|
||||
chosen.Shift = shift;
|
||||
chosen.Ctrl = ctrl;
|
||||
}
|
||||
|
||||
KeyboardConfig.Instance.Dirty = true;
|
||||
}
|
||||
|
||||
private static KeyBinding? CommandInSlot(string token, bool shift, bool ctrl) =>
|
||||
KeyboardConfig.Instance.Bindings.FirstOrDefault(b =>
|
||||
b.IsAssigned &&
|
||||
string.Equals(b.Key, token, StringComparison.OrdinalIgnoreCase) &&
|
||||
b.Shift == shift && b.Ctrl == ctrl);
|
||||
|
||||
private Control BuildLegend()
|
||||
{
|
||||
var legend = new WrapPanel { Orientation = Orientation.Horizontal };
|
||||
legend.Children.Add(LegendItem(KeyUnassignedBrush, App.Loc["KbUnassigned"]));
|
||||
legend.Children.Add(LegendItem(KeyPlainBrush, App.Loc["KbAssigned"]));
|
||||
legend.Children.Add(LegendItem(KeyShiftBrush, App.Loc["KbShift"]));
|
||||
legend.Children.Add(LegendItem(KeyCtrlBrush, App.Loc["KbCtrl"]));
|
||||
return legend;
|
||||
}
|
||||
|
||||
private Control LegendItem(IBrush brush, string text)
|
||||
{
|
||||
var sp = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 6,
|
||||
Margin = new Thickness(0, 0, 16, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
sp.Children.Add(new Border
|
||||
{
|
||||
Width = 16,
|
||||
Height = 16,
|
||||
Background = brush,
|
||||
BorderBrush = KeyBorderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(2),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
sp.Children.Add(new TextBlock { Text = text, Foreground = FgDimBrush, VerticalAlignment = VerticalAlignment.Center });
|
||||
return sp;
|
||||
}
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────
|
||||
private sealed class KeyState
|
||||
{
|
||||
public bool Plain;
|
||||
public bool Shift;
|
||||
public bool Ctrl;
|
||||
public readonly List<string> Tips = new();
|
||||
}
|
||||
|
||||
private static Dictionary<string, KeyState> ComputeKeyStates()
|
||||
{
|
||||
var map = new Dictionary<string, KeyState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var b in KeyboardConfig.Instance.Bindings)
|
||||
{
|
||||
if (!b.IsAssigned)
|
||||
continue;
|
||||
string key = b.Key.ToLowerInvariant();
|
||||
if (!map.TryGetValue(key, out var state))
|
||||
map[key] = state = new KeyState();
|
||||
|
||||
if (!b.Shift && !b.Ctrl) state.Plain = true;
|
||||
if (b.Shift) state.Shift = true;
|
||||
if (b.Ctrl) state.Ctrl = true;
|
||||
|
||||
state.Tips.Add($"{ComboText(b)} — {CommandLabel(b)}");
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static HashSet<string> ComputeConflicts()
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var dup = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var b in KeyboardConfig.Instance.Bindings)
|
||||
{
|
||||
if (!b.IsAssigned)
|
||||
continue;
|
||||
string k = ComboKey(b);
|
||||
if (!seen.Add(k))
|
||||
dup.Add(k);
|
||||
}
|
||||
return dup;
|
||||
}
|
||||
|
||||
private static string ComboKey(KeyBinding b) =>
|
||||
$"{(b.Ctrl ? 1 : 0)}|{(b.Shift ? 1 : 0)}|{b.Key.ToLowerInvariant()}";
|
||||
|
||||
private static string ComboText(KeyBinding b)
|
||||
{
|
||||
if (!b.IsAssigned)
|
||||
return "—";
|
||||
var parts = new List<string>();
|
||||
if (b.Ctrl) parts.Add("Ctrl");
|
||||
if (b.Shift) parts.Add("Shift");
|
||||
parts.Add(KeyMap.DisplayName(b.Key));
|
||||
return string.Join(" + ", parts);
|
||||
}
|
||||
|
||||
// Display label for a command: its (capitalised) description, or the raw
|
||||
// command token when the file has no comment for it.
|
||||
private static string CommandLabel(KeyBinding b) =>
|
||||
string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description);
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user