Rebuild the settings page

A sticky quality bar, a level and a reset per section, key bindings in their
own tab. UART moves into the page. A log and tools window replaces
AdvancedSettingsWindow, and the developer tab is gone.
This commit is contained in:
maj00r
2026-11-01 16:06:16 +01:00
parent 3b21583b53
commit f152a020e3
19 changed files with 2817 additions and 1570 deletions

View File

@@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.Controls;
namespace StarterNG.Controls;
/// <summary>
/// Where the settings layout wants the row of one of the SimulatorKeys, so it
/// sits among the rows it belongs with; the row is put in when the page is built.
/// </summary>
public sealed class KeySlot : Decorator
{
public static readonly StyledProperty<string?> KeyProperty =
AvaloniaProperty.Register<KeySlot, string?>(nameof(Key));
/// <summary>The eu07.ini key whose row goes here.</summary>
public string? Key
{
get => GetValue(KeyProperty);
set => SetValue(KeyProperty, value);
}
}

View File

@@ -17,6 +17,13 @@ public class SettingRow : ContentControl
public static readonly StyledProperty<string?> ValueTextProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(ValueText));
/// <summary>
/// Why the simulator to be started would pass this setting over; the row dims
/// and says so. The input stays usable - the file may serve another build.
/// </summary>
public static readonly StyledProperty<string?> IdleNoteProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(IdleNote));
public string? Label
{
get => GetValue(LabelProperty);
@@ -41,6 +48,12 @@ public class SettingRow : ContentControl
set => SetValue(ValueTextProperty, value);
}
public string? IdleNote
{
get => GetValue(IdleNoteProperty);
set => SetValue(IdleNoteProperty, value);
}
public SettingRow()
{
UpdatePseudoClasses();
@@ -54,8 +67,12 @@ public class SettingRow : ContentControl
change.Property == DescriptionProperty ||
change.Property == ConfigKeyProperty ||
change.Property == ValueTextProperty ||
change.Property == IdleNoteProperty ||
change.Property == ContentProperty)
UpdatePseudoClasses();
if (change.Property == IdleNoteProperty)
ToolTip.SetTip(this, string.IsNullOrEmpty(IdleNote) ? null : IdleNote);
}
private void UpdatePseudoClasses()
@@ -65,5 +82,6 @@ public class SettingRow : ContentControl
PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey));
PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText));
PseudoClasses.Set(":no-content", Content is null);
PseudoClasses.Set(":idle", !string.IsNullOrEmpty(IdleNote));
}
}

View File

@@ -1,4 +1,6 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace StarterNG.Controls;
@@ -8,9 +10,56 @@ public class SettingsSection : HeaderedContentControl
public static readonly StyledProperty<object?> ToggleProperty =
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Toggle));
public static readonly StyledProperty<string?> ToggleKeyProperty =
AvaloniaProperty.Register<SettingsSection, string?>(nameof(ToggleKey));
public static readonly StyledProperty<string?> ResetLabelProperty =
AvaloniaProperty.Register<SettingsSection, string?>(nameof(ResetLabel));
public static readonly StyledProperty<object?> LevelProperty =
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Level));
private Button? _reset;
/// <summary>The header's choice of a level of detail for this section alone; none where it has no such thing.</summary>
public object? Level
{
get => GetValue(LevelProperty);
set => SetValue(LevelProperty, value);
}
public object? Toggle
{
get => GetValue(ToggleProperty);
set => SetValue(ToggleProperty, value);
}
/// <summary>The eu07.ini key the header's switch sets.</summary>
public string? ToggleKey
{
get => GetValue(ToggleKeyProperty);
set => SetValue(ToggleKeyProperty, value);
}
/// <summary>The words on the header's button that puts the section back as the simulator has it; none, no button.</summary>
public string? ResetLabel
{
get => GetValue(ResetLabelProperty);
set => SetValue(ResetLabelProperty, value);
}
/// <summary>Raised when the header's reset button is pressed.</summary>
public event Action<SettingsSection>? ResetRequested;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (_reset is not null)
_reset.Click -= OnResetClick;
_reset = e.NameScope.Find<Button>("PART_Reset");
if (_reset is not null)
_reset.Click += OnResetClick;
}
private void OnResetClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => ResetRequested?.Invoke(this);
}

View File

@@ -20,6 +20,12 @@
<SolidColorBrush x:Key="CarbonBorderDark" Color="#0E0F12" />
<SolidColorBrush x:Key="CarbonFg" Color="#E6E8EA" />
<SolidColorBrush x:Key="CarbonFgDim" Color="#9098A0" />
<SolidColorBrush x:Key="TextControlPlaceholderForeground" Color="#9098A0" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#9098A0" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#9098A0" />
<SolidColorBrush x:Key="ComboBoxPlaceHolderForeground" Color="#9098A0" />
<SolidColorBrush x:Key="ComboBoxPlaceHolderForegroundPointerOver" Color="#9098A0" />
<SolidColorBrush x:Key="ComboBoxPlaceHolderForegroundFocused" Color="#9098A0" />
<SolidColorBrush x:Key="CarbonAccent" Color="#177f00" />
<SolidColorBrush x:Key="CarbonAccentHover" Color="#41c400" />
<SolidColorBrush x:Key="CarbonAccentBright" Color="#41c400" />
@@ -43,6 +49,7 @@
VerticalAlignment="Center" />
<TextBlock Text="{TemplateBinding Header}"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Foreground="{StaticResource CarbonFg}" />
</DockPanel>
@@ -325,9 +332,6 @@
<Setter Property="MinHeight" Value="0" />
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="RadioButton /template/ Grid:not(#RootGrid)">
<Setter Property="Height" Value="20" />
</Style>
<Style Selector="Label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Padding" Value="0,2" />
@@ -491,6 +495,11 @@
<Style Selector="CheckBox:focus-visible /template/ Border#NormalRectangle">
<Setter Property="BorderBrush" Value="{StaticResource CarbonAccentBright}" />
</Style>
<!-- The condition is what lets this win: the theme sets the half opacity in its template. -->
<Style Selector="TextBox:not(:disabled) /template/ TextBlock#PART_Placeholder">
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{StaticResource CarbonAccentBright}" />
</Style>

View File

@@ -11,7 +11,7 @@
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Template">
<ControlTemplate>
<Grid RowDefinitions="Auto,Auto">
<Grid RowDefinitions="Auto,Auto,Auto">
<Grid.ColumnDefinitions>
<!-- Every row is its own Grid, so the label column only
lines up across rows when a shared-size scope makes
@@ -79,6 +79,18 @@
TextWrapping="Wrap"
Margin="0,3,0,0" />
<!-- Why the chosen simulator would ignore the setting. Under
the explanation, at full strength while the rest dims. -->
<TextBlock Name="PART_IdleNote"
Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
Text="{TemplateBinding IdleNote}"
IsVisible="False"
FontSize="12"
FontStyle="Italic"
Foreground="{StaticResource CarbonFg}"
TextWrapping="Wrap"
Margin="0,3,0,0" />
</Grid>
</ControlTemplate>
</Setter>
@@ -99,6 +111,24 @@
<Setter Property="Grid.Column" Value="1" />
<Setter Property="Grid.ColumnSpan" Value="2" />
</Style>
<Style Selector="^:idle /template/ TextBlock#PART_IdleNote">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:idle /template/ TextBlock#PART_Label">
<Setter Property="Opacity" Value="0.45" />
</Style>
<Style Selector="^:idle /template/ ContentPresenter#PART_Content">
<Setter Property="Opacity" Value="0.45" />
</Style>
<Style Selector="^:idle /template/ TextBlock#PART_Value">
<Setter Property="Opacity" Value="0.45" />
</Style>
<Style Selector="^:idle /template/ TextBlock#PART_Description">
<Setter Property="Opacity" Value="0.45" />
</Style>
<Style Selector="^:idle /template/ SelectableTextBlock#PART_ConfigKey">
<Setter Property="Opacity" Value="0.45" />
</Style>
<!-- A checkbox is its own label, so let it take the label column too.
Alignment stays Stretch: a bare slider in a labelless row still
wants the full width. -->
@@ -118,6 +148,13 @@
<ContentPresenter DockPanel.Dock="Right"
Content="{TemplateBinding Toggle}"
VerticalAlignment="Center" />
<Button Name="PART_Reset" DockPanel.Dock="Right" Classes="Outline SectionReset"
Content="{TemplateBinding ResetLabel}"
IsVisible="{Binding $parent[ctl:SettingsSection].ResetLabel, Converter={x:Static ObjectConverters.IsNotNull}}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<ContentPresenter DockPanel.Dock="Right"
Content="{TemplateBinding Level}"
VerticalAlignment="Center" />
<TextBlock Text="{TemplateBinding Header}"
FontSize="12"
FontWeight="SemiBold"
@@ -194,8 +231,27 @@
<!-- Controls inside a row: keep them from stretching to absurd widths in the
single-column layout, and give the row a predictable height. -->
<Style Selector="ctl|SettingRow:not(.Compact) > ComboBox">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="MaxWidth" Value="360" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Width" Value="360" />
</Style>
<!-- The same width for a list that has a warning under it. -->
<Style Selector="ctl|SettingRow:not(.Compact) > StackPanel.Field">
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Width" Value="360" />
</Style>
<!-- The key of the row above holds for this one too. -->
<Style Selector="ctl|SettingRow.KeyAbove /template/ SelectableTextBlock#PART_ConfigKey">
<Setter Property="IsVisible" Value="False" />
</Style>
<!-- Content of several rows: the label and its key stay together at the first. -->
<Style Selector="ctl|SettingRow.TopLabel /template/ TextBlock#PART_Label">
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="0,2,14,0" />
</Style>
<Style Selector="ctl|SettingRow.TopLabel /template/ SelectableTextBlock#PART_ConfigKey">
<Setter Property="Grid.Row" Value="0" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="0,21,14,0" />
</Style>
<Style Selector="ctl|SettingRow:not(.Compact) > NumericUpDown">
<Setter Property="HorizontalAlignment" Value="Left" />
@@ -276,6 +332,23 @@
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
<!-- The header's "back to defaults": quiet until pointed at. -->
<!-- An Outline button, only smaller: without the frame it read as a caption. -->
<Style Selector="Button.SectionReset">
<Setter Property="Padding" Value="8,2" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="ComboBox.SectionLevel">
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="118" />
<Setter Property="Padding" Value="8,1" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Margin" Value="0,0,8,0" />
</Style>
<Style Selector="ctl|SettingsSection.Compact">
<Setter Property="Margin" Value="0,0,0,9" />
</Style>

View File

@@ -1,187 +0,0 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Infrastructure;
using StarterNG.Application;
namespace StarterNG.Views;
public sealed class AdvancedSettingsWindow : Window
{
private readonly TextBox _log = new()
{
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.NoWrap,
FontFamily = MonoFont.Family,
FontSize = 12
};
private readonly CheckBox _compressTex = new();
private readonly CheckBox _mipmaps = new();
private readonly CheckBox _scaleSpeculars = new();
private readonly CheckBox _shaderGamma = new();
private readonly CheckBox _useGles = new();
private readonly CheckBox _convertModels = new();
private readonly CheckBox _resourceMove = new();
private readonly CheckBox _resourceSweep = new();
private readonly CheckBox _ignoreIrrelevant = new();
private readonly Slider _threads = new() { Minimum = 0, Maximum = 16, TickFrequency = 1, IsSnapToTickEnabled = true };
private readonly TextBlock _threadsValue = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 24 };
public event Action<string>? ToolRequested;
public AdvancedSettingsWindow()
{
Title = App.Loc["AdvancedSettings"];
Width = 620;
Height = 470;
MinWidth = 480;
MinHeight = 360;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
_compressTex.Content = App.Loc["CompressTextures"];
_mipmaps.Content = App.Loc["ScreenMipmaps"];
_scaleSpeculars.Content = App.Loc["ScaleSpeculars"];
_shaderGamma.Content = App.Loc["ShaderGamma"];
_useGles.Content = App.Loc["UseGLES"];
_convertModels.Content = App.Loc["ExtendedModelConversion"];
_resourceMove.Content = App.Loc["GfxResourceMove"];
_resourceSweep.Content = App.Loc["GfxResourceSweep"];
_ignoreIrrelevant.Content = App.Loc["IgnoreIrrelevantTrains"];
_threads.PropertyChanged += (_, e) =>
{
if (e.Property == RangeBase.ValueProperty)
_threadsValue.Text = ((int)_threads.Value).ToString();
};
Load();
var tabs = new TabControl();
tabs.Items.Add(new TabItem { Header = App.Loc["AdvLogPage"], Content = BuildLogPage() });
tabs.Items.Add(new TabItem { Header = App.Loc["Advanced"], Content = BuildOptionsPage() });
tabs.Items.Add(new TabItem { Header = App.Loc["Tools"], Content = BuildToolsPage() });
var close = new Button
{
Content = App.Loc["Close"],
MinWidth = 100,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Avalonia.Thickness(0, 8, 0, 0),
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
close.Classes.Add("Accent");
close.Click += (_, _) => { Store(); Close(); };
var root = new DockPanel { Margin = new Avalonia.Thickness(10) };
DockPanel.SetDock(close, Dock.Bottom);
root.Children.Add(close);
root.Children.Add(tabs);
Content = root;
}
private Control BuildLogPage()
{
_log.Text = Diagnostics.Text;
var clear = new Button
{
Content = App.Loc["AdvClearLog"],
Margin = new Avalonia.Thickness(0, 8, 0, 0),
HorizontalAlignment = HorizontalAlignment.Left,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
clear.Classes.Add("Flat");
clear.Click += (_, _) => { Diagnostics.Clear(); _log.Text = ""; };
ScrollViewer.SetHorizontalScrollBarVisibility(_log, ScrollBarVisibility.Auto);
ScrollViewer.SetVerticalScrollBarVisibility(_log, ScrollBarVisibility.Auto);
var panel = new DockPanel { Margin = new Avalonia.Thickness(8) };
DockPanel.SetDock(clear, Dock.Bottom);
panel.Children.Add(clear);
panel.Children.Add(_log);
return panel;
}
private Control BuildOptionsPage()
{
var sp = new StackPanel { Margin = new Avalonia.Thickness(10), Spacing = 8 };
foreach (var c in new Control[]
{
_compressTex, _scaleSpeculars, _useGles, _shaderGamma, _mipmaps,
_convertModels, _resourceMove, _resourceSweep, _ignoreIrrelevant
})
sp.Children.Add(c);
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Avalonia.Thickness(0, 6, 0, 0) };
row.Children.Add(new TextBlock
{
Text = App.Loc["TrainPhysicsThreads"],
VerticalAlignment = VerticalAlignment.Center
});
_threads.Width = 220;
row.Children.Add(_threads);
row.Children.Add(_threadsValue);
sp.Children.Add(row);
return new ScrollViewer { Content = sp, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
}
private Control BuildToolsPage()
{
var sp = new StackPanel { Margin = new Avalonia.Thickness(10), Spacing = 8 };
Button Tool(string key, string tag)
{
var b = new Button
{
Content = App.Loc[key],
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
b.Classes.Add("Flat");
b.Click += (_, _) => ToolRequested?.Invoke(tag);
return b;
}
sp.Children.Add(Tool("AddAllCategory", "category"));
sp.Children.Add(Tool("AddUniqueMmd", "mmd"));
sp.Children.Add(Tool("RemoveAllTrains", "removeall"));
return sp;
}
private void Load()
{
var s = AppServices.Current.Settings;
_compressTex.IsChecked = s.CompressTextures;
_mipmaps.IsChecked = s.ScreenMipmaps;
_scaleSpeculars.IsChecked = s.ScaleSpeculars;
_shaderGamma.IsChecked = s.ShaderGamma;
_useGles.IsChecked = s.UseGLES;
_convertModels.IsChecked = s.ExtendedModelConversion;
_resourceMove.IsChecked = s.GfxResourceMove;
_resourceSweep.IsChecked = s.GfxResourceSweep;
_ignoreIrrelevant.IsChecked = s.IgnoreIrrelevantTrains;
_threads.Value = s.TrainPhysicsThreads;
_threadsValue.Text = s.TrainPhysicsThreads.ToString();
}
private void Store()
{
var s = AppServices.Current.Settings;
s.CompressTextures = _compressTex.IsChecked == true;
s.ScreenMipmaps = _mipmaps.IsChecked == true;
s.ScaleSpeculars = _scaleSpeculars.IsChecked == true;
s.ShaderGamma = _shaderGamma.IsChecked == true;
s.UseGLES = _useGles.IsChecked == true;
s.ExtendedModelConversion = _convertModels.IsChecked == true;
s.GfxResourceMove = _resourceMove.IsChecked == true;
s.GfxResourceSweep = _resourceSweep.IsChecked == true;
s.IgnoreIrrelevantTrains = _ignoreIrrelevant.IsChecked == true;
s.TrainPhysicsThreads = (int)_threads.Value;
}
}

View File

@@ -1,65 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG"
x:CompileBindings="True"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
x:Class="StarterNG.Views.Developer">
<!-- Read-only view of what the starter made of the installation: the counts a
package author needs before slicing anything out of it, and the state of
the JSON copy those packages will be generated from. -->
<StackPanel Margin="3" Spacing="3">
<Border Background="{StaticResource CarbonPanelBg}" BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="1" CornerRadius="2" Padding="10" HorizontalAlignment="Left" MinWidth="440">
<StackPanel Spacing="8">
<TextBlock Text="{Binding [DevDatabase], Source={x:Static starterNg:App.Loc}}"
FontWeight="Bold" FontSize="14" />
<Grid ColumnDefinitions="190,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"
RowSpacing="4">
<TextBlock Grid.Row="0" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevVehicles], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="VehiclesValue" />
<TextBlock Grid.Row="1" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevLiveries], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="LiveriesValue" />
<TextBlock Grid.Row="2" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevGroups], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="GroupsValue" />
<TextBlock Grid.Row="3" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevSets], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="SetsValue" />
<TextBlock Grid.Row="4" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevFiles], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="4" Grid.Column="1" x:Name="FilesValue" />
<TextBlock Grid.Row="5" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevSource], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="5" Grid.Column="1" x:Name="SourceValue" />
<TextBlock Grid.Row="6" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevWritten], Source={x:Static starterNg:App.Loc}}" />
<TextBlock Grid.Row="6" Grid.Column="1" x:Name="WrittenValue" />
<TextBlock Grid.Row="7" Grid.Column="0" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [DevLocation], Source={x:Static starterNg:App.Loc}}"
VerticalAlignment="Top" />
<SelectableTextBlock Grid.Row="7" Grid.Column="1" x:Name="LocationValue"
TextWrapping="Wrap" FontSize="11" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RebuildButton" Click="RebuildButton_OnClick" Cursor="Hand"
Content="{Binding [DevRebuild], Source={x:Static starterNg:App.Loc}}" />
<TextBlock x:Name="RebuildStatus" VerticalAlignment="Center"
Foreground="{StaticResource CarbonFgDim}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -1,78 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using StarterNG.Application;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Views;
/// <summary>
/// The developer tab: what the starter read out of the installation, and the state
/// of the JSON copy it keeps of it. Read-only apart from rebuilding that copy.
/// </summary>
public partial class Developer : UserControl
{
public Developer()
{
InitializeComponent();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
/// <summary>Fills the panel from the catalogue as it stands right now.</summary>
public void Refresh()
{
var catalog = AppServices.Current.Library.Vehicles;
var status = AppServices.Current.VehicleCache.Status;
VehiclesValue.Text = Count(catalog.TextureByUuid.Values
.Select(texture => texture.Directory)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count());
LiveriesValue.Text = Wrecks(catalog);
GroupsValue.Text = Count(catalog.GroupsById.Count);
SetsValue.Text = Count(catalog.Sets.Count);
FilesValue.Text = Count(status.Sources);
SourceValue.Text = App.Loc[status.ServedFromCopy ? "DevSourceCopy" : "DevSourceTextures"];
WrittenValue.Text = status.Written is { } written
? written.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)
: "-";
LocationValue.Text = status.Directory;
RebuildStatus.Text = "";
}
/// <summary>
/// Rebuilds the copy on disk without disturbing the catalogue the other tabs
/// are already drawn from - the vehicles on screen keep pointing at the entries
/// they were built with.
/// </summary>
private void RebuildButton_OnClick(object? sender, RoutedEventArgs e)
{
RebuildButton.IsEnabled = false;
try
{
int liveries = AppServices.Current.VehicleCache
.Rebuild(new VehicleCatalog(AppServices.Current.MiniTextures));
Refresh();
RebuildStatus.Text = $"{App.Loc["DevRebuilt"]} ({liveries})";
}
finally
{
RebuildButton.IsEnabled = true;
}
}
/// <summary>Liveries offered, saying so when wrecks are being held back.</summary>
private static string Wrecks(VehicleCatalog catalog)
{
int wrecks = catalog.TextureByUuid.Count - catalog.Textures.Count;
return wrecks > 0
? $"{Count(catalog.Textures.Count)} (+{Count(wrecks)} {App.Loc["DevWrecks"]})"
: Count(catalog.Textures.Count);
}
private static string Count(int value) => value.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -8,6 +8,7 @@ public static class ExeProblemText
public static string Key(ExeProblem problem) => problem switch
{
ExeProblem.NotExecutable => "ExeNotExecutable",
ExeProblem.NotAProgram => "ExeNotAProgram",
ExeProblem.WrongPlatform => "ExeWrongPlatform",
_ => "ExeNotFound"
};

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
using StarterNG.Application;
using StarterNG.Infrastructure;
namespace StarterNG.Views;
/// <summary>What a save would change in eu07.ini, key by key, before it is made.</summary>
internal static class IniChangesWindow
{
private static readonly IBrush Dim = new SolidColorBrush(Color.Parse("#9098A0"));
private static readonly IBrush New = new SolidColorBrush(Color.Parse("#41C400"));
/// <returns>Whether the save was asked for.</returns>
public static async Task<bool> Show(Window owner, string path, IReadOnlyList<IniChange> changes,
bool keyboardChanged)
{
var win = MessageBox.FixedDialog(App.Loc["IniChangesTitle"], 680);
var list = new StackPanel { Spacing = 2 };
AddGroup(list, App.Loc["IniChangesChanged"], changes.Where(c => c.Before is not null && c.After is not null));
AddGroup(list, App.Loc["IniChangesAdded"], changes.Where(c => c.Before is null));
AddGroup(list, App.Loc["IniChangesRemoved"], changes.Where(c => c.After is null));
var body = new StackPanel { Margin = new Thickness(16), Spacing = 12 };
body.Children.Add(new TextBlock
{
Text = path, Foreground = Dim, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis
});
body.Children.Add(changes.Count == 0
? new TextBlock { Text = App.Loc["IniChangesNone"], TextWrapping = TextWrapping.Wrap }
: new ScrollViewer { MaxHeight = 440, Content = list });
if (keyboardChanged)
body.Children.Add(new TextBlock { Text = App.Loc["IniChangesKeyboard"], TextWrapping = TextWrapping.Wrap });
bool save = false;
var saveBtn = new Button { Content = App.Loc["Save"], MinWidth = 80 };
saveBtn.Classes.Add("Accent");
saveBtn.IsEnabled = changes.Count > 0 || keyboardChanged;
saveBtn.Click += (_, _) => { save = true; win.Close(); };
var closeBtn = new Button { Content = App.Loc["Close"], MinWidth = 80 };
closeBtn.Classes.Add("Flat");
closeBtn.Click += (_, _) => win.Close();
body.Children.Add(MessageBox.ButtonRow(saveBtn, closeBtn));
win.Content = body;
await win.ShowDialog(owner);
return save;
}
private static void AddGroup(Panel list, string title, IEnumerable<IniChange> group)
{
var changes = group.ToList();
if (changes.Count == 0)
return;
list.Children.Add(new TextBlock
{
Text = $"{title} ({changes.Count})",
FontWeight = FontWeight.SemiBold,
Margin = new Thickness(0, list.Children.Count == 0 ? 0 : 12, 0, 4)
});
foreach (var change in changes)
{
var row = new Grid { ColumnDefinitions = new ColumnDefinitions("240,*") };
row.Children.Add(new TextBlock
{
Text = change.Key, FontFamily = MonoFont.Family, FontSize = 12,
TextTrimming = TextTrimming.CharacterEllipsis
});
var value = new TextBlock { FontFamily = MonoFont.Family, FontSize = 12, TextWrapping = TextWrapping.Wrap };
if (change.Before is not null)
value.Inlines!.Add(new Run(change.Before)
{
Foreground = Dim,
TextDecorations = change.After is null ? TextDecorations.Strikethrough : null
});
if (change.Before is not null && change.After is not null)
value.Inlines!.Add(new Run(" → ") { Foreground = Dim });
if (change.After is not null)
value.Inlines!.Add(new Run(change.After) { Foreground = New });
Grid.SetColumn(value, 1);
row.Children.Add(value);
list.Children.Add(row);
}
}
}

View File

@@ -0,0 +1,688 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Classes;
using StarterNG.Infrastructure;
using KeyBinding = StarterNG.Classes.KeyBinding;
namespace StarterNG.Views;
/// <summary>
/// The key bindings of the settings screen: the list of commands, the keyboard they
/// sit on, and the wait for a keypress.
/// </summary>
internal sealed class KeyBindingsTab(Grid host, TextBox search)
{
/// <summary>Raised on an edit by hand.</summary>
public event Action? Changed;
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 IsCapturing => _capturing is not null;
private KeyBinding? _capturing;
private Button? _capturingButton;
private readonly Dictionary<string, Grid> _keyCells = new(StringComparer.OrdinalIgnoreCase);
private bool _flyoutLoading;
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 KeyShiftCtrlBrush = new SolidColorBrush(Color.Parse("#C8372D"));
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"));
// The four combinations a key can be on. After the brushes: static fields start in text order.
private static readonly (bool Shift, bool Ctrl, string SlotTextKey, IBrush Colour)[] ModifierSlots =
{
(false, false, "BindNoMod", KeyPlainBrush),
(true, false, "BindShift", KeyShiftBrush),
(false, true, "BindCtrl", KeyCtrlBrush),
(true, true, "BindShiftCtrl", KeyShiftCtrlBrush),
};
private const double KeyUnit = 30;
private const double KeyHeight = 34;
private const double KeyGap = 4;
public void Build()
{
CancelCapture();
host.Children.Clear();
var head = new StackPanel { Spacing = 6, Margin = new Thickness(0, 0, 0, 8) };
var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
topBar.Children.Add(new TextBlock
{
Text = App.Loc["BindingsHint"],
VerticalAlignment = VerticalAlignment.Center,
Foreground = FgDimBrush
});
var restoreBtn = new Button { Content = App.Loc["RestoreDefaults"] };
restoreBtn.Classes.Add("Flat");
restoreBtn.Click += (_, _) =>
{
CancelCapture();
KeyboardConfig.Instance.LoadDefaults();
BindingsEdited();
};
topBar.Children.Add(restoreBtn);
head.Children.Add(topBar);
_numpadText = new TextBlock
{
Foreground = FgBrush,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center
};
var numpadShow = new Button
{
Content = App.Loc["BindingsNumpadShow"],
VerticalAlignment = VerticalAlignment.Center,
HorizontalContentAlignment = HorizontalAlignment.Center
};
numpadShow.Classes.Add("Flat");
numpadShow.Click += (_, _) => search.Text = KeyMap.NumpadPrefix;
Grid.SetColumn(numpadShow, 1);
_numpadNote = new Border
{
BorderBrush = ConflictBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(2),
Padding = new Thickness(8, 4),
Child = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 8,
Children = { _numpadText, numpadShow }
}
};
head.Children.Add(_numpadNote);
Grid.SetRow(head, 0);
host.Children.Add(head);
_bindingListPanel = new StackPanel { Spacing = 3 };
var listScroll = new ScrollViewer
{
Content = _bindingListPanel,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto
};
Grid.SetRow(listScroll, 1);
host.Children.Add(listScroll);
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);
host.Children.Add(bottom);
RedrawBindings();
}
/// <summary>The bindings as the file has them, and no key waited for.</summary>
public void Reload()
{
CancelCapture();
KeyboardConfig.Instance.Load();
RedrawBindings();
}
private void BindingsEdited()
{
KeyboardConfig.Instance.Dirty = true;
Changed?.Invoke();
RedrawBindings();
}
private void RedrawBindings()
{
RebuildList();
RebuildKeyboard();
}
public void RebuildList()
{
if (_bindingListPanel is null)
return;
_bindingListPanel.Children.Clear();
// The sidebar search box serves this list too - see Settings.ApplyFilter.
string filter = search.Text?.Trim() ?? string.Empty;
var conflicts = KeyboardConfig.Instance.ConflictingCombos();
foreach (var b in KeyboardConfig.Instance.Bindings)
{
if (filter.Length > 0 && !MatchesFilter(b, filter))
continue;
_bindingListPanel.Children.Add(BuildBindingRow(b, conflicts));
}
UpdateNumpadNote();
}
private Border? _numpadNote;
private TextBlock? _numpadText;
/// <summary>
/// Said whenever a binding uses the numpad: neither Windows nor Linux tells whether a
/// keyboard has one, a keyboard without it reports the same as any other.
/// </summary>
private void UpdateNumpadNote()
{
if (_numpadNote is null || _numpadText is null)
return;
int onNumpad = KeyboardConfig.Instance.BindingsOnNumpad();
_numpadNote.IsVisible = onNumpad > 0;
_numpadText.Text = string.Format(App.Loc["BindingsNumpadNote"], onNumpad);
}
private static bool MatchesFilter(KeyBinding b, string filter) =>
TextMatch.Contains(b.Command, filter) ||
TextMatch.Contains(b.Key, filter) ||
TextMatch.Contains(KeyboardConfig.Instance.DescriptionOf(b, TranslatedCommand), filter);
// Polish reads the bundled key file, which has the descriptions with Polish letters.
private static string TranslatedCommand(string command) =>
string.Equals(App.Loc.CurrentLangCode, "pl", StringComparison.OrdinalIgnoreCase)
? string.Empty
: App.Loc.OptOrEnglish("Cmd." + command);
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 = 12,
TextTrimming = TextTrimming.CharacterEllipsis
});
label.Children.Add(new TextBlock
{
Text = b.Command,
Foreground = FgDimBrush,
FontSize = 12,
TextTrimming = TextTrimming.CharacterEllipsis
});
Grid.SetColumn(label, 0);
grid.Children.Add(label);
var simulatorUse = b.IsAssigned ? KeyMap.SimulatorUse(b.Key, b.Shift, b.Ctrl) : null;
bool conflict = (b.IsAssigned && conflicts.Contains(b.ComboKey)) || simulatorUse is not null;
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 (simulatorUse is { } use)
ToolTip.SetTip(comboButton, SimulatorUseText(b.Key, b.Shift, b.Ctrl, use));
else 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.Clear();
BindingsEdited();
};
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
};
}
private void StartCapture(KeyBinding b, Button button)
{
if (_capturing is not null && _capturingButton is not null)
_capturingButton.Content = ComboText(_capturing);
_capturing = b;
_capturingButton = button;
button.Content = App.Loc["PressKey"];
button.Focus();
}
private void CancelCapture()
{
if (_capturing is not null && _capturingButton is not null)
_capturingButton.Content = ComboText(_capturing);
_capturing = null;
_capturingButton = null;
}
public void OnKeyDown(object? sender, KeyEventArgs e)
{
if (_capturing is null)
return;
if (e.Key == Key.Escape)
{
CancelCapture();
e.Handled = true;
return;
}
if (KeyMap.IsModifierKey(e.Key))
return;
string? token = KeyMap.FromInput(e.Key, e.PhysicalKey);
if (token is null)
{
e.Handled = true;
return;
}
bool shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
bool ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
if (KeyMap.SimulatorUse(token, shift, ctrl) is { Blocks: true } taken)
{
// Bound here it would never fire: the wait for a key goes on.
if (_capturingButton is not null)
_capturingButton.Content = string.Format(App.Loc["KbTaken"], App.Loc[taken.TextKey]);
e.Handled = true;
return;
}
// As the key flyout does: the combination moves here, not doubled up.
KeyboardConfig.Instance.Put(_capturing, token, shift, ctrl);
e.Handled = true;
_capturing = null;
_capturingButton = null;
BindingsEdited();
}
private void RebuildKeyboard()
{
if (_keyboardPanel is null)
return;
_keyCells.Clear();
_keyboardPanel.Children.Clear();
var states = ComputeKeyStates();
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 14 };
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);
}
private void UpdateKeyboardColors()
{
var states = ComputeKeyStates();
foreach (var (token, content) in _keyCells)
{
if (content.Children.Count > 0)
content.Children.RemoveAt(0);
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)
{
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[cap.Label switch
{
"Shift" or "Ctrl" => "KbModifier",
"Alt" => "KbAlt",
_ => "KbNotInSimulator"
}]);
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>();
for (int i = 0; i < ModifierSlots.Length; i++)
if (state is not null && state.SlotOn[i])
colours.Add(ModifierSlots[i].Colour);
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);
var lines = new List<string>();
if (state is null || state.Tips.Count == 0)
{
lines.Add($"{head}: {App.Loc["KbUnassigned"]}");
}
else
{
lines.Add(head);
lines.AddRange(state.Tips);
}
lines.AddRange(SimulatorNotes(token));
return string.Join("\n", lines);
}
private static string SimulatorUseText(string token, bool shift, bool ctrl, SimulatorKeyUse use) =>
string.Format(App.Loc[use.Blocks ? "KbSimBlocks" : "KbSimReplaces"],
KeyMap.ComboText(token, shift, ctrl),
App.Loc[use.TextKey]);
/// <summary>What the simulator does with this key by itself, once per thing it does.</summary>
private static List<string> SimulatorNotes(string token)
{
var notes = new List<string>();
var seen = new HashSet<SimulatorKeyUse>();
foreach (var (shift, ctrl, _, _) in ModifierSlots)
if (KeyMap.SimulatorUse(token, shift, ctrl) is { } use && seen.Add(use))
notes.Add(SimulatorUseText(token, shift, ctrl, use));
return notes;
}
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 combos = new ComboBox[ModifierSlots.Length];
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto")
};
for (int i = 0; i < ModifierSlots.Length; i++)
{
var (shift, ctrl, labelKey, colour) = ModifierSlots[i];
// The swatch ties the row to the key colours on the keyboard above.
var lbl = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 6,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 4, 12, 4),
Children =
{
new Border
{
Width = 10, Height = 10, CornerRadius = new CornerRadius(2),
Background = colour, VerticalAlignment = VerticalAlignment.Center
},
new TextBlock { Text = App.Loc[labelKey], Foreground = FgBrush, VerticalAlignment = VerticalAlignment.Center }
}
};
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
};
if (KeyMap.SimulatorUse(token, shift, ctrl) is { } use)
{
ToolTip.SetTip(cb, SimulatorUseText(token, shift, ctrl, use));
// What a hand-edited file put there can still be taken off.
cb.IsEnabled = !use.Blocks || KeyboardConfig.Instance.BindingAt(token, shift, ctrl) is not null;
}
cb.SelectionChanged += (_, _) =>
{
if (_flyoutLoading)
return;
AssignSlot(token, shift, ctrl, cb.SelectedIndex, commands);
SyncFlyoutCombos(token, combos);
UpdateKeyboardColors();
RebuildList();
};
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 = 12,
TextWrapping = TextWrapping.Wrap
});
panel.Children.Add(grid);
foreach (string note in SimulatorNotes(token))
panel.Children.Add(new TextBlock
{
Text = note,
Foreground = ConflictBrush,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 460
});
var flyout = new Flyout { Content = panel };
flyout.ShowAt(anchor);
}
private void SyncFlyoutCombos(string token, ComboBox[] combos)
{
var commands = KeyboardConfig.Instance.Bindings;
_flyoutLoading = true;
for (int i = 0; i < combos.Length; i++)
{
var cmd = KeyboardConfig.Instance.BindingAt(token, ModifierSlots[i].Shift, ModifierSlots[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];
KeyboardConfig.Instance.Put(chosen, token, shift, ctrl);
KeyboardConfig.Instance.Dirty = true;
Changed?.Invoke();
}
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"]));
legend.Children.Add(LegendItem(KeyShiftCtrlBrush, App.Loc["KbShiftCtrl"]));
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;
}
private sealed class KeyState
{
/// <summary>Which of the <see cref="ModifierSlots"/> have a binding.</summary>
public readonly bool[] SlotOn = new bool[ModifierSlots.Length];
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();
state.SlotOn[Array.FindIndex(ModifierSlots, slot => slot.Shift == b.Shift && slot.Ctrl == b.Ctrl)] = true;
state.Tips.Add($"{ComboText(b)} — {CommandLabel(b)}");
}
return map;
}
private static string ComboText(KeyBinding b) => KeyMap.ComboText(b.Key, b.Shift, b.Ctrl);
private static string CommandLabel(KeyBinding b) =>
KeyboardConfig.Instance.DescriptionOf(b, TranslatedCommand) is { Length: > 0 } description
? Capitalize(description)
: b.Command;
private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
}

View File

@@ -0,0 +1,105 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Infrastructure;
namespace StarterNG.Views;
/// <summary>
/// The starter's event log and the bulk depot tools. The options it used to carry
/// live in the Advanced category of the settings now, where they are found.
/// </summary>
public sealed class LogAndToolsWindow : Window
{
private readonly TextBox _log = new()
{
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.NoWrap,
FontFamily = MonoFont.Family,
FontSize = 12
};
public event Action<string>? ToolRequested;
public LogAndToolsWindow()
{
Title = App.Loc["LogAndTools"];
Width = 620;
Height = 470;
MinWidth = 480;
MinHeight = 360;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
var tabs = new TabControl();
tabs.Items.Add(new TabItem { Header = App.Loc["AdvLogPage"], Content = BuildLogPage() });
tabs.Items.Add(new TabItem { Header = App.Loc["Tools"], Content = BuildToolsPage() });
var close = new Button
{
Content = App.Loc["Close"],
MinWidth = 100,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Avalonia.Thickness(0, 8, 0, 0),
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
close.Classes.Add("Accent");
close.Click += (_, _) => Close();
var root = new DockPanel { Margin = new Avalonia.Thickness(10) };
DockPanel.SetDock(close, Dock.Bottom);
root.Children.Add(close);
root.Children.Add(tabs);
Content = root;
}
private Control BuildLogPage()
{
_log.Text = Diagnostics.Text;
var clear = new Button
{
Content = App.Loc["AdvClearLog"],
Margin = new Avalonia.Thickness(0, 8, 0, 0),
HorizontalAlignment = HorizontalAlignment.Left,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
clear.Classes.Add("Flat");
clear.Click += (_, _) => { Diagnostics.Clear(); _log.Text = ""; };
ScrollViewer.SetHorizontalScrollBarVisibility(_log, ScrollBarVisibility.Auto);
ScrollViewer.SetVerticalScrollBarVisibility(_log, ScrollBarVisibility.Auto);
var panel = new DockPanel { Margin = new Avalonia.Thickness(8) };
DockPanel.SetDock(clear, Dock.Bottom);
panel.Children.Add(clear);
panel.Children.Add(_log);
return panel;
}
private Control BuildToolsPage()
{
var sp = new StackPanel { Margin = new Avalonia.Thickness(10), Spacing = 8 };
Button Tool(string key, string tag)
{
var b = new Button
{
Content = App.Loc[key],
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
b.Classes.Add("Flat");
b.Click += (_, _) => ToolRequested?.Invoke(tag);
return b;
}
sp.Children.Add(Tool("AddAllCategory", "category"));
sp.Children.Add(Tool("AddUniqueMmd", "mmd"));
sp.Children.Add(Tool("RemoveAllTrains", "removeall"));
return sp;
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Layout;
@@ -18,34 +19,13 @@ public static class MessageBox
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons buttons,
string? yesText = null, string? noText = null)
{
var win = new Window
{
Title = title,
Width = 420,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false
};
var win = FixedDialog(title, 420);
bool yes = false;
var yesBtn = new Button { Content = App.Loc["Yes"], MinWidth = 80, Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) };
yesBtn.Classes.Add("Accent");
yesBtn.Click += (_, _) => { yes = true; win.Close(); };
var noBtn = new Button { Content = App.Loc["No"], MinWidth = 80, Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) };
noBtn.Classes.Add("Flat");
noBtn.Click += (_, _) => win.Close();
if (yesText is not null)
yesBtn.Content = yesText;
if (noText is not null)
noBtn.Content = noText;
if (buttons == MessageBoxButtons.Ok)
{
yesBtn.Content = App.Loc["Ok"];
noBtn.IsVisible = false;
}
var yesBtn = DialogButton(buttons == MessageBoxButtons.Ok ? App.Loc["Ok"] : yesText ?? App.Loc["Yes"],
"Accent", 80, () => { yes = true; win.Close(); });
var noBtn = DialogButton(noText ?? App.Loc["No"], "Flat", 80, () => win.Close());
noBtn.IsVisible = buttons != MessageBoxButtons.Ok;
win.Content = new StackPanel
{
@@ -54,17 +34,81 @@ public static class MessageBox
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { yesBtn, noBtn }
}
ButtonRow(yesBtn, noBtn)
}
};
await win.ShowDialog(owner);
return yes;
}
/// <summary>
/// A notice with one action besides closing it, and a box to stop it coming
/// back, which counts however the window is closed.
/// </summary>
public static async Task<(bool Act, bool Silence)> ShowNotice(Window owner, string message, string title,
string actionText, string silenceText)
{
var win = FixedDialog(title, 460);
bool act = false;
var actBtn = DialogButton(actionText, "Accent", null, () => { act = true; win.Close(); });
var okBtn = DialogButton(App.Loc["Ok"], "Flat", 80, () => win.Close());
var silence = new CheckBox { Content = silenceText, VerticalAlignment = VerticalAlignment.Center };
win.Content = new StackPanel
{
Margin = new Avalonia.Thickness(16),
Spacing = 16,
Children =
{
new SelectableTextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new DockPanel
{
Children =
{
silence,
ButtonRow(actBtn, okBtn)
}
}
}
};
await win.ShowDialog(owner);
return (act, silence.IsChecked == true);
}
/// <summary>A dialog over its owner, as tall as what it holds, not to be resized.</summary>
internal static Window FixedDialog(string title, double width) => new()
{
Title = title,
Width = width,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false
};
/// <summary>A dialog's buttons, at its right edge.</summary>
internal static StackPanel ButtonRow(params Button[] buttons)
{
var row = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
row.Children.AddRange(buttons);
return row;
}
private static Button DialogButton(string content, string cssClass, double? minWidth, Action onClick)
{
var button = new Button { Content = content, Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) };
if (minWidth is { } width)
button.MinWidth = width;
button.Classes.Add(cssClass);
button.Click += (_, _) => onClick();
return button;
}
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using StarterNG.Domain.Settings;
namespace StarterNG.Views;
/// <summary>The captions of the settings' rows and the words for their values.</summary>
internal static class SettingTexts
{
public static string[] QualityWords => new[]
{
App.Loc["QualityVeryLow"], App.Loc["QualityLow"], App.Loc["QualityNormal"],
App.Loc["QualityHigh"], App.Loc["QualityVeryHigh"]
};
/// <summary>The words of a slider that goes from 1 to 4, which has no middle.</summary>
public static string Fidelity(int level)
{
var q = QualityWords;
string[] four = { q[0], q[1], q[3], q[4] };
return four[Math.Clamp(level, 1, 4) - 1];
}
public static string Anisotropy(double value) => "x" + value.ToString("0.#", CultureInfo.CurrentCulture);
public static string Multisampling(int level) => level <= 0 ? App.Loc["MsaaNone"] : "x" + (1 << level);
public static string TwoDecimals(double value) => value.ToString("0.00", CultureInfo.CurrentCulture);
public static string ShadowMapWord(int pixels) => pixels switch
{
<= 512 => App.Loc["QualityVeryLow"],
<= 1024 => App.Loc["QualityLow"],
<= 2048 => App.Loc["QualityModerate"],
<= 4096 => App.Loc["QualityHigh"],
_ => App.Loc["QualityVeryHigh"]
};
public static string ShadowRangeWord(int metres) => metres switch
{
<= 100 => App.Loc["RangeLow"],
<= 150 => App.Loc["RangeModerate"],
<= 250 => App.Loc["RangeHigh"],
_ => App.Loc["RangeVeryHigh"]
};
public static string CabShadowRangeWord(int metres) => metres switch
{
<= 10 => App.Loc["RangeVeryLow"],
<= 20 => App.Loc["RangeLow"],
<= 30 => App.Loc["RangeStandard"],
<= 50 => App.Loc["RangeHigh"],
_ => App.Loc["RangeVeryHigh"]
};
/// <summary>A rate below one a second reads better as the time between updates.</summary>
public static string ReflectionsInterval(double perSecond)
{
if (perSecond >= 1)
return string.Format(App.Loc["TimesPerSecond"], perSecond.ToString("0.##", CultureInfo.CurrentCulture));
double seconds = 1 / perSecond;
return seconds >= 60
? string.Format(App.Loc["EveryMinutes"], (seconds / 60).ToString("0.#", CultureInfo.CurrentCulture))
: string.Format(App.Loc["EverySeconds"], seconds.ToString("0.#", CultureInfo.CurrentCulture));
}
/// <summary>What a level sets, in the words and the units of the rows it sets.</summary>
public static string PresetSummary(GraphicsPreset p)
{
var lines = new List<string>();
void Add(string label, string value) => lines.Add(App.Loc[label] + ": " + value);
string YesNo(bool on) => App.Loc[on ? "Yes" : "No"];
string Whole(double value) => value.ToString("0.#", CultureInfo.CurrentCulture);
Add("MaxTexResolution", p.TextureSize + " px");
Add("MaxTexCabResolution", p.CabTextureSize + " px");
Add("TexFilteringQuality", Anisotropy(p.Anisotropy));
Add("Multisampling", Multisampling(p.Multisampling));
Add("DynamicLights", p.DynamicLights.ToString(CultureInfo.CurrentCulture));
Add("SmokeParticlesMultiplier", Fidelity(p.SmokeFidelity));
Add("RenderShadows", YesNo(p.Shadows));
if (p.Shadows)
{
Add("ShadowsResolution", p.ShadowMap + " px");
Add("ShaderRange", SimulatorSettings.ShadowReach(p.ShadowRange, p.ShadowMap, legacyRenderer: false) + " m");
Add("ShadowDisplay", App.Loc[p.ShadowRank switch { 1 => "CSROnlyImportant", 2 => "CSRLimited", _ => "CSRAll" }]);
}
Add("ReflectionsCubeMap", YesNo(p.EnvMap));
if (p.EnvMap)
{
Add("ReflectionsDetails", App.Loc[p.ReflectionsFidelity switch { 0 => "RDTerrain", 1 => "RDTerrainModels", _ => "RDTerrainModelsVehicles" }]);
Add("ReflectionsFramerate", ReflectionsInterval(p.ReflectionsRate));
}
Add("AdditionalShadersEffects", YesNo(p.ExtraEffects));
Add("TrackCurvesQuality", Fidelity(p.SplineFidelity));
Add("Ini.basedrawrange", Whole(p.DrawRange) + " m");
Add("Ini.heightbase", "×" + p.DistanceFactor.ToString("0.##", CultureInfo.CurrentCulture));
Add("BufforResolution", p.BufferScale + "%");
Add("Ini." + GraphicsPreset.SleeperKey, Whole(p.SleeperDistance) + " m");
Add("RenderRange", "×" + Whole(p.DrawRangeFactor));
Add("Ini." + GraphicsPreset.ReflectedInstancesKey, Whole(p.ReflectedInstances) + " m");
Add("Ini." + GraphicsPreset.ReflectedVehiclesKey, Whole(p.ReflectedVehicles) + " m");
Add("CabShadowsRange", p.CabShadowsRange + " m");
if (p.Extras is { } extras)
{
Add("MotionBlur", YesNo(extras.MotionBlur));
Add("ChromaticAberration", YesNo(extras.ChromaticAberration));
Add("SmokeDisplay", YesNo(extras.Smoke));
Add("PyScreenPriority", App.Loc[extras.PythonUpdateMs <= 200 ? "PyPriorityNormal" : "PyPriorityLowest"]);
}
if (p.Top is { } top)
{
lines.Insert(0, App.Loc["PresetUltraNote"]);
Add("ShadowAngleLimit", TwoDecimals(top.ShadowAngleLimit));
Add("Ini." + GraphicsTop.TargetFpsKey, YesNo(top.TargetFps > 0));
Add("Ini." + GraphicsTop.TextureReleaseKey, YesNo(top.TextureRelease > 0));
Add("CompressTextures", YesNo(top.CompressTextures));
Add("IgnoreIrrelevantTrains", YesNo(top.SkipDecor));
}
return string.Join("\n", lines);
}
}

View File

@@ -14,14 +14,17 @@
<Border DockPanel.Dock="Bottom" Padding="16,10" Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="12"
<!-- A dock rather than a stack, so a long path is cut short instead of running under the buttons. -->
<DockPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock DockPanel.Dock="Left" VerticalAlignment="Center" Opacity="0.6" FontSize="12" Margin="0,0,6,0"
Text="{Binding [SettingsPath], Source={x:Static starterNg:App.Loc}}" />
<TextBlock x:Name="SettingsPathText" VerticalAlignment="Center" Opacity="0.8" FontSize="12"
TextTrimming="CharacterEllipsis" />
</StackPanel>
TextTrimming="LeadingCharacterEllipsis" />
</DockPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" HorizontalAlignment="Right">
<TextBlock x:Name="SaveStatus" VerticalAlignment="Center" Opacity="0.8" Text="" />
<Button x:Name="ChangesButton" Classes="Flat" Click="ChangesButton_OnClick"
Content="{Binding [IniChangesButton], Source={x:Static starterNg:App.Loc}}" />
<Button x:Name="ResetButton" Classes="Flat" Click="ResetButton_OnClick"
Content="{Binding [Reset], Source={x:Static starterNg:App.Loc}}" />
<Button x:Name="SaveButton" Classes="Flat" Click="SaveButton_OnClick"
@@ -64,11 +67,30 @@
<ListBoxItem Tag="physics" Content="{Binding [Physics], Source={x:Static starterNg:App.Loc}}" />
<ListBoxItem Tag="devices" Content="{Binding [Communication], Source={x:Static starterNg:App.Loc}}" />
<ListBoxItem Tag="controls" Content="{Binding [Controls], Source={x:Static starterNg:App.Loc}}" />
<ListBoxItem Tag="advanced" Content="{Binding [Advanced], Source={x:Static starterNg:App.Loc}}" />
</ListBox>
</DockPanel>
</Border>
<Panel Grid.Column="1">
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<!-- A level of detail in one go; stays in sight while the rows below follow it. -->
<Border x:Name="PresetBar" IsVisible="False" Padding="20,8"
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}" BorderThickness="0,0,0,1">
<StackPanel Spacing="4" MaxWidth="880">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10">
<TextBlock VerticalAlignment="Center" FontWeight="Bold"
Text="{Binding [GfxColPresets], Source={x:Static starterNg:App.Loc}}" />
<StackPanel x:Name="PresetButtons" Grid.Column="1" Orientation="Horizontal" Spacing="6" />
<TextBlock x:Name="PresetStateText" Grid.Column="2" VerticalAlignment="Center" FontSize="12"
Foreground="{StaticResource CarbonFgDim}" />
</Grid>
<TextBlock FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [GfxPresetDesc], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
</Border>
<Panel Grid.Row="1">
<ScrollViewer x:Name="SettingsScroll"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="SettingsStack" Margin="20,18,20,18" MaxWidth="880">
@@ -79,7 +101,15 @@
Text="{Binding [General], Source={x:Static starterNg:App.Loc}}" />
<ctl:SettingsSection Header="{Binding [General], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="lang" Label="{Binding [Language], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingRow ConfigKey="starter.exe.path" Label="{Binding [SelectEXE], Source={x:Static starterNg:App.Loc}}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
<ComboBox x:Name="SelectExeCb" Classes="Localized" SelectedIndex="0" HorizontalAlignment="Stretch" />
<Button x:Name="BrowseExeButton" Grid.Column="1" Classes="Flat" Cursor="Hand"
Click="BrowseExeButton_OnClick"
Content="{Binding [SelectEXEBrowse], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="lang" x:Name="LanguageRow" Label="{Binding [Language], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="ChangeLanguageCb"
SelectionChanged="LanguageComboBox_OnSelectionChanged" />
</ctl:SettingRow>
@@ -121,7 +151,7 @@
<ctl:SettingRow ConfigKey="starter.batterydefault"
Label="{Binding [ReadyOverride], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ReadyOverrideDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="ReadyOverrideCb" SelectedIndex="0">
<ComboBox x:Name="ReadyOverrideCb" Classes="Localized" SelectedIndex="0">
<ComboBoxItem Content="{Binding [ReadyOverrideScenery], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [ReadyOverrideCold], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [ReadyOverrideReady], Source={x:Static starterNg:App.Loc}}" />
@@ -132,14 +162,6 @@
<ctl:SettingsSection Header="{Binding [Others], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="starter.exe.path" Label="{Binding [SelectEXE], Source={x:Static starterNg:App.Loc}}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
<ComboBox x:Name="SelectExeCb" SelectedIndex="0" HorizontalAlignment="Stretch" />
<Button x:Name="BrowseExeButton" Grid.Column="1" Classes="Flat" Cursor="Hand"
Click="BrowseExeButton_OnClick"
Content="{Binding [SelectEXEBrowse], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</ctl:SettingRow>
<ctl:SettingRow Description="{Binding [DebugModeDesc], Source={x:Static starterNg:App.Loc}}" ConfigKey="debugmode">
<CheckBox x:Name="DebugModeCb" Content="{Binding [DebugMode], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
@@ -149,9 +171,6 @@
<ctl:SettingRow ConfigKey="starter.logmissingvehicles" Description="{Binding [LogMissingVehicleFilesDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="LogMissingVehicleFilesCb" Content="{Binding [LogMissingVehicleFiles], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="starter.developertools" Description="{Binding [DeveloperToolsDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="DeveloperToolsCb" Content="{Binding [DeveloperTools], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
</StackPanel>
@@ -160,17 +179,48 @@
<StackPanel Tag="graphics" IsVisible="False">
<TextBlock Classes="CategoryCaption" IsVisible="False"
Text="{Binding [Graphic], Source={x:Static starterNg:App.Loc}}" />
<!-- Which renderer comes first: it decides which of the rows below the simulator reads. -->
<ctl:SettingsSection Header="{Binding [GfxColRendering], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="gfxrenderer" Label="{Binding [RenderEngine], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [RenderEngineDesc], Source={x:Static starterNg:App.Loc}}">
<StackPanel Spacing="6">
<ComboBox x:Name="RenderEngineCb" Classes="Localized" SelectedIndex="0"
SelectionChanged="RenderEngineCb_OnSelectionChanged">
<ComboBoxItem Content="{Binding [REFull], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOld], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimp], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REExperimental], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
<TextBlock x:Name="RenderEngineWarning" IsVisible="False" TextWrapping="Wrap"
Foreground="{StaticResource CarbonAccentBright}"
Text="{Binding [REExperimentalWarning], Source={x:Static starterNg:App.Loc}}" />
<!-- Amber like the UART warnings: this one stops the simulator. -->
</StackPanel>
</ctl:SettingRow>
<ctl:SettingRow x:Name="GpuRow" ConfigKey="starter.gpu" Label="{Binding [GpuChoice], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="GpuCb" Classes="Localized" SelectedIndex="0"
SelectionChanged="GpuCb_OnSelectionChanged">
<ComboBoxItem Content="{Binding [GpuDiscrete], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [GpuSystem], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.angleplatform" Description="{Binding [RenderAngleVulkanDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="RenderAngleVulkanCb" Content="{Binding [RenderAngleVulkan], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.skippipeline" Description="{Binding [SkipPipelineDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="SkipPipelineCb" Content="{Binding [SkipPipeline], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="usevbo" Description="{Binding [RenderVBODesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="RenderVBOCb" Content="{Binding [RenderVBO], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection Header="{Binding [GfxColResolution], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="width, height" Label="{Binding [Resolution], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="ResolutionCb" SelectedIndex="0">
<ComboBoxItem>3840x2160</ComboBoxItem>
<ComboBoxItem>2560x1440</ComboBoxItem>
<ComboBoxItem>1920x1080</ComboBoxItem>
<ComboBoxItem>1600x900</ComboBoxItem>
<ComboBoxItem>1366x768</ComboBoxItem>
<ComboBoxItem>1280x720</ComboBoxItem>
</ComboBox>
<ComboBox x:Name="ResolutionCb" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="fullscreenwindowed">
<CheckBox x:Name="FullscreenWindowedCb" Content="{Binding [FullscreenWindowed], Source={x:Static starterNg:App.Loc}}" />
@@ -181,32 +231,45 @@
<Slider Name="bufferScale" Minimum="50" Maximum="200" TickFrequency="1"
IsSnapToTickEnabled="True" Value="100" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="maxtexturesize" x:Name="TexResolutionRow"
Label="{Binding [MaxTexResolution], Source={x:Static starterNg:App.Loc}}">
<Slider Name="textureResolutionSlider" Minimum="9" Maximum="14" TickFrequency="1"
IsSnapToTickEnabled="True" Value="12"
ValueChanged="TextureResolutionSlider_OnValueChanged" />
<ctl:SettingRow ConfigKey="fieldofview" Label="{Binding [FieldOfView], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [FieldOfViewDesc], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #fovSlider.Value, StringFormat='{}{0:0}°'}">
<Slider Name="fovSlider" Minimum="10" Maximum="75" TickFrequency="5"
IsSnapToTickEnabled="True" Value="45" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="maxcabtexturesize" x:Name="CabTexResolutionRow"
Label="{Binding [MaxTexCabResolution], Source={x:Static starterNg:App.Loc}}">
<Slider Name="cabTextureResolutionSlider" Minimum="9" Maximum="14" TickFrequency="1"
IsSnapToTickEnabled="True" Value="12"
ValueChanged="CabTextureResolutionSlider_OnValueChanged" />
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection Header="{Binding [GfxColSmoothness], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="vsync" Description="{Binding [VSyncDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="VSyncCb" Content="{Binding [VSync], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="anisotropicfiltering" x:Name="TexFilteringRow"
Label="{Binding [TexFilteringQuality], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [TexFilteringQualityDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="TexFilteringSlider" Minimum="1" Maximum="5" TickFrequency="1"
IsSnapToTickEnabled="True" Value="4"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="multisampling" x:Name="MultisamplingRow"
Label="{Binding [Multisampling], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [MultisamplingDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="MultisamplingSlider" Minimum="1" Maximum="4" TickFrequency="1"
IsSnapToTickEnabled="True" Value="3"
<ctl:SettingRow ConfigKey="fpslimit" x:Name="FpsLimitRow"
Label="{Binding [FpsLimit], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [FpsLimitDesc], Source={x:Static starterNg:App.Loc}}">
<StackPanel Spacing="4">
<!-- Moves through no limit and Steps.FrameRateLimits; see SliderSteps. -->
<Slider x:Name="FpsLimitSlider" ValueChanged="QualitySlider_OnValueChanged" />
<!-- The screens found, each with a button to match the limit to it. -->
<WrapPanel x:Name="MonitorRates" IsVisible="False" />
</StackPanel>
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection x:Name="DrawRangeSection" Header="{Binding [GfxColDrawRange], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:KeySlot Key="basedrawrange" />
<ctl:KeySlot Key="targetfps" />
<ctl:SettingRow ConfigKey="gfx.drawrange.factor.max" x:Name="RenderRangeRow"
Label="{Binding [RenderRange], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [RenderRangeDesc], Source={x:Static starterNg:App.Loc}}">
<!-- Moves through Steps.DrawRangeFactors; see SliderSteps. -->
<Slider x:Name="RenderRangeSlider"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:KeySlot Key="sleeperdistance" />
<ctl:SettingRow ConfigKey="splinefidelity" x:Name="TrackCurvesRow"
Label="{Binding [TrackCurvesQuality], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [TrackCurvesQualityDesc], Source={x:Static starterNg:App.Loc}}">
@@ -214,38 +277,44 @@
IsSnapToTickEnabled="True" Value="4"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.drawrange.factor.max" x:Name="RenderRangeRow"
Label="{Binding [RenderRange], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [RenderRangeDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="RenderRangeSlider" Minimum="1" Maximum="3" TickFrequency="1"
IsSnapToTickEnabled="True" Value="1"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection Header="{Binding [GfxColRendering], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingsSection x:Name="TexturesSection" Header="{Binding [GfxColTextures], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="gfxrenderer" Label="{Binding [RenderEngine], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [RenderEngineDesc], Source={x:Static starterNg:App.Loc}}">
<StackPanel Spacing="6">
<ComboBox x:Name="RenderEngineCb" SelectedIndex="0"
SelectionChanged="RenderEngineCb_OnSelectionChanged">
<ComboBoxItem Content="{Binding [REFull], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOld], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimpShader], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RESimp], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REOff], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [REExperimental], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
<TextBlock x:Name="RenderEngineWarning" IsVisible="False" TextWrapping="Wrap"
Foreground="{StaticResource CarbonAccentBright}"
Text="{Binding [REExperimentalWarning], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
<ctl:SettingRow ConfigKey="maxtexturesize" x:Name="TexResolutionRow"
Label="{Binding [MaxTexResolution], Source={x:Static starterNg:App.Loc}}">
<Slider Name="textureResolutionSlider" Minimum="6" Maximum="13" TickFrequency="1"
IsSnapToTickEnabled="True" Value="12"
ValueChanged="TextureResolutionSlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.angleplatform" Description="{Binding [RenderAngleVulkanDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="RenderAngleVulkanCb" Content="{Binding [RenderAngleVulkan], Source={x:Static starterNg:App.Loc}}" />
<ctl:SettingRow ConfigKey="maxcabtexturesize" x:Name="CabTexResolutionRow"
Label="{Binding [MaxTexCabResolution], Source={x:Static starterNg:App.Loc}}">
<Slider Name="cabTextureResolutionSlider" Minimum="9" Maximum="13" TickFrequency="1"
IsSnapToTickEnabled="True" Value="12"
ValueChanged="CabTextureResolutionSlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="anisotropicfiltering" x:Name="TexFilteringRow"
Label="{Binding [TexFilteringQuality], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [TexFilteringQualityDesc], Source={x:Static starterNg:App.Loc}}">
<!-- Moves through Steps.Anisotropy; see SliderSteps. -->
<Slider x:Name="TexFilteringSlider"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="multisampling" x:Name="MultisamplingRow"
Label="{Binding [Multisampling], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [MultisamplingDesc], Source={x:Static starterNg:App.Loc}}">
<!-- Moves through Steps.MultisamplingLevels; see SliderSteps. -->
<Slider x:Name="MultisamplingSlider"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:KeySlot Key="gfx.textures.streaming" />
<ctl:KeySlot Key="gfx.textures.releasedistance" />
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection x:Name="EffectsSection" Header="{Binding [GfxColEffects], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="gfx.extraeffects">
<CheckBox x:Name="AdditionalShadersCb" Content="{Binding [AdditionalShadersEffects], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
@@ -255,26 +324,7 @@
<ctl:SettingRow ConfigKey="gfx.postfx.motionblur.enabled">
<CheckBox x:Name="MotionBlurCb" Content="{Binding [MotionBlur], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.skippipeline" Description="{Binding [SkipPipelineDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="SkipPipelineCb" Content="{Binding [SkipPipeline], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="usevbo" Description="{Binding [RenderVBODesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="RenderVBOCb" Content="{Binding [RenderVBO], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="vsync" Description="{Binding [VSyncDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="VSyncCb" Content="{Binding [VSync], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="fpslimit" Margin="0,0,0,4"
ValueText="{Binding #FpsLimitSlider.Value, StringFormat='{}{0:0} FPS'}">
<CheckBox x:Name="FpsLimitEnableCb" Content="{Binding [FpsLimitEnable], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="fpslimit">
<Slider x:Name="FpsLimitSlider" Minimum="1" Maximum="100" TickFrequency="1"
IsSnapToTickEnabled="True" Value="30"
IsEnabled="{Binding #FpsLimitEnableCb.IsChecked}" />
</ctl:SettingRow>
<ctl:KeySlot Key="gfx.postfx.motionblur.shutter" />
<ctl:SettingRow ConfigKey="gfx.smoke" Margin="0,0,0,4">
<CheckBox x:Name="SmokeDisplayCb" Content="{Binding [SmokeDisplay], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
@@ -285,29 +335,26 @@
IsSnapToTickEnabled="True" Value="2"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow Description="{Binding [DynamicLightsDesc], Source={x:Static starterNg:App.Loc}}" ConfigKey="dynamiclights" Label="{Binding [DynamicLights], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #DynamicLightsSlider.Value, StringFormat='{}{0:0}'}">
<Slider x:Name="DynamicLightsSlider" Minimum="0" Maximum="7" TickFrequency="1"
IsSnapToTickEnabled="True" Value="7" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="fieldofview" Label="{Binding [FieldOfView], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [FieldOfViewDesc], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #fovSlider.Value, StringFormat='{}{0:0}°'}">
<Slider Name="fovSlider" Minimum="15" Maximum="75" TickFrequency="5"
IsSnapToTickEnabled="True" Value="45" />
</ctl:SettingRow>
<ctl:KeySlot Key="skyenabled" />
<ctl:KeySlot Key="rendercab" />
<ctl:KeySlot Key="newaircouplers" />
<ctl:KeySlot Key="smoothtraction" />
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection Header="{Binding [GfxColShadows], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingsSection ToggleKey="shadows" x:Name="ShadowsSection" Header="{Binding [GfxColShadows], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingsSection.Toggle>
<CheckBox x:Name="RenderShadowsCb" FontSize="12" MinHeight="0"
Content="{Binding [RenderShadows], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingsSection.Toggle>
<StackPanel IsEnabled="{Binding #RenderShadowsCb.IsChecked}">
<ctl:SettingRow Description="{Binding [ShadowDisplayDesc], Source={x:Static starterNg:App.Loc}}" ConfigKey="gfx.shadow.rank.cutoff" Label="{Binding [ShadowDisplay], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="ShadowDisplayCb" SelectedIndex="0">
<ComboBox x:Name="ShadowDisplayCb" Classes="Localized" SelectedIndex="0">
<ComboBoxItem Content="{Binding [CSROnlyImportant], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [CSRLimited], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [CSRAll], Source={x:Static starterNg:App.Loc}}" />
@@ -315,13 +362,15 @@
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="shadowtune" x:Name="ShadowResolutionRow"
Label="{Binding [ShadowsResolution], Source={x:Static starterNg:App.Loc}}">
<Slider Name="shaderResolutionSlider" Minimum="9" Maximum="14" TickFrequency="1"
<Slider Name="shaderResolutionSlider" Minimum="9" Maximum="13" TickFrequency="1"
IsSnapToTickEnabled="True" Value="12"
ValueChanged="shaderResolutionSlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="shadowtune" x:Name="ShadowRangeRow"
Label="{Binding [ShaderRange], Source={x:Static starterNg:App.Loc}}">
<Slider Name="shaderRange" Minimum="25" Maximum="400" TickFrequency="5"
Label="{Binding [ShaderRange], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ShaderRangeDesc], Source={x:Static starterNg:App.Loc}}">
<!-- The simulator's floor follows the map size: see shaderResolutionSlider_OnValueChanged. -->
<Slider Name="shaderRange" Minimum="75" Maximum="600" TickFrequency="5"
IsSnapToTickEnabled="True" Value="150"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
@@ -332,12 +381,13 @@
IsSnapToTickEnabled="True" Value="30"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.shadow.angle.min" Label="{Binding [ShadowAngleLimit], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ShadowAngleLimitDesc], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #ShadowAngleLimitSlider.Value, StringFormat='{}{0:0.0}'}">
<ctl:SettingRow ConfigKey="gfx.shadow.angle.min" x:Name="ShadowAngleLimitRow"
Label="{Binding [ShadowAngleLimit], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ShadowAngleLimitDesc], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<Slider x:Name="ShadowAngleLimitSlider" Minimum="0.2" Maximum="1.0"
TickFrequency="0.1" Value="0.2" HorizontalAlignment="Stretch" />
TickFrequency="0.05" IsSnapToTickEnabled="True" Value="0.2" HorizontalAlignment="Stretch"
ValueChanged="QualitySlider_OnValueChanged" />
<DockPanel>
<TextBlock DockPanel.Dock="Right" Classes="FieldHint"
Text="{Binding [ShadowLimitShort], Source={x:Static starterNg:App.Loc}}" />
@@ -349,25 +399,30 @@
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection Header="{Binding [GfxColReflections], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingsSection x:Name="ReflectionsSection" Header="{Binding [GfxColReflections], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="gfx.envmap.enabled" Description="{Binding [ReflectionsCubeMapDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ReflectionsCubeMapCb" Content="{Binding [ReflectionsCubeMap], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.reflections.fidelity" Label="{Binding [ReflectionsDetails], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ReflectionsDetailsDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="ReflectionsDetailsCb" SelectedIndex="0">
<ComboBoxItem Content="{Binding [RDTerrain], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RDTerrainModels], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RDTerrainModelsVehicles], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.reflections.framerate" x:Name="ReflectionsFramerateRow"
Label="{Binding [ReflectionsFramerate], Source={x:Static starterNg:App.Loc}}">
<Slider Name="reflectionsFramerate" Minimum="5" Maximum="120" TickFrequency="5"
IsSnapToTickEnabled="True" Value="30"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<StackPanel x:Name="ReflectionRows">
<ctl:SettingRow ConfigKey="gfx.reflections.fidelity" Label="{Binding [ReflectionsDetails], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ReflectionsDetailsDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="ReflectionsDetailsCb" Classes="Localized" SelectedIndex="0">
<ComboBoxItem Content="{Binding [RDTerrain], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RDTerrainModels], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Content="{Binding [RDTerrainModelsVehicles], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.reflections.framerate" x:Name="ReflectionsFramerateRow"
Label="{Binding [ReflectionsFramerate], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [ReflectionsFramerateDesc], Source={x:Static starterNg:App.Loc}}">
<!-- Moves through Steps.ReflectionUpdates; see SliderSteps. -->
<Slider Name="reflectionsFramerate"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:KeySlot Key="gfx.reflections.range_instances" />
<ctl:KeySlot Key="gfx.reflections.range_vehicles" />
</StackPanel>
</StackPanel>
</ctl:SettingsSection>
@@ -376,18 +431,23 @@
<ctl:SettingRow ConfigKey="python.enabled" Description="{Binding [RenderScreensDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="RenderScreensCb" Content="{Binding [RenderScreens], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="python.threadedupload">
<CheckBox x:Name="RenderScreensThreadCb" Content="{Binding [RenderScreensThread], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="python.updatetime" Label="{Binding [PyScreenPriority], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [PyScreenPriorityDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="PyScreenPriorityCb">
<ComboBoxItem Tag="200" Content="{Binding [PyPriorityNormal], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="400" Content="{Binding [PyPriorityLower], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="800" Content="{Binding [PyPriorityLowest], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="1600" Content="{Binding [PyPriorityIdle], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
</ctl:SettingRow>
<StackPanel IsEnabled="{Binding #RenderScreensCb.IsChecked}">
<ctl:SettingRow ConfigKey="python.threadedupload">
<CheckBox x:Name="RenderScreensThreadCb" Content="{Binding [RenderScreensThread], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="python.updatetime" Label="{Binding [PyScreenPriority], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [PyScreenPriorityDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="PyScreenPriorityCb" Classes="Localized">
<ComboBoxItem Tag="200" Content="{Binding [PyPriorityNormal], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="400" Content="{Binding [PyPriorityLower], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="800" Content="{Binding [PyPriorityLowest], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="1600" Content="{Binding [PyPriorityIdle], Source={x:Static starterNg:App.Loc}}" />
</ComboBox>
</ctl:SettingRow>
<ctl:KeySlot Key="python.fpslimit" />
<ctl:KeySlot Key="python.vsync" />
<ctl:KeySlot Key="python.displaywindows" />
</StackPanel>
</StackPanel>
</ctl:SettingsSection>
</StackPanel>
@@ -396,7 +456,7 @@
<StackPanel Tag="sound" IsVisible="False">
<TextBlock Classes="CategoryCaption" IsVisible="False"
Text="{Binding [Sounds], Source={x:Static starterNg:App.Loc}}" />
<ctl:SettingsSection Header="{Binding [Sounds], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingsSection ToggleKey="soundenabled" Header="{Binding [Sounds], Source={x:Static starterNg:App.Loc}}">
<ctl:SettingsSection.Toggle>
<CheckBox x:Name="EnableSoundsCb" FontSize="12" MinHeight="0"
Content="{Binding [EnableSounds], Source={x:Static starterNg:App.Loc}}" />
@@ -404,27 +464,27 @@
<StackPanel IsEnabled="{Binding #EnableSoundsCb.IsChecked}">
<ctl:SettingRow ConfigKey="sound.volume" Label="{Binding [Volume], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #VolumeSlider.Value, StringFormat='{}{0:0}%'}">
<Slider x:Name="VolumeSlider" Minimum="1" Maximum="100" TickFrequency="5"
<Slider x:Name="VolumeSlider" Minimum="0" Maximum="100" TickFrequency="5"
IsSnapToTickEnabled="True" Value="100" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="sound.volume.radio" Label="{Binding [RadiophoneVolume], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #RadioVolumeSlider.Value, StringFormat='{}{0:0}%'}">
<Slider x:Name="RadioVolumeSlider" Minimum="1" Maximum="100" TickFrequency="5"
<Slider x:Name="RadioVolumeSlider" Minimum="0" Maximum="100" TickFrequency="5"
IsSnapToTickEnabled="True" Value="100" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="sound.volume.vehicle" Label="{Binding [VehiclesVolume], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #VehiclesVolumeSlider.Value, StringFormat='{}{0:0}%'}">
<Slider x:Name="VehiclesVolumeSlider" Minimum="1" Maximum="100" TickFrequency="5"
<Slider x:Name="VehiclesVolumeSlider" Minimum="0" Maximum="100" TickFrequency="5"
IsSnapToTickEnabled="True" Value="100" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="sound.volume.positional" Label="{Binding [PositionedSoundsVolume], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #PositionalVolumeSlider.Value, StringFormat='{}{0:0}%'}">
<Slider x:Name="PositionalVolumeSlider" Minimum="1" Maximum="100" TickFrequency="5"
<Slider x:Name="PositionalVolumeSlider" Minimum="0" Maximum="100" TickFrequency="5"
IsSnapToTickEnabled="True" Value="100" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="sound.volume.ambient" Label="{Binding [AmbientVolume], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #AmbientVolumeSlider.Value, StringFormat='{}{0:0}%'}">
<Slider x:Name="AmbientVolumeSlider" Minimum="1" Maximum="100" TickFrequency="5"
<Slider x:Name="AmbientVolumeSlider" Minimum="0" Maximum="100" TickFrequency="5"
IsSnapToTickEnabled="True" Value="100" />
</ctl:SettingRow>
</StackPanel>
@@ -465,20 +525,20 @@
<CheckBox x:Name="CrashDamageCb" Content="{Binding [CrashDamage], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="friction" Label="{Binding [Friction], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [FrictionDesc], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #FrictionSlider.Value, StringFormat='{}{0:0.00}'}">
<Slider x:Name="FrictionSlider" Minimum="0.1" Maximum="3.0" TickFrequency="0.1" Value="1.0" />
<ctl:SettingRow ConfigKey="friction" x:Name="FrictionRow" Label="{Binding [Friction], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [FrictionDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="FrictionSlider" Minimum="0.1" Maximum="3.0" TickFrequency="0.1" Value="1.0"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="brakestep" Label="{Binding [BrakeStep], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [BrakeStepDesc], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #BrakeStepSlider.Value, StringFormat='{}{0:0.00}'}">
<Slider x:Name="BrakeStepSlider" Minimum="0.4" Maximum="3.0" TickFrequency="0.1" Value="1.0" />
<ctl:SettingRow ConfigKey="brakestep" x:Name="BrakeStepRow" Label="{Binding [BrakeStep], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [BrakeStepDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="BrakeStepSlider" Minimum="0.4" Maximum="3.0" TickFrequency="0.1" Value="1.0"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="brakespeed" Label="{Binding [BrakeSpeed], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [BrakeSpeedDesc], Source={x:Static starterNg:App.Loc}}"
ValueText="{Binding #BrakeSpeedSlider.Value, StringFormat='{}{0:0.00}'}">
<Slider x:Name="BrakeSpeedSlider" Minimum="0.4" Maximum="3.0" TickFrequency="0.1" Value="1.0" />
<ctl:SettingRow ConfigKey="brakespeed" x:Name="BrakeSpeedRow" Label="{Binding [BrakeSpeed], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [BrakeSpeedDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="BrakeSpeedSlider" Minimum="0.4" Maximum="3.0" TickFrequency="0.1" Value="1.0"
ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
@@ -495,7 +555,7 @@
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="feedbackmode" Label="{Binding [Feedback], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [FeedbackDesc], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="FeedbackCb" SelectedIndex="0">
<ComboBox x:Name="FeedbackCb" Classes="Localized" SelectedIndex="0">
<ComboBoxItem Tag="0" Content="{Binding [FeedbackOff], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="1" Content="{Binding [FeedbackCASHP], Source={x:Static starterNg:App.Loc}}" />
<ComboBoxItem Tag="2" Content="{Binding [FeedbackCA], Source={x:Static starterNg:App.Loc}}" />
@@ -510,11 +570,104 @@
<NumericUpDown x:Name="FeedbackPortNud" Minimum="0" Maximum="65535" Value="888"
Increment="1" FormatString="0" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uart" x:Name="ComButtonRow" IsVisible="False">
<Button x:Name="ComButton" Classes="Flat" Cursor="Hand"
</StackPanel>
</ctl:SettingsSection>
<!-- Shown for the serial port feedback; saved with everything else on this page. -->
<ctl:SettingsSection x:Name="UartSettings" Header="{Binding [UartSection], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="uart">
<CheckBox x:Name="UartEnabledCb" Content="{Binding [UartEnable], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uart" Classes="KeyAbove" Label="{Binding [UartPort], Source={x:Static starterNg:App.Loc}}">
<StackPanel Classes="Field" Spacing="4">
<ComboBox x:Name="UartPortCb" IsEditable="True" HorizontalAlignment="Stretch"
PlaceholderText="{Binding [UartPortNone], Source={x:Static starterNg:App.Loc}}" />
<TextBlock x:Name="UartPortWarning" IsVisible="False" FontSize="12" TextWrapping="Wrap"
Foreground="#E0A030"
Text="{Binding [UartPortMissing], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uart" Classes="KeyAbove" Label="{Binding [UartBaud], Source={x:Static starterNg:App.Loc}}">
<ComboBox x:Name="UartBaudCb" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uart" Classes="KeyAbove" Label="{Binding [UartEvery], Source={x:Static starterNg:App.Loc}}">
<NumericUpDown x:Name="UartEveryNud" Minimum="0" Maximum="10" Increment="0.05" FormatString="0.###" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uarttune" Label="{Binding [UartTune], Source={x:Static starterNg:App.Loc}}">
<StackPanel Classes="Field" Spacing="4">
<TextBox x:Name="UartTuneBox" TextWrapping="Wrap"
PlaceholderText="{Binding [UartTunePlaceholder], Source={x:Static starterNg:App.Loc}}" />
<TextBlock x:Name="UartTuneWarning" IsVisible="False" FontSize="12" TextWrapping="Wrap"
Foreground="#E0A030" />
</StackPanel>
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uartdebug">
<CheckBox x:Name="UartDebugCb" Content="{Binding [UartDebug], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="uartfeature" Classes="TopLabel" Label="{Binding [UartFeature], Source={x:Static starterNg:App.Loc}}">
<UniformGrid Columns="2" RowSpacing="8" ColumnSpacing="8">
<CheckBox x:Name="UartMainCb" Content="{Binding [UartMain], Source={x:Static starterNg:App.Loc}}" />
<CheckBox x:Name="UartScndCb" Content="{Binding [UartScnd], Source={x:Static starterNg:App.Loc}}" />
<CheckBox x:Name="UartTrainCb" Content="{Binding [UartTrain], Source={x:Static starterNg:App.Loc}}" />
<CheckBox x:Name="UartLocalCb" Content="{Binding [UartLocal], Source={x:Static starterNg:App.Loc}}" />
<CheckBox x:Name="UartRadioVolumeCb" Content="{Binding [UartRadioVolume], Source={x:Static starterNg:App.Loc}}" />
<CheckBox x:Name="UartRadioChannelCb" Content="{Binding [UartRadioChannel], Source={x:Static starterNg:App.Loc}}" />
</UniformGrid>
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
</StackPanel>
<!-- ============================ ADVANCED ============================ -->
<StackPanel Tag="advanced" IsVisible="False">
<TextBlock Classes="CategoryCaption" IsVisible="False"
Text="{Binding [Advanced], Source={x:Static starterNg:App.Loc}}" />
<ctl:SettingsSection Header="{Binding [AdvancedResources], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow ConfigKey="compresstex" Description="{Binding [CompressTexturesDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="CompressTexCb" Content="{Binding [CompressTextures], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="scalespeculars" Description="{Binding [ScaleSpecularsDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ScaleSpecularsCb" Content="{Binding [ScaleSpeculars], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="python.mipmaps" Description="{Binding [ScreenMipmapsDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ScreenMipmapsCb" Content="{Binding [ScreenMipmaps], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.shadergamma" Description="{Binding [ShaderGammaDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ShaderGammaCb" Content="{Binding [ShaderGamma], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.usegles" Description="{Binding [UseGLESDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="UseGlesCb" Content="{Binding [UseGLES], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="convertmodels" Description="{Binding [ExtendedModelConversionDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ConvertModelsCb" Content="{Binding [ExtendedModelConversion], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.resource.sweep" Description="{Binding [GfxResourceSweepDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ResourceSweepCb" Content="{Binding [GfxResourceSweep], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="gfx.resource.move" Description="{Binding [GfxResourceMoveDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="ResourceMoveCb" Content="{Binding [GfxResourceMove], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="async.trainThreads" x:Name="TrainThreadsRow"
Label="{Binding [TrainPhysicsThreads], Source={x:Static starterNg:App.Loc}}"
Description="{Binding [TrainPhysicsThreadsDesc], Source={x:Static starterNg:App.Loc}}">
<Slider x:Name="TrainThreadsSlider" Minimum="0" Maximum="16" TickFrequency="1"
IsSnapToTickEnabled="True" ValueChanged="QualitySlider_OnValueChanged" />
</ctl:SettingRow>
<ctl:SettingRow ConfigKey="starter.ignoreirrelevant" Description="{Binding [IgnoreIrrelevantTrainsDesc], Source={x:Static starterNg:App.Loc}}">
<CheckBox x:Name="IgnoreIrrelevantCb" Content="{Binding [IgnoreIrrelevantTrains], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
<ctl:SettingsSection Header="{Binding [AdvancedLogTools], Source={x:Static starterNg:App.Loc}}">
<StackPanel>
<ctl:SettingRow Description="{Binding [OpenLogToolsDesc], Source={x:Static starterNg:App.Loc}}">
<Button x:Name="OpenLogToolsButton" Classes="Flat" Cursor="Hand"
HorizontalContentAlignment="Center"
Click="ComButton_OnClick"
Content="{Binding [UartSection], Source={x:Static starterNg:App.Loc}}" />
Click="OpenLogToolsButton_OnClick"
Content="{Binding [OpenLogTools], Source={x:Static starterNg:App.Loc}}" />
</ctl:SettingRow>
</StackPanel>
</ctl:SettingsSection>
@@ -526,7 +679,8 @@
<!-- Key bindings bring their own scrolling and a star-sized middle
row, so they live beside the scroller, not inside it. -->
<Grid x:Name="ControlsHost" RowDefinitions="Auto,*,Auto" Margin="8" IsVisible="False" />
</Panel>
</Panel>
</Grid>
</Grid>
</DockPanel>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia.Controls;
using Avalonia.LogicalTree;
using StarterNG.Controls;
using StarterNG.Domain.Settings;
namespace StarterNG.Views;
/// <summary>
/// The rows of the <see cref="SimulatorKeys"/>, one section per group, added to
/// the end of their settings category. A row writes its key only once the user
/// moves it away from what it showed, so a value the control cannot show - a draw
/// range above the slider's end - survives a save untouched.
/// </summary>
internal sealed class SimulatorKeyRows
{
private sealed record Row(SimulatorKey Key, SettingRow Setting, Control Input)
{
public string Shown = string.Empty;
}
private readonly List<Row> _rows = new();
private readonly List<(SettingsSection Section, string HeaderKey)> _sections = new();
public event Action? Changed;
/// <summary>Raised on every move of a row, the starter's own setting of it included.</summary>
public event Action<SimulatorKey>? Moved;
/// <summary>
/// Puts each row where the layout has a <see cref="KeySlot"/> for it; the rest
/// go to sections of their own at the end of their category.
/// </summary>
public void Build(Panel categories)
{
var slots = categories.GetLogicalDescendants().OfType<KeySlot>()
.Where(slot => slot.Key is not null)
.ToDictionary(slot => slot.Key!, StringComparer.OrdinalIgnoreCase);
foreach (var key in SimulatorKeys.All)
if (slots.TryGetValue(key.Key, out var slot))
slot.Child = BuildRow(key);
foreach (var group in SimulatorKeys.All.Where(k => !slots.ContainsKey(k.Key))
.GroupBy(k => (k.Category, k.Section)))
{
var category = categories.Children.OfType<StackPanel>()
.FirstOrDefault(p => string.Equals(p.Tag as string, group.Key.Category, StringComparison.Ordinal));
if (category is null)
continue;
var rows = new StackPanel();
foreach (var key in group)
rows.Children.Add(BuildRow(key));
var section = new SettingsSection { Content = rows };
_sections.Add((section, group.Key.Section));
category.Children.Add(section);
}
UpdateTexts();
}
private SettingRow BuildRow(SimulatorKey key)
{
Control input = key.Kind switch
{
SimulatorKeyKind.Toggle => new CheckBox(),
SimulatorKeyKind.Number => new Slider
{
Minimum = key.Minimum,
Maximum = key.Maximum,
TickFrequency = key.Step,
IsSnapToTickEnabled = true
},
_ => new TextBox { MinWidth = 200 }
};
// Marks the input as one of these rows, which tell about their own changes.
input.Tag = key;
var setting = new SettingRow { ConfigKey = key.Key, Content = input };
var row = new Row(key, setting, input);
_rows.Add(row);
switch (input)
{
case CheckBox box:
box.IsCheckedChanged += (_, _) => OnEdited(row);
break;
case Slider slider:
slider.ValueChanged += (_, _) =>
{
UpdateValueText(row);
OnEdited(row);
};
break;
case TextBox text:
text.TextChanged += (_, _) => OnEdited(row);
break;
}
return setting;
}
/// <summary>
/// Only a value other than the one shown counts: a text box reports the text it
/// was given after the fact, and a slider reports its own clamping.
/// </summary>
private void OnEdited(Row row)
{
Moved?.Invoke(row.Key);
if (Current(row) != row.Shown)
Changed?.Invoke();
}
/// <summary>The row of <paramref name="key"/>, wherever it was put.</summary>
public SettingRow? RowOf(string key) => Find(key)?.Setting;
private Row? Find(string key) =>
_rows.FirstOrDefault(row => string.Equals(row.Key.Key, key, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// The number a row holds once it says something: moved by the user, or read
/// from a file that has the key. Null while it only shows the default, which
/// the simulator may not share.
/// </summary>
public double? NumberIfSet(string key, SimulatorSettings settings)
{
var row = Find(key);
if (row is null || row.Input is not Slider slider)
return null;
if (Current(row) == row.Shown && !settings.Extra.ContainsKey(row.Key.Key))
return null;
return slider.Value;
}
/// <summary>Labels in the current language; called again when it changes.</summary>
public void UpdateTexts()
{
foreach (var (section, headerKey) in _sections)
section.Header = App.Loc[headerKey];
foreach (var row in _rows)
{
string label = App.Loc[row.Key.LabelKey];
row.Setting.Description = App.Loc[row.Key.DescriptionKey];
switch (row.Input)
{
case CheckBox box:
box.Content = label;
break;
case TextBox text:
row.Setting.Label = label;
text.PlaceholderText = App.Loc["IniDefault"];
break;
default:
row.Setting.Label = label;
UpdateValueText(row);
break;
}
}
}
public void Apply(SimulatorSettings settings)
{
foreach (var row in _rows)
{
Put(row, row.Key.RawIn(settings));
row.Shown = Current(row);
}
}
/// <param name="remember">
/// Whether what is handed over counts as shown from now on: for the settings a
/// save writes, not for a copy to try something on.
/// </param>
public void Capture(SimulatorSettings settings, bool remember)
{
foreach (var row in _rows)
{
string now = Current(row);
if (now == row.Shown)
continue;
settings.Extra[row.Key.Key] = now;
if (remember)
row.Shown = now;
}
}
/// <summary>
/// Puts <paramref name="raw"/> in the row of <paramref name="key"/> as an edit
/// by hand would: what it shows stays what it showed, so a save writes it.
/// </summary>
public void Show(string key, string raw)
{
if (Find(key) is { } row)
Put(row, raw);
}
/// <summary>Puts the simulator's own value in the row of <paramref name="key"/>, as <see cref="Show"/> does.</summary>
public void ShowDefault(string key)
{
if (Find(key) is { } row)
Put(row, row.Key.Default);
}
private static void Put(Row row, string raw)
{
switch (row.Input)
{
case CheckBox box:
box.IsChecked = SimulatorKey.ParseToggle(raw);
break;
case Slider slider:
slider.Value = Math.Clamp(row.Key.ParseNumber(raw), row.Key.Minimum, row.Key.Maximum);
break;
case TextBox text:
text.Text = raw.Trim();
break;
}
}
private static string Current(Row row) => row.Input switch
{
CheckBox box => SimulatorKey.FormatToggle(box.IsChecked == true),
Slider slider => row.Key.FormatNumber(slider.Value),
TextBox text => (text.Text ?? string.Empty).Trim(),
_ => string.Empty
};
private static void UpdateValueText(Row row)
{
if (row.Input is not Slider slider)
return;
double value = slider.Value;
row.Setting.ValueText = value == 0 && row.Key.ZeroIsOff
? App.Loc["RangeDisabled"]
: value.ToString(NumberFormat(row.Key.Step), CultureInfo.CurrentCulture) + row.Key.Unit;
}
private static string NumberFormat(double step) =>
step >= 1 ? "0" : step >= 0.1 ? "0.0" : step >= 0.01 ? "0.00" : "0.000";
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using StarterNG.Domain.Settings;
namespace StarterNG.Views;
/// <summary>
/// A slider that moves through a list of values rather than along a range: its
/// position is an index, so the values sit evenly on the track however far apart
/// they are - 30 and 144 frames alike. A value the list lacks joins it while
/// shown, so opening and saving the settings never moves it.
/// </summary>
internal sealed class SliderSteps
{
private readonly Slider _slider;
private readonly IReadOnlyList<double> _steps;
private IReadOnlyList<double> _shown;
public SliderSteps(Slider slider, IReadOnlyList<double> steps)
{
_slider = slider;
_steps = steps;
_shown = steps;
slider.Minimum = 0;
slider.TickFrequency = 1;
slider.IsSnapToTickEnabled = true;
slider.Maximum = steps.Count - 1;
}
public double Value
{
get => _shown[Math.Clamp((int)Math.Round(_slider.Value), 0, _shown.Count - 1)];
set
{
_shown = Steps.With(_steps, value);
_slider.Maximum = _shown.Count - 1;
int at = Steps.IndexOf(_shown, value);
_slider.Value = at;
}
}
}

View File

@@ -1,129 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Application;
namespace StarterNG.Views;
public sealed class UartWindow : Window
{
private readonly CheckBox _enabled = new();
private readonly TextBox _port = new() { PlaceholderText = "COM3", MinWidth = 170 };
private readonly TextBox _tune = new() { MinWidth = 170 };
private readonly CheckBox _debug = new();
private readonly CheckBox _main = new();
private readonly CheckBox _scnd = new();
private readonly CheckBox _train = new();
private readonly CheckBox _local = new();
private readonly CheckBox _radioVolume = new();
private readonly CheckBox _radioChannel = new();
public UartWindow()
{
Title = App.Loc["UartSection"];
Width = 430;
SizeToContent = SizeToContent.Height;
CanResize = false;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
_enabled.Content = App.Loc["UartEnable"];
_debug.Content = App.Loc["UartDebug"];
_main.Content = App.Loc["UartMain"];
_scnd.Content = App.Loc["UartScnd"];
_train.Content = App.Loc["UartTrain"];
_local.Content = App.Loc["UartLocal"];
_radioVolume.Content = App.Loc["UartRadioVolume"];
_radioChannel.Content = App.Loc["UartRadioChannel"];
Load();
var root = new StackPanel { Margin = new Avalonia.Thickness(16), Spacing = 8 };
root.Children.Add(_enabled);
root.Children.Add(Row(App.Loc["UartPort"], _port));
root.Children.Add(Row(App.Loc["UartTune"], _tune));
root.Children.Add(new TextBlock
{
Text = App.Loc["UartHint"],
FontSize = 12,
Opacity = 0.65,
TextWrapping = TextWrapping.Wrap
});
root.Children.Add(_debug);
root.Children.Add(new TextBlock
{
Text = App.Loc["UartFeature"],
FontWeight = FontWeight.Bold,
Margin = new Avalonia.Thickness(0, 6, 0, 0)
});
var top = new UniformGrid { Columns = 2, Margin = new Avalonia.Thickness(0, 0, 0, 2) };
top.Children.Add(_main);
top.Children.Add(_scnd);
top.Children.Add(_train);
top.Children.Add(_local);
root.Children.Add(top);
var mid = new UniformGrid { Columns = 2 };
mid.Children.Add(_radioVolume);
mid.Children.Add(_radioChannel);
root.Children.Add(mid);
var save = new Button
{
Content = App.Loc["UartSaveClose"],
MinWidth = 140,
Margin = new Avalonia.Thickness(0, 10, 0, 0),
HorizontalAlignment = HorizontalAlignment.Right,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
};
save.Classes.Add("Accent");
save.Click += (_, _) => { Store(); Close(); };
root.Children.Add(save);
Content = root;
}
private static Control Row(string label, Control input)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
sp.Children.Add(new TextBlock
{
Text = label,
VerticalAlignment = VerticalAlignment.Center,
MinWidth = 110
});
sp.Children.Add(input);
return sp;
}
private void Load()
{
var s = AppServices.Current.Settings;
_enabled.IsChecked = s.UartEnabled;
_port.Text = s.UartPort;
_tune.Text = s.UartTune;
_debug.IsChecked = s.UartDebug;
_main.IsChecked = s.UartMain;
_scnd.IsChecked = s.UartScnd;
_train.IsChecked = s.UartTrain;
_local.IsChecked = s.UartLocal;
_radioVolume.IsChecked = s.UartRadioVolume;
_radioChannel.IsChecked = s.UartRadioChannel;
}
private void Store()
{
var s = AppServices.Current.Settings;
s.UartEnabled = _enabled.IsChecked == true;
s.UartPort = _port.Text ?? "";
s.UartTune = _tune.Text ?? "";
s.UartDebug = _debug.IsChecked == true;
s.UartMain = _main.IsChecked == true;
s.UartScnd = _scnd.IsChecked == true;
s.UartTrain = _train.IsChecked == true;
s.UartLocal = _local.IsChecked == true;
s.UartRadioVolume = _radioVolume.IsChecked == true;
s.UartRadioChannel = _radioChannel.IsChecked == true;
}
}