WIP: settings sections, shortcuts window, depot tweaks

This commit is contained in:
maj00r
2026-09-04 20:40:47 +02:00
parent bcf266a167
commit aef086e9f0
35 changed files with 2945 additions and 1104 deletions

View File

@@ -3,27 +3,35 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:views="clr-namespace:StarterNG.Views"
x:CompileBindings="True"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
x:Class="StarterNG.Views.Depot">
<!-- TabIndex follows the workflow - pick a consist, browse, build the train,
then the editors - not the column order the grid happens to have. -->
<Grid Margin="0" ColumnSpacing="3" RowSpacing="3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="270" MinWidth="220" />
<ColumnDefinition />
<ColumnDefinition Width="340" MinWidth="300" />
</Grid.ColumnDefinitions>
<!-- The vehicle panel keeps its old height on a roomy window (MaxHeight)
but shrinks with everything else towards the 666 px floor instead of
being clipped. The consist row measures itself, so a whole card fits
whether the thumbnails are the small ones or the large ones. -->
<Grid.RowDefinitions>
<RowDefinition Height="*" MinHeight="230" />
<RowDefinition Height="275" MinHeight="260" />
<RowDefinition Height="175" MinHeight="165" />
<RowDefinition Height="*" MinHeight="150" />
<RowDefinition Height="1.57*" MinHeight="120" MaxHeight="192" />
<RowDefinition Height="Auto" MinHeight="140" MaxHeight="230" />
</Grid.RowDefinitions>
<Border Grid.Column="0" Grid.Row="0" Background="{StaticResource CarbonPanelBg}"
<Border Grid.Column="0" Grid.Row="0" TabIndex="2" Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
CornerRadius="2" Padding="8">
CornerRadius="2" Padding="6">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto">
<DockPanel Grid.Row="0" Margin="0,0,0,6" LastChildFill="True">
<CheckBox Name="hideArchivalCheck" DockPanel.Dock="Right" FontSize="11"
<CheckBox Name="hideArchivalCheck" DockPanel.Dock="Right" FontSize="12"
VerticalAlignment="Center" Margin="8,0,0,0"
Content="{Binding [HideArchivalVehicles], Source={x:Static starterNg:App.Loc}}"
IsCheckedChanged="HideArchivalCheck_OnChanged" />
@@ -34,10 +42,12 @@
<Grid Grid.Row="1" RowDefinitions="Auto,Auto" RowSpacing="6" Margin="0,0,0,6">
<DockPanel Grid.Row="0" LastChildFill="True">
<Button Name="textureBaseButton" DockPanel.Dock="Right" Classes="Flat"
Width="30" Margin="4,0,0,0" Cursor="Hand" Content="&#x1F50D;"
Width="30" Margin="4,0,0,0" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="TextureBaseButton_OnClick"
ToolTip.Tip="{Binding [OpenTextureBase], Source={x:Static starterNg:App.Loc}}" />
ToolTip.Tip="{Binding [OpenTextureBase], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="Magnify" Width="15" Height="15" />
</Button>
<ComboBox Name="categoryCombo" HorizontalAlignment="Stretch"
PlaceholderText="{Binding [Category], Source={x:Static starterNg:App.Loc}}"
PointerWheelChanged="Combo_OnPointerWheel"
@@ -49,7 +59,7 @@
SelectionChanged="ClassCombo_OnSelectionChanged" />
</Grid>
<Border Name="miniPreviewPanel" Grid.Row="3" Height="66"
<Border Name="miniPreviewPanel" Grid.Row="3" Height="46"
Margin="0,6,0,0"
Background="{StaticResource CarbonInputBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="1"
@@ -71,10 +81,15 @@
</Grid>
</Border>
<HeaderedContentControl Name="mapConsistsPanel" Grid.Column="1" Grid.Row="0"
Theme="{StaticResource GroupBox}">
<!-- No group header here: the tab strip already names the list, and the
scenery it belongs to is written into the tab itself. -->
<Border Grid.Column="1" Grid.Row="0" TabIndex="1"
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="6">
<TabControl>
<TabItem Header="{Binding [SceneryConsists], Source={x:Static starterNg:App.Loc}}">
<TabItem Name="sceneryConsistsTab"
Header="{Binding [SceneryConsists], Source={x:Static starterNg:App.Loc}}">
<DockPanel LastChildFill="True">
<Button Name="addToWarehouseButton" DockPanel.Dock="Bottom" Classes="Flat"
Margin="0,6,0,0" HorizontalAlignment="Stretch" Cursor="Hand"
@@ -83,10 +98,10 @@
Content="{Binding [AddToWarehouse], Source={x:Static starterNg:App.Loc}}" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="16"
Margin="2,6,0,0">
<CheckBox Name="depotDrivableOnlyCheck" FontSize="11"
<CheckBox Name="depotDrivableOnlyCheck" FontSize="12"
IsCheckedChanged="ConsistFilter_OnChanged"
Content="{Binding [ShowDrivableOnly], Source={x:Static starterNg:App.Loc}}" />
<CheckBox Name="depotShowAiCheck" FontSize="11"
<CheckBox Name="depotShowAiCheck" FontSize="12"
IsCheckedChanged="ConsistFilter_OnChanged"
Content="{Binding [ShowAiVehicles], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
@@ -107,74 +122,107 @@
</DockPanel>
</TabItem>
</TabControl>
</HeaderedContentControl>
</Border>
<HeaderedContentControl Name="selectedVehiclePanel" Grid.Row="1" Grid.ColumnSpan="2"
Theme="{StaticResource GroupBox}">
<Grid ColumnDefinitions="290*,3,380*,3,280*">
<TabControl Grid.Column="0">
<TabItem Header="{Binding [GeneralInfo], Source={x:Static starterNg:App.Loc}}">
<DockPanel LastChildFill="True" Margin="4">
<Button Name="removeVehicleButton" DockPanel.Dock="Bottom" Classes="Flat"
Margin="0,6,0,0" HorizontalAlignment="Stretch" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="RemoveVehicleButton_OnClick"
Content="{Binding [RemoveVehicle], Source={x:Static starterNg:App.Loc}}" />
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="generalPanel" Spacing="6" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem Header="{Binding [AboutTexture], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="consistTexturePanel" Margin="4" Spacing="6" />
</ScrollViewer>
</TabItem>
</TabControl>
<TabControl Grid.Column="2">
<!-- The configuration is the one panel here that is actually edited, so it
gets a column of its own spanning both rows - from the top edge down to
the consist - instead of a third of one squeezed row. -->
<Border Grid.Column="2" Grid.Row="0" Grid.RowSpan="2" TabIndex="6"
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="6">
<DockPanel LastChildFill="True">
<Button Name="removeVehicleButton" DockPanel.Dock="Bottom" Classes="Flat"
Margin="0,6,0,0" HorizontalAlignment="Stretch" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="RemoveVehicleButton_OnClick"
Content="{Binding [RemoveVehicle], Source={x:Static starterNg:App.Loc}}" />
<TabControl>
<TabItem Header="{Binding [Configuration], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="loadsPanel" Margin="4" Spacing="6" />
</ScrollViewer>
</TabItem>
<TabItem Header="{Binding [Brakes], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="brakesPanel" Margin="4" Spacing="6" />
<StackPanel Name="loadsPanel" Margin="4" Grid.IsSharedSizeScope="True" />
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
</Border>
<TabControl Grid.Column="4">
<TabItem Header="{Binding [Coupling], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="couplerPanel" Margin="4" Spacing="6" />
</ScrollViewer>
</TabItem>
<TabItem Header="{Binding [Damage], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="damagePanel" Margin="4" Spacing="6" />
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</HeaderedContentControl>
<!-- No title band either: the tab strips start straight away, and the
vehicle being edited is the highlighted card in the consist below.
Two cards, not one: the read-only pair (general info / texture) is
visually separate from the editors (coupling / damage / brakes), and
each lines up with the panel above it. -->
<Border Grid.Row="1" Grid.Column="0" TabIndex="4"
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="6">
<TabControl>
<TabItem Header="{Binding [GeneralInfo], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="generalPanel" Margin="4" Grid.IsSharedSizeScope="True" />
</ScrollViewer>
</TabItem>
<TabItem Header="{Binding [AboutTexture], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="consistTexturePanel" Margin="4" Spacing="3" />
</ScrollViewer>
</TabItem>
</TabControl>
</Border>
<HeaderedContentControl Grid.Row="2" Grid.ColumnSpan="2" Theme="{StaticResource GroupBox}"
<Border Grid.Row="1" Grid.Column="1" TabIndex="5"
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="6">
<TabControl>
<TabItem Header="{Binding [Coupling], Source={x:Static starterNg:App.Loc}}">
<!-- The coupling grid now uses the full tab width. The two
actions sit underneath, so they do not steal horizontal
space from the checkbox columns. -->
<DockPanel LastChildFill="True" Margin="4">
<Panel Name="couplerActions" DockPanel.Dock="Bottom" />
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="couplerPanel" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem Header="{Binding [Damage], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="damagePanel" Margin="4" Grid.IsSharedSizeScope="True" />
</ScrollViewer>
</TabItem>
<TabItem Header="{Binding [Brakes], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<!-- The scope is what makes every row's label column in this
panel settle on one width. -->
<StackPanel Name="brakesPanel" Margin="4"
Grid.IsSharedSizeScope="True" />
</ScrollViewer>
</TabItem>
</TabControl>
</Border>
<HeaderedContentControl Grid.Row="2" Grid.ColumnSpan="3" TabIndex="3" Theme="{StaticResource GroupBox}"
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
<!-- Mass, length and the rest ride in the title bar instead of taking
a row of their own above the cards. -->
<HeaderedContentControl.Tag>
<StackPanel Name="trainStats" Orientation="Horizontal"
VerticalAlignment="Center" />
</HeaderedContentControl.Tag>
<DockPanel LastChildFill="True">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel Name="trainStats" Orientation="Horizontal"
VerticalAlignment="Center" />
</DockPanel>
<Panel Name="consistDropTarget">
<TextBlock Name="emptyHint" VerticalAlignment="Center" HorizontalAlignment="Center"
TextWrapping="Wrap" MaxWidth="520" Opacity="0.6" TextAlignment="Center"
Text="{Binding [ConsistEmpty], Source={x:Static starterNg:App.Loc}}" />
<ScrollViewer Name="consistScroll"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
HorizontalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Disabled"
Padding="0,0,0,4">
<Panel>
<!-- Tab reaches the strip once and lands on the selected
card; the arrow keys walk it from there. -->
<StackPanel Name="consistStack" Orientation="Horizontal"
KeyboardNavigation.TabNavigation="Once"
VerticalAlignment="Center" Spacing="0" />
<Canvas Name="consistOverlay" IsHitTestVisible="False" />
</Panel>

View File

@@ -7,6 +7,7 @@ using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
@@ -76,8 +77,11 @@ public partial class Depot : UserControl
_cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand,
RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag);
// Hidden still scrolls - it only drops the bar itself.
MiniTextures.Sharp(miniPreview);
_details = new VehicleDetails(
selectedVehiclePanel, generalPanel, consistTexturePanel, couplerPanel,
generalPanel, consistTexturePanel, couplerPanel, couplerActions,
brakesPanel, loadsPanel, damagePanel, removeVehicleButton,
_db, _info, _consist, _cargo, _hand,
RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo);
@@ -163,6 +167,9 @@ public partial class Depot : UserControl
_details.Refresh();
}
/// <summary>Redraws the consist cards, e.g. after the thumbnail size changed.</summary>
public void RefreshConsistView() => RebuildConsist();
private void RebuildConsist()
{
consistStack.Children.Clear();
@@ -183,6 +190,10 @@ public partial class Depot : UserControl
_details.Refresh();
UpdateTrainStats();
WriteBackToScenery();
// The cards are thrown away and rebuilt on every change, so a keyboard-driven
// edit has to be handed its card back or focus escapes the strip.
_cards.FocusSelectedCard(onlyIfRequested: true);
}
private void WriteBackToScenery()
@@ -232,9 +243,9 @@ public partial class Depot : UserControl
depotDrivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
_listScenery = AppState.Instance.CurrentScenery ?? _sceneries.FirstOrDefault();
mapConsistsPanel.Header = _listScenery != null
? $"{App.Loc["MapConsists"]}: {Path.GetFileNameWithoutExtension(_listScenery.Path)}"
: App.Loc["NoSceneryConsists"];
sceneryConsistsTab.Header = App.Loc["SceneryConsists"];
ToolTip.SetTip(sceneryConsistsTab,
_listScenery != null ? Path.GetFileNameWithoutExtension(_listScenery.Path) : App.Loc["NoSceneryConsists"]);
if (_listScenery != null)
{
@@ -425,6 +436,25 @@ public partial class Depot : UserControl
AddTexture(_browser.Selected);
}
/// <summary>Keyboard shortcut entry points. See <see cref="Infrastructure.Shortcuts"/>.</summary>
public void KeyAddVehicle() => AddVehicleButton_OnClick(null, null!);
public void KeyRemoveVehicle() => RemoveVehicleButton_OnClick(null, null!);
public void KeyClearConsist()
{
if (_consist.EditingTrainset is { } ts && ts.Vehicles.Count > 0)
RemoveAllVehicles(ts);
}
public void KeySetWagonNumber() => _cards.ShowNumberFlyoutForSelection();
public void KeyFocusSearch()
{
searchBox.Focus();
searchBox.SelectAll();
}
public void ToolAddAllCategory() => AddAllCategoryButton_OnClick(null, null!);
public void ToolAddUniqueMmd() => AddUniqueMmdButton_OnClick(null, null!);

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.IO;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using StarterNG.Classes;
@@ -10,6 +12,18 @@ public sealed class MiniTextures
{
private readonly Dictionary<string, Bitmap?> _cache = new();
/// <summary>
/// Draws a mini without smoothing. These files are about 80x30 and get shown up
/// to 68 px tall, so bilinear just smears the upscale; nearest keeps the edges.
/// (Has to be set in code - Avalonia 12 exposes RenderOptions as a struct with
/// Get/Set helpers, not as a styleable attached property.)
/// </summary>
public static Image Sharp(Image image)
{
RenderOptions.SetBitmapInterpolationMode(image, BitmapInterpolationMode.None);
return image;
}
public Bitmap? Get(string? name, int height, bool strict = false)
{
if (string.IsNullOrEmpty(name))
@@ -27,8 +41,17 @@ public sealed class MiniTextures
{
try
{
// Decode at native size and let the renderer scale. The minis are
// about 80x30, so DecodeToHeight would bake a 2x upscale into the
// cache - blurry, and five times the memory. Only shrink here.
using var fs = File.OpenRead(path);
bmp = Bitmap.DecodeToHeight(fs, height);
bmp = new Bitmap(fs);
if (bmp.PixelSize.Height > height)
{
bmp.Dispose();
fs.Position = 0;
bmp = Bitmap.DecodeToHeight(fs, height);
}
}
catch (System.Exception ex)
{

View File

@@ -222,13 +222,13 @@ public sealed class VehicleBrowser
};
var bmp = _minis.Get(cls, height);
if (bmp != null)
cell.Children.Add(new Image
cell.Children.Add(MiniTextures.Sharp(new Image
{
Source = bmp, Height = height,
Stretch = Stretch.Uniform,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
});
}));
cell.Children.Add(new TextBlock
{
Text = cls,
@@ -421,7 +421,7 @@ public sealed class VehicleBrowser
target.Children.Add(new TextBlock
{
Text = line,
FontSize = 10,
FontSize = 12,
Opacity = 0.85,
TextTrimming = TextTrimming.CharacterEllipsis
});

View File

@@ -8,6 +8,9 @@ public static class VehicleCardStyle
public static readonly IBrush BorderSelected = new SolidColorBrush(Color.Parse("#AA41C400"));
public static readonly IBrush BackgroundSelected = new SolidColorBrush(Color.Parse("#2241C400"));
/// <summary>Lit state of the flip button on a reversed vehicle.</summary>
public static readonly IBrush Flipped = new SolidColorBrush(Color.Parse("#41C400"));
public static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080"));
public static int ThumbHeight =>

View File

@@ -7,6 +7,8 @@ using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using Material.Icons;
using Material.Icons.Avalonia;
using StarterNG.Classes;
using StarterNG.Domain;
@@ -75,7 +77,10 @@ public sealed class VehicleCards
?? _minis.Get(car.MiniName, VehicleCardStyle.ThumbHeight);
if (bmp != null)
{
var img = new Image { Source = bmp, Height = VehicleCardStyle.ThumbHeight, Stretch = Stretch.Uniform };
var img = MiniTextures.Sharp(new Image
{
Source = bmp, Height = VehicleCardStyle.ThumbHeight, Stretch = Stretch.Uniform
});
if (item.Flipped)
{
img.RenderTransform = new ScaleTransform(-1, 1);
@@ -101,57 +106,50 @@ public sealed class VehicleCards
}
}
var nameBlock = new TextBlock
{
Text = item.Grouped && item.Cars.Count > 1 ? Consist.UnitLabel(item) : string.Empty,
FontSize = 11,
MaxWidth = Math.Max(120, item.Cars.Count * 96),
TextWrapping = TextWrapping.Wrap,
TextAlignment = TextAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
IsVisible = item.Grouped && item.Cars.Count > 1
};
var controls = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 2,
HorizontalAlignment = HorizontalAlignment.Center
Spacing = 3,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 1, 0, 2)
};
controls.Children.Add(SmallButton("◀", App.Loc["TipMoveLeft"], () => _consist.MoveLeft(item)));
controls.Children.Add(DriverButton(item));
controls.Children.Add(SmallButton("⇄", App.Loc["TipFlip"], () => _consist.Flip(item)));
controls.Children.Add(SmallButton("▶", App.Loc["TipMoveRight"], () => _consist.MoveRight(item)));
controls.Children.Add(FlipButton(item));
if (item.Grouped && item.Cars.Count > 1)
controls.Children.Add(SmallButton("⧉", App.Loc["TipSplit"], () => _consist.Split(item)));
controls.Children.Add(SmallButton(MaterialIconKind.CallSplit, App.Loc["TipSplit"], () => _consist.Split(item)));
else if ((index + 1 < _consist.Count &&
(Consist.HoldsTail(item) || _consist.CanFormUnit(item, _consist[index + 1]))) ||
(index > 0 && Consist.HoldsTail(_consist[index - 1])))
controls.Children.Add(SmallButton("⛓", App.Loc["TipJoin"], () => _consist.Join(item)));
controls.Children.Add(SmallButton(MaterialIconKind.Link, App.Loc["TipJoin"], () => _consist.Join(item)));
var remove = SmallButton("✕", App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item));
var remove = SmallButton(MaterialIconKind.Close, App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item));
remove.Foreground = new SolidColorBrush(Color.Parse("#E05252"));
remove.FontWeight = FontWeight.Bold;
controls.Children.Add(remove);
var inner = new StackPanel { Spacing = 4 };
var inner = new StackPanel { Spacing = 3 };
inner.Children.Add(minis);
inner.Children.Add(nameBlock);
inner.Children.Add(controls);
var card = new Border
{
Margin = new Thickness(2, 0),
Padding = new Thickness(6),
Margin = new Thickness(3, 2, 3, 6),
Padding = new Thickness(selected ? 4 : 5),
CornerRadius = new CornerRadius(6),
BorderThickness = new Thickness(selected ? 2 : 1),
BorderBrush = selected ? VehicleCardStyle.BorderSelected : VehicleCardStyle.Border,
Background = selected ? VehicleCardStyle.BackgroundSelected : Brushes.Transparent,
Cursor = _hand,
Child = inner,
Tag = item
Tag = item,
// Focusable so the strip can be reached with Tab and walked with the
// arrow keys; the focus ring itself comes from Carbon.axaml.
Focusable = true
};
card.Classes.Add("ConsistCard");
card.GotFocus += (_, _) => Select(item, null);
card.KeyDown += (_, e) => OnCardKey(card, item, e);
card.PointerPressed += (_, e) =>
{
@@ -167,6 +165,7 @@ public sealed class VehicleCards
_consist.SyncStartingVehicle();
RefreshCardSelectionChrome();
_refreshDetails();
_selectInBrowser(_consist.SelectedCar);
_armDrag(index, e);
};
@@ -178,31 +177,30 @@ public sealed class VehicleCards
TrainsetDisplay.VehicleLabel(car,
head: index == 0 && ReferenceEquals(car, Consist.HeadCar(item)) && TrainsetDisplay.IsPowered(car));
/// <summary>
/// The name is not printed under the thumbnail - it costs a row on every card
/// and the tooltip says the same thing when it is actually wanted.
/// </summary>
private Control MemberFrame(Control visual, ConsistItem item, Dynamic car, string caption)
{
var stack = new StackPanel { Spacing = 2 };
stack.Children.Add(visual);
stack.Children.Add(new TextBlock
{
Text = caption,
FontSize = 10,
MaxWidth = 96,
TextWrapping = TextWrapping.Wrap,
TextAlignment = TextAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
});
var frame = new Border
{
Child = stack,
Child = visual,
Padding = new Thickness(1),
BorderThickness = new Thickness(0, 2),
BorderBrush = Brushes.Transparent,
Cursor = _hand
};
var lines = new List<string> { caption };
if (!string.IsNullOrWhiteSpace(car.Name) && car.Name != caption)
ToolTip.SetTip(frame, car.Name);
lines.Add(car.Name);
if (item.Grouped && item.Cars.Count > 1)
lines.Add(Consist.UnitLabel(item));
string tip = string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l)));
if (tip.Length > 0)
ToolTip.SetTip(frame, tip);
var picked = car;
frame.PointerPressed += (_, _) => PressedCar = picked;
@@ -233,12 +231,154 @@ public sealed class VehicleCards
if (child is not Border { Tag: ConsistItem ci } border)
continue;
bool sel = ReferenceEquals(ci, _consist.Selected);
border.Padding = new Thickness(sel ? 4 : 5);
border.BorderThickness = new Thickness(sel ? 2 : 1);
border.BorderBrush = sel ? VehicleCardStyle.BorderSelected : VehicleCardStyle.Border;
border.Background = sel ? VehicleCardStyle.BackgroundSelected : Brushes.Transparent;
}
}
/// <summary>
/// Selects an item without going through a redraw. <paramref name="car"/> picks the
/// active member of a multi-vehicle unit; null keeps the current one when possible.
/// </summary>
private void Select(ConsistItem item, Dynamic? car)
{
var target = car ?? (ReferenceEquals(_consist.Selected, item)
? _consist.ActiveCar(item)
: item.Cars[0]);
if (ReferenceEquals(_consist.Selected, item) && ReferenceEquals(_consist.SelectedCar, target))
return;
_consist.Selected = item;
_consist.SelectedCar = target;
_consist.SyncStartingVehicle();
RefreshCardSelectionChrome();
_refreshDetails();
_selectInBrowser(target);
}
private Border? CardFor(ConsistItem item) =>
consistStack.Children.OfType<Border>()
.FirstOrDefault(b => ReferenceEquals(b.Tag, item));
/// <summary>
/// Set before an action that rebuilds the strip, so focus lands back on the card
/// instead of falling out to whatever comes next in the tab order.
/// </summary>
private bool _refocusAfterRedraw;
public void FocusSelectedCard(bool onlyIfRequested = false)
{
if (onlyIfRequested && !_refocusAfterRedraw)
return;
_refocusAfterRedraw = false;
if (_consist.Selected is { } item && CardFor(item) is { } card)
{
card.Focus();
card.BringIntoView();
}
}
private void Redraw(bool keepFocus)
{
_refocusAfterRedraw = keepFocus;
_redraw();
}
private void OnCardKey(Border card, ConsistItem item, KeyEventArgs e)
{
int index = _consist.IndexOf(item);
if (index < 0)
return;
bool ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
switch (e.Key)
{
case Key.Left when ctrl:
_consist.MoveLeft(item);
Redraw(keepFocus: true);
break;
case Key.Right when ctrl:
_consist.MoveRight(item);
Redraw(keepFocus: true);
break;
case Key.Left:
case Key.Right:
FocusCardAt(index + (e.Key == Key.Left ? -1 : 1));
break;
case Key.Home:
FocusCardAt(0);
break;
case Key.End:
FocusCardAt(_consist.Count - 1);
break;
// Within a coupled unit the arrow keys pick which member the detail
// panels talk about - the same thing clicking a thumbnail does.
case Key.Up:
case Key.Down:
StepMember(item, e.Key == Key.Down ? 1 : -1);
break;
case Key.Space:
_consist.Flip(item);
Redraw(keepFocus: true);
break;
case Key.Enter:
card.ContextMenu?.Open(card);
break;
default:
return;
}
e.Handled = true;
}
private void FocusCardAt(int index)
{
if (index < 0 || index >= _consist.Count)
return;
var item = _consist[index];
Select(item, null);
if (CardFor(item) is { } card)
{
card.Focus();
// Focusing alone does not scroll a plain Border into view.
card.BringIntoView();
}
}
private void StepMember(ConsistItem item, int delta)
{
if (item.Cars.Count < 2)
return;
int at = item.Cars.IndexOf(_consist.ActiveCar(item));
int next = Math.Clamp(at + delta, 0, item.Cars.Count - 1);
if (next != at)
Select(item, item.Cars[next]);
}
/// <summary>Ctrl+N on the selected card, from the window's shortcut table.</summary>
public void ShowNumberFlyoutForSelection()
{
if (_consist.Selected is not { } item || CardFor(item) is not { } card)
return;
ShowWagonNumberFlyout(card, _consist.ActiveCar(item));
}
private void ShowWagonNumberFlyout(Control anchor, Dynamic car)
{
var num = new NumericUpDown
@@ -276,7 +416,7 @@ public sealed class VehicleCards
};
var coupler = new Button
{
Padding = new Thickness(2),
Padding = new Thickness(4, 6, 4, 6),
MinWidth = 0,
Cursor = _hand,
VerticalAlignment = VerticalAlignment.Center,
@@ -288,7 +428,7 @@ public sealed class VehicleCards
}
};
coupler.Classes.Add("Basic");
ToolTip.SetTip(coupler, App.Loc["Coupling"]);
ToolTip.SetTip(coupler, $"{App.Loc["Coupling"]} — {App.Loc["TipReorder"]}");
return coupler;
}
@@ -302,7 +442,7 @@ public sealed class VehicleCards
var copy = new Button
{
Content = "\u00AB",
FontSize = 13,
FontSize = 12,
Padding = new Thickness(6, 1),
Margin = new Thickness(12, 0, 0, 0),
IsEnabled = unitIdx > 0,
@@ -324,11 +464,20 @@ public sealed class VehicleCards
{
Text = App.Loc["Coupling"],
FontWeight = FontWeight.Bold,
FontSize = 11,
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center
});
panel.Children.Add(header);
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,*"),
RowSpacing = 2,
ColumnSpacing = 12
};
for (int r = 0; r < (CouplingBits.BitKeys.Length + 1) / 2; r++)
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
for (int i = 0; i < CouplingBits.BitKeys.Length; i++)
{
int bit = 1 << i;
@@ -336,17 +485,20 @@ public sealed class VehicleCards
{
Content = App.Loc[CouplingBits.BitKeys[i]],
IsChecked = d.Coupling.Has(bit),
FontSize = 11
FontSize = 12
};
check.IsCheckedChanged += (_, _) => d.Coupling.Set(bit, check.IsChecked == true);
panel.Children.Add(check);
Grid.SetRow(check, i / 2);
Grid.SetColumn(check, i % 2);
grid.Children.Add(check);
}
panel.Children.Add(grid);
var thermo = new CheckBox
{
Content = App.Loc["ThermoAmbient"],
IsChecked = d.Coupling.ThermoDynamic,
FontSize = 11,
FontSize = 12,
Margin = new Thickness(0, 2, 0, 0)
};
thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true;
@@ -355,18 +507,26 @@ public sealed class VehicleCards
return new Border { Padding = new Thickness(8), Child = panel };
}
private const string TextPresentation = "\uFE0E";
/// <summary>
/// Card buttons draw vector icons rather than glyphs - Windows renders half
/// the arrow and link characters as colour emoji, whatever the font asks for.
/// </summary>
private Button SmallButton(MaterialIconKind icon, string tip, Action onClick)
{
var btn = SmallButton(tip, onClick);
btn.Content = new MaterialIcon { Kind = icon, Width = 16, Height = 16 };
return btn;
}
private Button SmallButton(string glyph, string tip, Action onClick)
private Button SmallButton(string tip, Action onClick)
{
var btn = new Button
{
Content = glyph + TextPresentation,
FontSize = 15,
FontSize = 13,
Padding = new Thickness(0),
MinWidth = 0,
MinHeight = 0,
Width = 26,
Width = 28,
Height = 22,
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center,
@@ -378,9 +538,26 @@ public sealed class VehicleCards
return btn;
}
/// <summary>
/// Orientation is a state, not just an action, so the button carries it: lit means
/// this vehicle sits reversed. Reading it off the card beats a checkbox in a panel
/// two columns away, and the card is rebuilt on every flip so the paint is current.
/// </summary>
private Button FlipButton(ConsistItem item)
{
var btn = SmallButton(MaterialIconKind.SwapHorizontal, App.Loc["TipFlip"],
() => _consist.Flip(item));
if (item.Flipped)
{
btn.Foreground = VehicleCardStyle.Flipped;
ToolTip.SetTip(btn, $"{App.Loc["TipFlip"]} \u2014 {App.Loc["Reversed"]}");
}
return btn;
}
private Button DriverButton(ConsistItem item)
{
var btn = SmallButton("", App.Loc["TipDriver"], () => _consist.CycleDriver(item));
var btn = SmallButton(App.Loc["TipDriver"], () => _consist.CycleDriver(item));
btn.FontWeight = FontWeight.Bold;
_driverChrome.Add((btn, item));
PaintDriverButton(btn, item);

View File

@@ -10,16 +10,17 @@ using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Classes;
using StarterNG.Controls;
using StarterNG.Domain;
namespace StarterNG.Views;
public sealed class VehicleDetails
{
private readonly HeaderedContentControl selectedVehiclePanel;
private readonly StackPanel generalPanel;
private readonly StackPanel consistTexturePanel;
private readonly StackPanel couplerPanel;
private readonly Panel couplerActions;
private readonly StackPanel brakesPanel;
private readonly StackPanel loadsPanel;
private readonly StackPanel damagePanel;
@@ -36,17 +37,17 @@ public sealed class VehicleDetails
private readonly Action<VehicleTexture, StackPanel> _showTextureInfo;
public VehicleDetails(
HeaderedContentControl selectedVehiclePanel,
StackPanel generalPanel, StackPanel consistTexturePanel, StackPanel couplerPanel,
Panel couplerActions,
StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel,
Button removeVehicleButton,
VehicleDatabase db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
Action redraw, Action refreshChrome, Action<VehicleTexture, StackPanel> showTextureInfo)
{
this.selectedVehiclePanel = selectedVehiclePanel;
this.generalPanel = generalPanel;
this.consistTexturePanel = consistTexturePanel;
this.couplerPanel = couplerPanel;
this.couplerActions = couplerActions;
this.brakesPanel = brakesPanel;
this.loadsPanel = loadsPanel;
this.damagePanel = damagePanel;
@@ -67,31 +68,27 @@ public sealed class VehicleDetails
generalPanel.Children.Clear();
consistTexturePanel.Children.Clear();
couplerPanel.Children.Clear();
couplerActions.Children.Clear();
brakesPanel.Children.Clear();
loadsPanel.Children.Clear();
damagePanel.Children.Clear();
selectedVehiclePanel.Header = _consist.Selected is null
? App.Loc["SelectedVehicle"]
: $"{App.Loc["SelectedVehicle"]}: " +
(_consist.Selected.Grouped ? Consist.UnitLabel(_consist.Selected) : _consist.Selected.Cars[0].Name);
if (_consist.Selected != null && !_consist.Selected.Cars.Contains(_consist.SelectedCar!))
_consist.SelectedCar = _consist.Selected.Cars[0];
_refreshChrome();
loadsPanel.Children.Add(BuildConsistLoadTools());
if (_consist.Selected is null)
{
generalPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
consistTexturePanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
couplerPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
loadsPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
damagePanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
generalPanel.Children.Add(PanelNote(App.Loc["SelectVehicleHint"]));
consistTexturePanel.Children.Add(PanelNote(App.Loc["SelectVehicleHint"]));
couplerPanel.Children.Add(PanelNote(App.Loc["SelectVehicleHint"]));
brakesPanel.Children.Add(PanelNote(App.Loc["SelectVehicleHint"]));
loadsPanel.Children.Add(PanelNote(App.Loc["SelectVehicleHint"]));
damagePanel.Children.Add(PanelNote(App.Loc["SelectVehicleHint"]));
removeVehicleButton.IsEnabled = false;
// The whole-consist tools still apply with nothing selected.
AddConsistLoadSection();
return;
}
@@ -100,21 +97,27 @@ public sealed class VehicleDetails
if (_db.TextureForSkin(_consist.ActiveCar(_consist.Selected).SkinFile) is { } tex)
_showTextureInfo(tex, consistTexturePanel);
else
consistTexturePanel.Children.Add(DetailHint(App.Loc["NoTextureInfo"]));
consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"]));
removeVehicleButton.IsEnabled = true;
if (_consist.Selected.Cars.Count > 1)
{
couplerPanel.Children.Add(UnitScopeHint(App.Loc["CouplingUnitRear"]));
brakesPanel.Children.Add(UnitScopeHint(App.Loc["AppliesToUnit"]));
couplerPanel.Children.Add(PanelNote(App.Loc["CouplingUnitRear"]));
brakesPanel.Children.Add(PanelNote(App.Loc["AppliesToUnit"]));
}
BuildCouplerEditor(_consist.Selected);
BuildBrakesEditor(_consist.Selected);
BuildLoadsEditor(_consist.Selected);
BuildConfigExtras(_consist.Selected);
BuildDamageEditor(_consist.Selected);
// Configuration tab: what this vehicle is, what it carries, then the actions
// that reach past it - each under a header that says which is which.
loadsPanel.Children.Add(
Section(App.Loc["Vehicle"], BuildVehicleSection(_consist.Selected)));
loadsPanel.Children.Add(
Section(App.Loc["Load"], BuildLoadSection(_consist.Selected)));
AddConsistLoadSection();
}
private void BuildGeneralInfo(ConsistItem item)
@@ -122,6 +125,9 @@ public sealed class VehicleDetails
var car = _consist.ActiveCar(item);
var inv = CultureInfo.InvariantCulture;
if (!string.IsNullOrWhiteSpace(car.Name))
generalPanel.Children.Add(Reading(App.Loc["Name"], car.Name));
bool unit = item.Cars.Count > 1;
var members = item.Cars.Select(_info.PhysicsFor).Where(p => p != null).ToList();
@@ -132,49 +138,19 @@ public sealed class VehicleDetails
double? vmax = members.Count == 0 ? null
: unit ? members.Min(p => p!.VMax) : members[0]!.VMax;
generalPanel.Children.Add(InfoRow($"{App.Loc["Length"]} [m]:",
generalPanel.Children.Add(Reading($"{App.Loc["Length"]} [m]",
length?.ToString("0.##", inv)));
generalPanel.Children.Add(InfoRow($"{App.Loc["Mass"]} [t]:",
generalPanel.Children.Add(Reading($"{App.Loc["Mass"]} [t]",
mass?.ToString("0.#", inv)));
generalPanel.Children.Add(InfoRow($"{App.Loc["VMax"]} [km/h]:",
generalPanel.Children.Add(Reading($"{App.Loc["VMax"]} [km/h]",
vmax?.ToString("0", inv)));
if (unit)
generalPanel.Children.Add(InfoRow($"{App.Loc["VehicleCount"]}:",
generalPanel.Children.Add(Reading(App.Loc["VehicleCount"],
item.Cars.Count.ToString(inv)));
generalPanel.Children.Add(InfoRow($"{App.Loc["Model"]}:", car.MmdFile));
generalPanel.Children.Add(InfoRow($"{App.Loc["Texture"]}:", car.SkinFile));
}
private static Control InfoRow(string label, string? value)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,10,*") };
var caption = new TextBlock
{
Text = label,
FontSize = 12,
MinWidth = 110,
TextAlignment = TextAlignment.Right,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
};
var reading = new TextBlock
{
Text = string.IsNullOrWhiteSpace(value) ? "–" : value,
FontSize = 12,
TextAlignment = TextAlignment.Right,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
Grid.SetColumn(reading, 2);
grid.Children.Add(caption);
grid.Children.Add(reading);
return grid;
generalPanel.Children.Add(Reading(App.Loc["Model"], car.MmdFile));
generalPanel.Children.Add(Reading(App.Loc["Texture"], car.SkinFile));
}
private void BuildCouplerEditor(ConsistItem item)
@@ -187,12 +163,17 @@ public sealed class VehicleDetails
var d = Consist.TailCar(item);
var grid = new Grid
// Two columns: the tab is wide enough and the list is short enough
// to avoid scrolling while still reading left-to-right, top-to-bottom.
var list = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,8,*"),
RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto"),
RowSpacing = 4
ColumnDefinitions = new ColumnDefinitions("*,*"),
RowSpacing = 2,
ColumnSpacing = 16
};
for (int r = 0; r < (CouplingBits.BitKeys.Length + 1) / 2; r++)
list.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
for (int i = 0; i < CouplingBits.BitKeys.Length; i++)
{
int bit = 1 << i;
@@ -207,17 +188,16 @@ public sealed class VehicleDetails
d.Coupling.Set(bit, check.IsChecked == true);
_redraw();
};
int half = (CouplingBits.BitKeys.Length + 1) / 2;
Grid.SetColumn(check, i < half ? 0 : 2);
Grid.SetRow(check, i % half);
grid.Children.Add(check);
Grid.SetRow(check, i / 2);
Grid.SetColumn(check, i % 2);
list.Children.Add(check);
}
couplerPanel.Children.Add(grid);
couplerPanel.Children.Add(list);
var copy = new Button
{
Content = App.Loc["CopyCoupler"],
FontSize = 11,
FontSize = 12,
Cursor = _hand,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
@@ -235,7 +215,7 @@ public sealed class VehicleDetails
var auto = new Button
{
Content = App.Loc["AutoCoupler"],
FontSize = 11,
FontSize = 12,
Cursor = _hand,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
@@ -250,19 +230,23 @@ public sealed class VehicleDetails
Refresh();
};
var buttons = new Grid
// Laid out horizontally under the checkbox list so the grid can use the full tab width
var buttons = new StackPanel
{
ColumnDefinitions = new ColumnDefinitions("125*,4,48*"),
Orientation = Orientation.Horizontal,
Spacing = 4,
Margin = new Thickness(0, 6, 0, 0)
};
Grid.SetColumn(copy, 0);
Grid.SetColumn(auto, 2);
buttons.Children.Add(copy);
buttons.Children.Add(auto);
couplerPanel.Children.Add(buttons);
couplerActions.Children.Add(buttons);
}
private void BuildConfigExtras(ConsistItem item)
/// <summary>
/// The vehicle's own settings. Every write path is unchanged: crew and wagon number
/// go through the unit's active car, the coolant flag to its first car only.
/// </summary>
private Control BuildVehicleSection(ConsistItem item)
{
var driver = new ComboBox { FontSize = 12, MinWidth = 0 };
foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" })
@@ -296,75 +280,143 @@ public sealed class VehicleDetails
};
driver.MinWidth = 0;
driver.HorizontalAlignment = HorizontalAlignment.Stretch;
loadsPanel.Children.Insert(0, LabeledRow(App.Loc["CrewLabel"], driver, labelWidth: 70));
var reversed = new CheckBox
{
Content = App.Loc["Reversed"],
IsChecked = item.Flipped,
FontSize = 11
};
reversed.IsCheckedChanged += (_, _) =>
{
if ((reversed.IsChecked == true) != item.Flipped)
_consist.Flip(item);
};
var rows = new StackPanel();
rows.Children.Add(Row(App.Loc["CrewLabel"], driver));
if (IsPassengerVehicle(item))
{
var lead = item.Cars[0];
var numBox = new NumericUpDown
{
FontSize = 12,
Minimum = 0,
Maximum = 99999,
Increment = 1,
Value = Consist.GetWagonNumber(lead),
FormatString = "0"
};
numBox.ValueChanged += (_, _) =>
{
_consist.SetWagonNumber(lead, (int)(numBox.Value ?? 0));
_redraw();
};
rows.Children.Add(Row(App.Loc["WagonNumber"], numBox));
}
// Orientation is not here any more: it reads and toggles off the flip button on
// the vehicle's own card, which lights up when the vehicle sits reversed.
// The flag rides on the unit's first car, so that is the car that has to own a
// diesel engine for it to mean anything: the simulator runs its heat model only
// for DieselEngine and DieselElectric, and on anything else ".TA" is written into
// the scenario and then ignored. Shown anyway when the flag is already set, so an
// inherited one stays visible and removable.
var d = item.Cars[0];
var thermo = new CheckBox
{
Content = App.Loc["ThermoAmbient"],
IsChecked = d.Coupling.ThermoDynamic,
FontSize = 11
};
thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true;
bool thermoApplies = _info.PhysicsFor(d)?.IsDieselEngine == true
|| d.Coupling.ThermoDynamic;
var switches = new Grid
if (thermoApplies)
{
ColumnDefinitions = new ColumnDefinitions("*,8,*"),
Margin = new Thickness(0, 4, 0, 0)
};
Grid.SetColumn(thermo, 2);
switches.Children.Add(reversed);
switches.Children.Add(thermo);
loadsPanel.Children.Add(switches);
var thermo = new CheckBox
{
Content = App.Loc["ThermoAmbient"],
IsChecked = d.Coupling.ThermoDynamic,
FontSize = 12
};
thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true;
// Label-less row, so the box indents to the control column with everything
// else instead of starting at the panel's left edge on its own.
rows.Children.Add(Row(content: thermo));
}
return rows;
}
private static TextBlock UnitScopeHint(string text) => new()
private bool IsPassengerVehicle(ConsistItem item)
{
var lead = _consist.ActiveCar(item);
var phys = _info.PhysicsFor(lead);
if (phys == null)
return false;
return phys.LoadAccepted.Split(',', ';')
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.Any(s => s.Equals("passengers", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// The one row shape every detail tab uses. The label column negotiates its width
/// through the shared-size group on the host panel, so all rows in a panel line up
/// without anyone naming a pixel count - see the Compact styles in Settings.axaml.
/// Omitting <paramref name="content"/> makes it a read-only line; omitting
/// <paramref name="label"/> indents a bare widget to the control column.
/// </summary>
private static SettingRow Row(string? label = null, Control? content = null,
string? value = null, string? description = null,
string? tip = null)
{
var row = new SettingRow
{
Label = label,
Description = description,
ValueText = value,
Content = content
};
row.Classes.Add("Compact");
if (!string.IsNullOrWhiteSpace(tip))
ToolTip.SetTip(row, tip);
return row;
}
/// <summary>
/// A read-only label/value line. The dash keeps the row from collapsing when a
/// vehicle has no reading for the field.
/// </summary>
private static SettingRow Reading(string label, string? value)
{
bool empty = string.IsNullOrWhiteSpace(value);
return Row(label, value: empty ? "\u2013" : value, tip: empty ? null : value);
}
private static SettingsSection Section(string header, Control content, Control? meta = null)
{
var section = new SettingsSection
{
Header = header,
Content = content,
// Passive text only: Toggle sits outside the logical tree, so nothing put
// here can be found later with GetLogicalDescendants.
Toggle = meta
};
section.Classes.Add("Compact");
return section;
}
private static StackPanel Stack(params Control[] children)
{
var panel = new StackPanel { Spacing = 3 };
foreach (var c in children)
panel.Children.Add(c);
return panel;
}
private static readonly IBrush DimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
/// <summary>
/// A note that governs a whole panel or section rather than one row - same look as a
/// row's description line. Replaces the old DetailHint/UnitScopeHint pair, which
/// differed only by a 2 px margin.
/// </summary>
private static TextBlock PanelNote(string text) => new()
{
Text = text,
FontSize = 11,
Opacity = 0.6,
FontSize = 12,
Foreground = DimBrush,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 0, 0, 2)
Margin = new Thickness(0, 0, 0, 8)
};
private static TextBlock DetailHint(string text) => new()
{
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
};
private static Control LabeledRow(string label, Control control, double labelWidth = 120)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,8,*") };
var caption = new TextBlock
{
Text = label,
FontSize = 12,
MinWidth = labelWidth,
TextAlignment = TextAlignment.Right,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
};
control.HorizontalAlignment = HorizontalAlignment.Stretch;
Grid.SetColumn(control, 2);
grid.Children.Add(caption);
grid.Children.Add(control);
return grid;
}
private static void AddOption(ComboBox combo, string content, string? tag)
=> combo.Items.Add(new ComboBoxItem { Content = content, Tag = tag });
@@ -402,9 +454,9 @@ public sealed class VehicleDetails
switchCombo.SelectionChanged += (_, _) => Apply();
loadCombo.SelectionChanged += (_, _) => Apply();
brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeMode"], modeCombo, labelWidth: 96));
brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeSwitch"], switchCombo, labelWidth: 96));
brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeLoad"], loadCombo, labelWidth: 96));
brakesPanel.Children.Add(Row(App.Loc["BrakeMode"], modeCombo));
brakesPanel.Children.Add(Row(App.Loc["BrakeSwitch"], switchCombo));
brakesPanel.Children.Add(Row(App.Loc["BrakeLoad"], loadCombo));
}
private static string BrakeModeLabel(string? code) => code switch
@@ -413,7 +465,6 @@ public sealed class VehicleDetails
"P" => App.Loc["BrakePassenger"],
"R" => App.Loc["BrakeExpress"],
"M" => App.Loc["BrakeExpressMg"],
"Q" => App.Loc["BrakeNoAir"],
_ => App.Loc["BrakeActingNone"]
};
@@ -427,8 +478,12 @@ public sealed class VehicleDetails
private static string SwitchLabel(string? code) => code switch
{
"<>" => App.Loc["SwitchEmergencyTest"],
"0" => App.Loc["SwitchOff"],
"1" => App.Loc["SwitchOff10"],
"X" => App.Loc["SwitchAgonal"],
"E" => App.Loc["SwitchEmptied"],
"Q" => App.Loc["BrakeNoAir"],
_ => App.Loc["SwitchNormal"]
};
@@ -439,7 +494,7 @@ public sealed class VehicleDetails
NumericUpDown Spin(int value, int max) => new()
{
FontSize = 12, Minimum = 0, Maximum = max, Increment = 1,
Value = value, MinWidth = 0, Width = 100, FormatString = "0"
Value = value, MinWidth = 0, FormatString = "0"
};
var sway = Spin(w.Sway, 100);
@@ -465,84 +520,100 @@ public sealed class VehicleDetails
flatRand.ValueChanged += (_, _) => Apply();
flatProb.ValueChanged += (_, _) => Apply();
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,8,Auto"),
RowSpacing = 6
};
void Row(string label, Control control)
{
int r = grid.RowDefinitions.Count;
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
var caption = new TextBlock
{
Text = label, FontSize = 12,
TextWrapping = TextWrapping.Wrap,
TextAlignment = TextAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetRow(caption, r);
Grid.SetColumn(caption, 0);
control.HorizontalAlignment = HorizontalAlignment.Right;
Grid.SetRow(control, r);
Grid.SetColumn(control, 2);
grid.Children.Add(caption);
grid.Children.Add(control);
}
Row(App.Loc["DamageSway"], sway);
Row(App.Loc["DamageFlatness"], flat);
Row(App.Loc["DamageFlatnessRand"], flatRand);
Row(App.Loc["DamageFlatnessProb"], flatProb);
damagePanel.Children.Add(grid);
damagePanel.Children.Add(Row(App.Loc["DamageSway"], sway));
damagePanel.Children.Add(Row(App.Loc["DamageFlatness"], flat));
damagePanel.Children.Add(Row(App.Loc["DamageFlatnessRand"], flatRand));
damagePanel.Children.Add(Row(App.Loc["DamageFlatnessProb"], flatProb));
}
private Control BuildConsistLoadTools()
/// <summary>
/// The three actions that fill the selected vehicle from its neighbours. They keep
/// the primary (filled) look because they act on what you have selected.
/// </summary>
private Control BuildVehicleLoadTools(ConsistItem sel)
{
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) };
var row = new WrapPanel { Orientation = Orientation.Horizontal };
row.Children.Add(LoadToolButton(App.Loc["ConsistRandomType"],
() => { _cargo.RandomTypes(_consist); _redraw(); }));
row.Children.Add(LoadToolButton(App.Loc["ConsistMaxAmount"],
() => { _cargo.MaxAmounts(_consist); _redraw(); }));
row.Children.Add(LoadToolButton(App.Loc["ConsistRandomAmount"],
() => { _cargo.RandomAmounts(_consist); _redraw(); }));
panel.Children.Add(row);
return panel;
bool loadable = _cargo.IsLoadable(sel);
var prev = LoadToolButton(App.Loc["LoadPrevShort"],
() => { if (_consist.Selected is { } s) { _cargo.CopyFromPrevious(_consist, s); _redraw(); } });
prev.IsEnabled = loadable && _consist.IndexOf(sel) > 0;
ToolTip.SetTip(prev, App.Loc["LoadCopyPrev"]);
var max = LoadToolButton(App.Loc["LoadMaxShort"],
() => { if (_consist.Selected is { } s) { _cargo.FillUnit(s); _redraw(); } });
max.IsEnabled = loadable;
ToolTip.SetTip(max, App.Loc["LoadMax"]);
var toAll = LoadToolButton(App.Loc["LoadToAllShort"],
() => { if (_consist.Selected is { } s) { _cargo.CopyToFollowing(_consist, s); _redraw(); } });
toAll.IsEnabled = loadable;
ToolTip.SetTip(toAll, App.Loc["LoadCopyToAll"]);
return Stack(prev, max, toAll);
}
private Button LoadToolButton(string text, Action onClick)
/// <summary>
/// The whole-consist actions. Secondary styling plus a header that names the scope
/// and counts the vehicles, because these reach far past the selected card. No
/// tooltips: theirs used to repeat the button label word for word.
/// </summary>
private void AddConsistLoadSection()
{
if (_consist.Count == 0)
return;
var buttons = Stack(
LoadToolButton(App.Loc["ConsistRandomType"],
() => { _cargo.RandomTypes(_consist); _redraw(); }, secondary: true),
LoadToolButton(App.Loc["ConsistMaxAmount"],
() => { _cargo.MaxAmounts(_consist); _redraw(); }, secondary: true),
LoadToolButton(App.Loc["ConsistRandomAmount"],
() => { _cargo.RandomAmounts(_consist); _redraw(); }, secondary: true));
var count = new TextBlock
{
Text = $"{App.Loc["VehicleCount"]}: {_consist.Count}",
FontSize = 12,
Foreground = DimBrush,
VerticalAlignment = VerticalAlignment.Center
};
loadsPanel.Children.Add(Section(App.Loc["ConsistLoadTools"], buttons, count));
}
private Button LoadToolButton(string text, Action onClick, bool secondary = false)
{
var b = new Button
{
Content = text, FontSize = 11, Margin = new Thickness(0, 0, 4, 4),
Padding = new Thickness(8, 3), Cursor = _hand,
Content = text, FontSize = 12,
Padding = new Thickness(6, 2), Cursor = _hand,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
IsEnabled = _consist.Count > 0
};
b.Classes.Add("Flat");
b.Classes.Add(secondary ? "Outline" : "Flat");
b.Click += (_, _) => onClick();
return b;
}
private void BuildLoadsEditor(ConsistItem item)
/// <summary>
/// What the selected vehicle carries, and the actions that fill it. A vehicle that
/// takes no cargo gets the note alone - previously the three actions stayed behind,
/// greyed out and useless.
/// </summary>
private Control BuildLoadSection(ConsistItem item)
{
var lead = _consist.ActiveCar(item);
var available = _cargo.AcceptedTypes(lead);
if (available.Count == 0 && string.IsNullOrEmpty(lead.LoadType))
{
loadsPanel.Children.Add(DetailHint(App.Loc["LoadNotLoadable"]));
return;
}
return PanelNote(App.Loc["LoadNotLoadable"]);
if (!string.IsNullOrEmpty(lead.LoadType) &&
!available.Contains(lead.LoadType!, StringComparer.OrdinalIgnoreCase))
available.Insert(0, lead.LoadType!);
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 0 };
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 120 };
typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null });
foreach (var name in available)
typeCombo.Items.Add(new ComboBoxItem { Content = LoadWeights.Describe(name), Tag = name });
@@ -557,41 +628,129 @@ public sealed class VehicleDetails
break;
}
string? SelectedType() => (typeCombo.SelectedItem as ComboBoxItem)?.Tag as string;
bool IsPant() => Dynamic.IsPantStateType(SelectedType());
int limit = _cargo.LimitFor(lead);
var countBox = new NumericUpDown
{
FontSize = 12, Minimum = 0, Maximum = limit, Increment = 1,
Value = Math.Min(lead.LoadCount, limit), MinWidth = 120, FormatString = "0"
Value = Math.Min(lead.LoadCount, limit), FormatString = "0",
HorizontalAlignment = HorizontalAlignment.Stretch
};
var pantHint = DetailHint(App.Loc["LoadPantStateHint"]);
pantHint.IsVisible = lead.IsPantState;
var pantStateCombo = new ComboBox
{
FontSize = 12, MinWidth = 0,
HorizontalAlignment = HorizontalAlignment.Stretch
};
pantStateCombo.Items.Add(new ComboBoxItem { Content = App.Loc["PantStateNone"], Tag = 0 });
pantStateCombo.Items.Add(new ComboBoxItem { Content = "A", Tag = 1 });
pantStateCombo.Items.Add(new ComboBoxItem { Content = "B", Tag = 2 });
pantStateCombo.Items.Add(new ComboBoxItem { Content = App.Loc["PantStateBoth"], Tag = 3 });
string? SelectedType() => (typeCombo.SelectedItem as ComboBoxItem)?.Tag as string;
var countContainer = new Panel();
countContainer.Children.Add(countBox);
countContainer.Children.Add(pantStateCombo);
string UnitText()
{
var phys = _info.PhysicsFor(lead);
if (phys == null) return App.Loc["LoadUnitGeneric"];
if (string.Equals(phys.LoadQ, "pieces", StringComparison.OrdinalIgnoreCase))
return App.Loc["LoadUnitPieces"];
if (string.Equals(phys.LoadQ, "tonns", StringComparison.OrdinalIgnoreCase))
return App.Loc["LoadUnitTons"];
return App.Loc["LoadUnitGeneric"];
}
var countRow = Row(App.Loc["LoadCount"], countContainer);
void SyncPantStateFromCount()
{
int value = Math.Clamp((int)(countBox.Value ?? 0), 0, Dynamic.PantStateMax);
foreach (var obj in pantStateCombo.Items)
if (obj is ComboBoxItem ci && ci.Tag is int t && t == value)
{
pantStateCombo.SelectedItem = ci;
return;
}
}
void SyncCountFromPantState()
{
int value = pantStateCombo.SelectedItem is ComboBoxItem { Tag: int t } ? t : 0;
countBox.Value = Math.Clamp(value, 0, countBox.Maximum);
}
void UpdateVisibility()
{
bool pant = IsPant();
countBox.IsVisible = !pant;
pantStateCombo.IsVisible = pant;
// The unit slot is reserved by the Compact style even when empty, so the
// spinner does not jump sideways as the unit comes and goes.
countRow.Label = pant ? App.Loc["Pantograph"] : App.Loc["LoadCount"];
countRow.ValueText = pant ? null : UnitText();
// The "set an amount above 0" advice is only true while the amount is 0;
// past that it is a line of noise in a panel with no height to spare.
countRow.Description = pant ? App.Loc["LoadPantStateHint"]
: GetCount() == 0 ? App.Loc["LoadHint"]
: null;
}
int GetCount()
{
if (IsPant())
return pantStateCombo.SelectedItem is ComboBoxItem { Tag: int t } ? t : 0;
return (int)(countBox.Value ?? 0);
}
void Apply()
{
string? type = SelectedType();
int count = (int)(countBox.Value ?? 0);
int count = GetCount();
foreach (var c in _consist.MemberTargets(item))
{
c.LoadType = string.IsNullOrEmpty(type) ? null : type;
c.LoadCount = string.IsNullOrEmpty(type) ? 0 : Math.Min(count, _cargo.LimitFor(c, type));
}
}
// Seed the pantograph combo from the model before any handler is listening.
SyncPantStateFromCount();
bool wasPant = IsPant();
typeCombo.SelectionChanged += (_, _) =>
{
string? type = SelectedType();
pantHint.IsVisible = Dynamic.IsPantStateType(type);
countBox.Maximum = _cargo.LimitFor(lead, type);
countBox.Maximum = _cargo.LimitFor(lead, SelectedType());
// Both widgets edit the same LoadCount, so the value is carried across only
// when the mode actually flips. Doing it unconditionally - as UpdateVisibility
// used to - reset the amount to zero on every rebuild, which is why setting
// the maximum load appeared to do nothing: the model was written, the panel
// refreshed, and the refresh immediately wrote zero back.
bool pant = IsPant();
if (pant != wasPant)
{
if (pant)
SyncPantStateFromCount();
else
SyncCountFromPantState();
wasPant = pant;
}
UpdateVisibility();
Apply();
};
countBox.ValueChanged += (_, _) => Apply();
pantStateCombo.SelectionChanged += (_, _) => Apply();
countBox.ValueChanged += (_, _) => { Apply(); UpdateVisibility(); };
loadsPanel.Children.Add(LabeledRow(App.Loc["LoadType"], typeCombo));
loadsPanel.Children.Add(LabeledRow(App.Loc["LoadCount"], countBox));
loadsPanel.Children.Add(pantHint);
UpdateVisibility();
return Stack(
Row(App.Loc["LoadType"], typeCombo),
countRow,
BuildVehicleLoadTools(item));
}
}

View File

@@ -15,7 +15,8 @@
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="150" />
<!-- Measures itself, so a whole card fits at either thumbnail size. -->
<RowDefinition Height="Auto" MinHeight="120" MaxHeight="200" />
</Grid.RowDefinitions>
<HeaderedContentControl Grid.Column="0" Grid.Row="0" Theme="{StaticResource GroupBox}"
@@ -23,7 +24,7 @@
Header="{Binding [Sceneries], Source={x:Static starterNg:App.Loc}}">
<DockPanel LastChildFill="True">
<CheckBox DockPanel.Dock="Bottom" Name="archivalSwitch"
Margin="2,4,0,0" FontSize="11"
Margin="2,4,0,0" FontSize="12"
IsCheckedChanged="ArchivalSwitch_OnChanged"
Content="{Binding [HideArchivalSceneries], Source={x:Static starterNg:App.Loc}}" />
<TreeView Name="sceneryList" Classes="Compact"
@@ -54,7 +55,7 @@
Header="{Binding [SceneryConsists], Source={x:Static starterNg:App.Loc}}">
<HeaderedContentControl.Tag>
<Button Name="randomTexturesButton" Classes="Flat" Cursor="Hand"
FontSize="11" MinHeight="0" Padding="10,1"
FontSize="12" MinHeight="0" Padding="10,1"
Click="RandomTexturesButton_OnClick"
Content="{Binding [RandomizeTextures], Source={x:Static starterNg:App.Loc}}"
ToolTip.Tip="{Binding [TipRandomizeTextures], Source={x:Static starterNg:App.Loc}}" />
@@ -144,9 +145,9 @@
VerticalAlignment="Center" TextAlignment="Right"
FontWeight="SemiBold" />
<DockPanel Grid.Row="5" Grid.Column="2" Margin="0,-6,0,0">
<TextBlock DockPanel.Dock="Right" FontSize="11"
<TextBlock DockPanel.Dock="Right" FontSize="12"
Foreground="{StaticResource CarbonFgDim}" Text="+50 °C" />
<TextBlock FontSize="11"
<TextBlock FontSize="12"
Foreground="{StaticResource CarbonFgDim}" Text="-50 °C" />
</DockPanel>
@@ -161,10 +162,10 @@
VerticalAlignment="Center" TextAlignment="Right"
FontWeight="SemiBold" />
<DockPanel Grid.Row="8" Grid.Column="2" Margin="0,-6,0,0">
<TextBlock DockPanel.Dock="Right" FontSize="11"
<TextBlock DockPanel.Dock="Right" FontSize="12"
Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [VisibilityGood], Source={x:Static starterNg:App.Loc}}" />
<TextBlock FontSize="11" Foreground="{StaticResource CarbonFgDim}"
<TextBlock FontSize="12" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [VisibilityBad], Source={x:Static starterNg:App.Loc}}" />
</DockPanel>
@@ -217,9 +218,13 @@
<HeaderedContentControl Grid.Row="1" Grid.ColumnSpan="3" Theme="{StaticResource GroupBox}"
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
<!-- Mass, length and the rest ride in the title bar, same as in the
depot, instead of taking a row of their own above the cards. -->
<HeaderedContentControl.Tag>
<StackPanel Name="trainStats" Orientation="Horizontal"
VerticalAlignment="Center" />
</HeaderedContentControl.Tag>
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Top" Name="trainStats" Orientation="Horizontal"
VerticalAlignment="Center" Margin="0,0,0,6" />
<ScrollViewer Name="consistScroll"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<Panel>

View File

@@ -896,13 +896,13 @@ public partial class Scenarios : UserControl
var bmp = LoadMini(miniName, thumbH);
Control visual = bmp is not null
? new Image
? MiniTextures.Sharp(new Image
{
Source = bmp,
Height = thumbH,
Stretch = Avalonia.Media.Stretch.Uniform,
Margin = new Thickness(0)
}
})
: MissingMiniBox(train.SkinFile, thumbH);
bool selected = !string.IsNullOrEmpty(train.Name) &&

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,19 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml.Styling;
using Avalonia.Media;
using StarterNG.Classes;
using StarterNG.Controls;
using StarterNG.Infrastructure;
using StarterNG.Services;
using KeyBinding = StarterNG.Classes.KeyBinding;
@@ -22,6 +26,15 @@ public partial class Settings : UserControl
{
private bool _loading;
/// <summary>
/// Raised once a saved (or applied) setting changes something the other tabs
/// have already drawn, so they can redraw instead of waiting for a restart.
/// </summary>
public event Action? ThumbnailSizeChanged;
/// <summary>Thumbnail size the other tabs were last drawn at.</summary>
private bool _drawnThumbs = StarterNG.Classes.Settings.Instance.LargeThumbnails;
public Settings()
{
InitializeComponent();
@@ -41,6 +54,7 @@ public partial class Settings : UserControl
App.Loc.LanguageChanged += SelectActiveLanguage;
App.Loc.LanguageChanged += UpdateQualityCaptions;
App.Loc.LanguageChanged += UpdateSaveState;
App.Loc.LanguageChanged += () => shaderResolutionSlider_OnValueChanged(null, null);
FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged;
@@ -54,6 +68,10 @@ public partial class Settings : UserControl
KeyboardConfig.Instance.Load();
BuildControlsTab();
AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel);
HookDirtyTracking();
ApplyFilter();
UpdateSaveState();
}
private static int IndexOfTag(ComboBox cb, string? tag)
@@ -95,6 +113,7 @@ public partial class Settings : UserControl
if (!File.Exists(path)) return;
StarterNG.Classes.Settings.Instance.LoadFrom(path);
ApplyToUi();
RedrawThumbnailsIfNeeded();
}
private void ProfileSaveAsButton_OnClick(object? sender, RoutedEventArgs e)
@@ -232,10 +251,9 @@ public partial class Settings : UserControl
VehiclesVolumeSlider.Value = s.VehiclesVolume;
PositionalVolumeSlider.Value = s.PositionalVolume;
AmbientVolumeSlider.Value = s.AmbientVolume;
VolumePausedSlider.Value = s.VolumePaused;
SkipPipelineCb.IsChecked = s.SkipPipeline;
DebugLogVisibleCb.IsChecked = s.DebugLogVisible;
PyScreenPriorityCb.SelectedIndex = Math.Max(0, IndexOfTag(PyScreenPriorityCb, s.PyScreenPriority));
PyScreenPriorityCb.SelectedIndex = Math.Max(0, IndexOfTag(PyScreenPriorityCb, s.PythonScreenUpdateRate.ToString(CultureInfo.InvariantCulture)));
AutoCloseStarterCb.IsChecked = s.AutoCloseStarter;
LargeThumbnailsCb.IsChecked = s.LargeThumbnails;
@@ -327,10 +345,9 @@ public partial class Settings : UserControl
s.VehiclesVolume = (int)VehiclesVolumeSlider.Value;
s.PositionalVolume = (int)PositionalVolumeSlider.Value;
s.AmbientVolume = (int)AmbientVolumeSlider.Value;
s.VolumePaused = (int)VolumePausedSlider.Value;
s.SkipPipeline = IsChecked(SkipPipelineCb);
s.DebugLogVisible = IsChecked(DebugLogVisibleCb);
s.PyScreenPriority = (PyScreenPriorityCb.SelectedItem as ComboBoxItem)?.Tag as string ?? "normal";
s.PythonScreenUpdateRate = ParsePriorityTag((PyScreenPriorityCb.SelectedItem as ComboBoxItem)?.Tag);
s.AutoCloseStarter = IsChecked(AutoCloseStarterCb);
s.LargeThumbnails = IsChecked(LargeThumbnailsCb);
@@ -344,31 +361,39 @@ public partial class Settings : UserControl
new UartWindow().ShowDialog(owner);
}
private void AboutButton_OnClick(object? sender, RoutedEventArgs e)
{
if (TopLevel.GetTopLevel(this) is Window owner)
AboutWindow.Create().ShowDialog(owner);
}
private void SaveButton_OnClick(object? sender, RoutedEventArgs e)
{
ReadFromUi();
StarterNG.Classes.Settings.Instance.Save();
KeyboardConfig.Instance.Save();
RedrawThumbnailsIfNeeded();
_dirty = false;
UpdateSaveState();
if (SaveStatus is not null)
SaveStatus.Text = App.Loc["SettingsSaved"];
}
private void RedrawThumbnailsIfNeeded()
{
bool large = StarterNG.Classes.Settings.Instance.LargeThumbnails;
if (large == _drawnThumbs)
return;
_drawnThumbs = large;
ThumbnailSizeChanged?.Invoke();
}
private void ResetButton_OnClick(object? sender, RoutedEventArgs e)
{
StarterNG.Classes.Settings.Instance.Load();
ApplyToUi();
RedrawThumbnailsIfNeeded();
CancelCapture();
KeyboardConfig.Instance.Load();
RebuildBindingList();
RebuildKeyboard();
if (SaveStatus is not null)
SaveStatus.Text = string.Empty;
_dirty = false;
UpdateSaveState();
}
private void SelectResolution(int width, int height)
@@ -404,29 +429,29 @@ public partial class Settings : UserControl
private void TextureResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (texResolution is null || textureResolutionSlider is null)
if (TexResolutionRow is null || textureResolutionSlider is null)
return;
int resolution = 1 << (int)textureResolutionSlider.Value;
texResolution.Text = $"{resolution} px";
TexResolutionRow.ValueText = $"{resolution} px";
}
private void CabTextureResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (cabTexResolution is null || cabTextureResolutionSlider is null)
if (CabTexResolutionRow is null || cabTextureResolutionSlider is null)
return;
int resolution = 1 << (int)cabTextureResolutionSlider.Value;
cabTexResolution.Text = $"{resolution} px";
CabTexResolutionRow.ValueText = $"{resolution} px";
}
private void shaderResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (shaderResolution is null || shaderResolutionSlider is null)
if (ShadowResolutionRow is null || shaderResolutionSlider is null)
return;
int resolution = 1 << (int)shaderResolutionSlider.Value;
shaderResolution.Text = $"{resolution} px";
ShadowResolutionRow.ValueText = $"{resolution} px";
ToolTip.SetTip(shaderResolutionSlider, ShadowMapWord(resolution));
}
@@ -464,16 +489,196 @@ public partial class Settings : UserControl
private void FeedbackCb_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) =>
UpdateFeedbackDetailVisibility();
private void UpdateFeedbackDetailVisibility()
/// <summary>
/// The port and UART rows only apply to some feedback modes. Visibility is
/// decided in one place — <see cref="ApplyFilter"/> — so that a search cannot
/// reveal a row the selected mode does not use.
/// </summary>
private void UpdateFeedbackDetailVisibility() => ApplyFilter();
private bool ConditionAllows(SettingRow row)
{
FeedbackPortRow.IsVisible = FeedbackCb.SelectedIndex == 3;
ComButton.IsVisible = FeedbackCb.SelectedIndex == 5;
if (ReferenceEquals(row, FeedbackPortRow))
return FeedbackCb.SelectedIndex == 3;
if (ReferenceEquals(row, ComButtonRow))
return FeedbackCb.SelectedIndex == 5;
return true;
}
// ── Category navigation, search and unsaved-changes state ────────────────
private bool _dirty;
private void CategoryList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
// Picking a category is how you leave a search, so the query goes with it.
if (SettingsSearch is { Text.Length: > 0 })
SettingsSearch.Text = string.Empty; // raises TextChanged -> ApplyFilter
else
ApplyFilter();
}
private void SettingsSearch_OnTextChanged(object? sender, TextChangedEventArgs e) => ApplyFilter();
/// <summary>
/// Single owner of what is on screen. With an empty query it shows the selected
/// category; with a query it stacks every category and keeps only the rows that
/// match, collapsing sections and categories that end up empty.
/// </summary>
private void ApplyFilter()
{
if (SettingsStack is null || CategoryList is null || ControlsHost is null)
return;
string query = SettingsSearch?.Text?.Trim() ?? string.Empty;
bool searching = query.Length > 0;
string category = (CategoryList.SelectedItem as ListBoxItem)?.Tag as string ?? string.Empty;
// Key bindings are their own world: they bring their own scrolling and a
// star-sized row, so they replace the settings scroller rather than living
// in it, and the search box filters them instead of the settings.
bool bindings = category == "controls";
ControlsHost.IsVisible = bindings;
SettingsScroll.IsVisible = !bindings;
if (bindings)
{
RebuildBindingList();
foreach (var entry in CategoryList.Items.OfType<ListBoxItem>())
entry.Classes.Set("NoHits", false);
return;
}
foreach (var panel in SettingsStack.Children.OfType<StackPanel>())
{
bool anyRow = false;
foreach (var section in panel.GetLogicalDescendants().OfType<SettingsSection>())
{
bool anyInSection = false;
foreach (var row in section.GetLogicalDescendants().OfType<SettingRow>())
{
bool visible = ConditionAllows(row) &&
(!searching || TextMatch.Contains(RowSearchText(row), query));
row.IsVisible = visible;
anyInSection |= visible;
}
section.IsVisible = anyInSection;
anyRow |= anyInSection;
}
string tag = panel.Tag as string ?? string.Empty;
panel.IsVisible = anyRow && (searching || tag == category);
// The caption only earns its place when several categories are stacked.
foreach (var caption in panel.Children.OfType<TextBlock>())
caption.IsVisible = searching;
if (CategoryItem(tag) is { } item)
item.Classes.Set("NoHits", searching && !anyRow);
}
if (searching)
SettingsScroll.Offset = new Vector(0, 0);
}
private ListBoxItem? CategoryItem(string tag) =>
CategoryList.Items.OfType<ListBoxItem>()
.FirstOrDefault(i => string.Equals(i.Tag as string, tag, StringComparison.Ordinal));
/// <summary>
/// Everything about a row a user might type: its label, its explanation and
/// the text of the control itself (a checkbox caption, a dropdown's options).
/// </summary>
private static string RowSearchText(SettingRow row)
{
var sb = new StringBuilder();
sb.Append(row.Label).Append(' ').Append(row.Description).Append(' ');
switch (row.Content)
{
case CheckBox cb:
sb.Append(cb.Content);
break;
case Button btn:
sb.Append(btn.Content);
break;
case ComboBox combo:
foreach (var item in combo.Items.OfType<ComboBoxItem>())
sb.Append(item.Content).Append(' ');
break;
}
return sb.ToString();
}
/// <summary>
/// Subscribes every input in the panel to <see cref="MarkDirty"/> once, so the
/// Save button can tell the user there is something to save. Section master
/// switches hang off SettingsSection.Toggle rather than the logical tree, so
/// they are collected separately.
/// </summary>
private void HookDirtyTracking()
{
foreach (var control in this.GetLogicalDescendants().OfType<Control>())
{
switch (control)
{
// Choosing a profile changes nothing until Apply is pressed.
case ComboBox combo when ReferenceEquals(combo, ProfileCb):
break;
case CheckBox cb:
cb.IsCheckedChanged += (_, _) => MarkDirty();
break;
case Slider slider:
slider.ValueChanged += (_, _) => MarkDirty();
break;
case ComboBox combo:
combo.SelectionChanged += (_, _) => MarkDirty();
break;
case NumericUpDown nud:
nud.ValueChanged += (_, _) => MarkDirty();
break;
}
}
foreach (var section in this.GetLogicalDescendants().OfType<SettingsSection>())
if (section.Toggle is CheckBox toggle)
toggle.IsCheckedChanged += (_, _) => MarkDirty();
}
private void MarkDirty()
{
if (_loading || _dirty)
return;
_dirty = true;
UpdateSaveState();
}
private void UpdateSaveState()
{
if (SaveButton is null || SaveStatus is null)
return;
bool dirty = _dirty || KeyboardConfig.Instance.Dirty;
SaveButton.Classes.Set("Accent", dirty);
SaveStatus.Text = dirty ? App.Loc["UnsavedChanges"] : string.Empty;
}
private static bool IsChecked(CheckBox cb) => cb.IsChecked == true;
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
private static int ParsePriorityTag(object? tag)
{
if (tag is int i) return i;
if (tag is string s && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)) return n;
return 200;
}
private static int Log2(int value, int fallback)
{
if (value <= 0) return fallback;
@@ -490,10 +695,15 @@ public partial class Settings : UserControl
return 4;
}
private TextBox? _controlsSearch;
private StackPanel? _bindingListPanel;
private StackPanel? _keyboardPanel;
/// <summary>
/// True while a cab-control binding is waiting for a keypress. The window's own
/// shortcuts stand down for the duration, otherwise Ctrl+N would never reach here.
/// </summary>
public bool IsCapturingKey => _capturing is not null;
private KeyBinding? _capturing;
private Button? _capturingButton;
@@ -527,10 +737,6 @@ public partial class Settings : UserControl
VerticalAlignment = VerticalAlignment.Center,
Foreground = FgDimBrush
});
_controlsSearch = new TextBox { Width = 240, PlaceholderText = App.Loc["Search"] };
_controlsSearch.TextChanged += (_, _) => RebuildBindingList();
topBar.Children.Add(_controlsSearch);
var restoreBtn = new Button { Content = App.Loc["RestoreDefaults"] };
restoreBtn.Classes.Add("Flat");
restoreBtn.Click += (_, _) =>
@@ -538,6 +744,7 @@ public partial class Settings : UserControl
CancelCapture();
KeyboardConfig.Instance.LoadDefaults();
KeyboardConfig.Instance.Dirty = true;
MarkDirty();
RebuildBindingList();
RebuildKeyboard();
};
@@ -576,7 +783,8 @@ public partial class Settings : UserControl
return;
_bindingListPanel.Children.Clear();
string filter = _controlsSearch?.Text?.Trim().ToLowerInvariant() ?? string.Empty;
// The sidebar search box serves this list too - see ApplyFilter.
string filter = SettingsSearch?.Text?.Trim() ?? string.Empty;
var conflicts = ComputeConflicts();
foreach (var b in KeyboardConfig.Instance.Bindings)
@@ -588,8 +796,8 @@ public partial class Settings : UserControl
}
private static bool MatchesFilter(KeyBinding b, string filter) =>
b.Command.ToLowerInvariant().Contains(filter) ||
b.Description.ToLowerInvariant().Contains(filter);
TextMatch.Contains(b.Command, filter) ||
TextMatch.Contains(b.Description, filter);
private Control BuildBindingRow(KeyBinding b, HashSet<string> conflicts)
{
@@ -604,14 +812,14 @@ public partial class Settings : UserControl
{
Text = CommandLabel(b),
Foreground = FgBrush,
FontSize = 13,
FontSize = 12,
TextTrimming = TextTrimming.CharacterEllipsis
});
label.Children.Add(new TextBlock
{
Text = b.Command,
Foreground = FgDimBrush,
FontSize = 11,
FontSize = 12,
TextTrimming = TextTrimming.CharacterEllipsis
});
Grid.SetColumn(label, 0);
@@ -647,6 +855,7 @@ public partial class Settings : UserControl
b.Shift = b.Ctrl = false;
b.Key = "none";
KeyboardConfig.Instance.Dirty = true;
MarkDirty();
RebuildBindingList();
RebuildKeyboard();
};
@@ -710,6 +919,7 @@ public partial class Settings : UserControl
_capturing.Ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
_capturing.Key = token;
KeyboardConfig.Instance.Dirty = true;
MarkDirty();
e.Handled = true;
_capturing = null;
@@ -912,7 +1122,7 @@ public partial class Settings : UserControl
{
Text = App.Loc["BindKeyHint"],
Foreground = FgDimBrush,
FontSize = 11,
FontSize = 12,
TextWrapping = TextWrapping.Wrap
});
panel.Children.Add(grid);
@@ -957,6 +1167,7 @@ public partial class Settings : UserControl
}
KeyboardConfig.Instance.Dirty = true;
MarkDirty();
}
private static KeyBinding? CommandInSlot(string token, bool shift, bool ctrl) =>
@@ -1074,25 +1285,25 @@ public partial class Settings : UserControl
{
var q = QualityWords;
if (texFilteringValue != null)
texFilteringValue.Text = q[Clamp((int)TexFilteringSlider.Value, 1, 5) - 1];
if (TexFilteringRow != null)
TexFilteringRow.ValueText = q[Clamp((int)TexFilteringSlider.Value, 1, 5) - 1];
string[] four = { q[0], q[1], q[3], q[4] };
if (trackCurvesValue != null)
trackCurvesValue.Text = four[Clamp((int)TrackCurvesSlider.Value, 1, 4) - 1];
if (smokeParticlesValue != null)
smokeParticlesValue.Text = four[Clamp((int)SmokeParticlesSlider.Value, 1, 4) - 1];
if (TrackCurvesRow != null)
TrackCurvesRow.ValueText = four[Clamp((int)TrackCurvesSlider.Value, 1, 4) - 1];
if (SmokeParticlesRow != null)
SmokeParticlesRow.ValueText = four[Clamp((int)SmokeParticlesSlider.Value, 1, 4) - 1];
string[] msaa = { App.Loc["MsaaNone"], "x2", "x4", "x8" };
if (multisamplingValue != null)
multisamplingValue.Text = msaa[Clamp((int)MultisamplingSlider.Value, 1, 4) - 1];
if (MultisamplingRow != null)
MultisamplingRow.ValueText = msaa[Clamp((int)MultisamplingSlider.Value, 1, 4) - 1];
string[] range = { App.Loc["RangeNormal"], App.Loc["RangeHigh"], App.Loc["RangeVeryHigh"] };
if (renderRangeValue != null)
renderRangeValue.Text = range[Clamp((int)RenderRangeSlider.Value, 1, 3) - 1];
if (RenderRangeRow != null)
RenderRangeRow.ValueText = range[Clamp((int)RenderRangeSlider.Value, 1, 3) - 1];
if (cursorSensitivityValue != null)
cursorSensitivityValue.Text = (int)CursorSensitivitySlider.Value switch
if (CursorSensitivityRow != null)
CursorSensitivityRow.ValueText = (int)CursorSensitivitySlider.Value switch
{
<= 1 => q[1],
2 => App.Loc["QualityStandard"],
@@ -1100,25 +1311,25 @@ public partial class Settings : UserControl
_ => q[4]
};
if (shaderRangeValue != null)
if (ShadowRangeRow != null)
{
int metres = (int)shaderRange.Value;
shaderRangeValue.Text = $"{metres} m";
ShadowRangeRow.ValueText = $"{metres} m";
ToolTip.SetTip(shaderRange, ShadowRangeWord(metres));
}
if (cabShadowRangeValue != null)
if (CabShadowRangeRow != null)
{
int metres = (int)cabShaderSourceRange.Value;
cabShadowRangeValue.Text = metres <= 0 ? App.Loc["RangeDisabled"] : $"{metres} m";
CabShadowRangeRow.ValueText = metres <= 0 ? App.Loc["RangeDisabled"] : $"{metres} m";
ToolTip.SetTip(cabShaderSourceRange, CabShadowRangeWord(metres));
}
if (reflectionsFramerateValue != null)
if (ReflectionsFramerateRow != null)
{
int fps = (int)reflectionsFramerate.Value;
string word = ReflectionsRefreshWord(fps);
reflectionsFramerateValue.Text = $"{fps} FPS";
ReflectionsFramerateRow.ValueText = $"{fps} FPS";
ToolTip.SetTip(reflectionsFramerate, word.Length == 0 ? null : word);
}
}

View File

@@ -0,0 +1,103 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Infrastructure;
namespace StarterNG.Views;
/// <summary>
/// The cheat sheet, printed straight from <see cref="Shortcuts.All"/> so it can never
/// list a gesture the window does not actually dispatch.
/// </summary>
public static class ShortcutsWindow
{
public static Window Create()
{
var list = new StackPanel { Spacing = 2, Margin = new Thickness(20, 12, 20, 20) };
foreach (var scope in new[] { ShortcutScope.Global, ShortcutScope.Depot, ShortcutScope.Consist })
{
list.Children.Add(new TextBlock
{
Text = Shortcuts.ScopeLabel(scope),
FontWeight = FontWeight.Bold,
Margin = new Thickness(0, 12, 0, 4)
});
foreach (var s in Shortcuts.InScope(scope))
list.Children.Add(Row(s.Display, App.Loc[s.LabelKey]));
}
list.Children.Add(new TextBlock
{
Text = App.Loc["ShortcutsTabHint"],
Opacity = 0.7,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 16, 0, 0)
});
var win = new Window
{
Title = App.Loc["ShortcutsTitle"],
Width = 460,
Height = 560,
MinWidth = 380,
MinHeight = 320,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Content = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = list
}
};
win.KeyDown += (_, e) =>
{
if (e.Key is Key.Escape or Key.F1)
{
win.Close();
e.Handled = true;
}
};
return win;
}
private static Control Row(string gesture, string label)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("130,12,*") };
var keys = new Border
{
Background = new SolidColorBrush(Color.Parse("#2A3037")),
BorderBrush = new SolidColorBrush(Color.Parse("#3A424A")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(3),
Padding = new Thickness(6, 1),
HorizontalAlignment = HorizontalAlignment.Left,
Child = new TextBlock
{
Text = gesture,
FontFamily = new FontFamily("Consolas, Menlo, monospace"),
FontSize = 12
}
};
var text = new TextBlock
{
Text = label,
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
Grid.SetColumn(text, 2);
grid.Children.Add(keys);
grid.Children.Add(text);
grid.Margin = new Thickness(0, 2);
return grid;
}
}

View File

@@ -36,7 +36,7 @@
</Grid>
<TextBlock Name="statusText" DockPanel.Dock="Bottom" Margin="0,6,0,0"
FontSize="11" Opacity="0.75" />
FontSize="12" Opacity="0.75" />
<Grid DockPanel.Dock="Left" Width="260" RowDefinitions="*,4,*">
<ListBox Name="typeList" Grid.Row="0" Classes="Compact" />

View File

@@ -45,7 +45,7 @@ public sealed class UartWindow : Window
root.Children.Add(new TextBlock
{
Text = App.Loc["UartHint"],
FontSize = 11,
FontSize = 12,
Opacity = 0.65,
TextWrapping = TextWrapping.Wrap
});