Show bare keys in settings, fix the depot detail tabs

Drops the vehicle counter from the General tab, and the texture tab stops
gluing "label: value" into single lines and uses the same rows as General.
This commit is contained in:
maj00r
2026-09-04 21:41:38 +02:00
parent bce008f0ed
commit 8e87eb8638
8 changed files with 93 additions and 249 deletions

View File

@@ -1,47 +0,0 @@
using StarterNG.Infrastructure.Settings;
namespace StarterNG.Tests;
public class SettingKeyCatalogTests
{
[Fact]
public void A_clamped_key_is_annotated_with_its_default_and_range()
{
Assert.Equal("dynamiclights = 7 (0-7)", SettingKeyCatalog.Annotate("dynamiclights"));
Assert.Equal("starter.bufferscale = 100 (50-200)", SettingKeyCatalog.Annotate("starter.bufferscale"));
}
[Fact]
public void An_unclamped_key_is_annotated_with_its_default_alone()
{
Assert.Equal("maxtexturesize = 4096", SettingKeyCatalog.Annotate("maxtexturesize"));
Assert.Equal("vsync = false", SettingKeyCatalog.Annotate("vsync"));
}
[Fact]
public void A_text_valued_key_is_shown_without_a_default()
{
// An absent lang means "follow the operating system", not "English", and an
// absent exe path means "go and find the binary" - neither has a default
// worth printing.
Assert.Equal("lang", SettingKeyCatalog.Annotate("lang"));
Assert.Equal("starter.exe.path", SettingKeyCatalog.Annotate("starter.exe.path"));
}
[Fact]
public void A_composite_or_unknown_key_is_shown_as_written()
{
Assert.Equal("shadowtune", SettingKeyCatalog.Annotate("shadowtune"));
Assert.Equal("", SettingKeyCatalog.Annotate(null));
}
[Fact]
public void The_annotation_matches_what_the_serializer_actually_defaults_to()
{
var settings = new StarterNG.Domain.Settings.SimulatorSettings();
new SettingsSerializer().Read(settings, new StarterNG.Classes.ConfigFile());
Assert.Equal($"maxtexturesize = {settings.MaxTextureSize}", SettingKeyCatalog.Annotate("maxtexturesize"));
Assert.Equal($"fieldofview = {settings.FieldOfView} (15-75)", SettingKeyCatalog.Annotate("fieldofview"));
}
}

View File

@@ -0,0 +1,56 @@
using Avalonia.Controls;
namespace StarterNG.Controls;
/// <summary>
/// The read-only label/value lines the depot's detail tabs are built from.
/// </summary>
/// <remarks>
/// Shared so every tab reads the same way: label in the left column, value in
/// the right, aligned down the panel. Previously each tab built its own rows,
/// and the texture tab glued "label: value" into a single line, which lined up
/// with nothing.
/// </remarks>
public static class DetailRows
{
/// <summary>Shown in place of a reading a vehicle does not have.</summary>
public const string NoValue = "–";
/// <summary>
/// The one row shape every detail tab uses. The label column negotiates its
/// width through the shared-size group on the host panel, so all rows in a
/// panel line up without anyone naming a pixel count - see the Compact styles
/// in Settings.axaml. Omitting <paramref name="content"/> makes it a
/// read-only line; omitting <paramref name="label"/> indents a bare widget to
/// the control column.
/// </summary>
public static SettingRow Row(string? label = null, Control? content = null,
string? value = null, string? description = null,
string? tip = null)
{
var row = new SettingRow
{
Label = label,
Description = description,
ValueText = value,
Content = content
};
row.Classes.Add("Compact");
if (!string.IsNullOrWhiteSpace(tip))
ToolTip.SetTip(row, tip);
return row;
}
/// <summary>
/// A read-only label/value line. The dash keeps the row from collapsing when a
/// vehicle has no reading for the field; the full value is on the tooltip, so
/// a trimmed one is still legible.
/// </summary>
public static SettingRow Reading(string label, string? value)
{
bool empty = string.IsNullOrWhiteSpace(value);
return Row(label, value: empty ? NoValue : value, tip: empty ? null : value);
}
}

View File

@@ -1,127 +0,0 @@
using System.Collections.Generic;
namespace StarterNG.Infrastructure.Settings;
/// <summary>What the settings screen can state about a key without describing it.</summary>
/// <param name="Key">The eu07.ini key itself.</param>
/// <param name="Default">The value the simulator assumes when the key is absent.</param>
/// <param name="Range">"lo-hi" when the starter clamps the value, otherwise null.</param>
public readonly record struct SettingKeyInfo(string Key, string? Default, string? Range);
/// <summary>
/// The default and the accepted range of every eu07.ini key the starter writes.
/// </summary>
/// <remarks>
/// Read straight off <see cref="SettingsSerializer"/> - these are the same
/// literals that method defaults and clamps with - so the settings screen can
/// state a fact about each key rather than a description of it. If a default or
/// a clamp changes in the serializer, it changes here too.
///
/// Text-valued keys carry no default. The literal the serializer passes to
/// GetString is the last step of a chain, not the value the simulator assumes:
/// an absent <c>lang</c> means the starter follows the operating system, and an
/// absent <c>starter.exe.path</c> means it goes looking for the binary itself.
/// Printing "lang = en" beside the language picker would state something untrue.
/// </remarks>
public static class SettingKeyCatalog
{
private static readonly Dictionary<string, SettingKeyInfo> Entries = new()
{
["ai.trainman"] = new("ai.trainman", "true", null),
["anisotropicfiltering"] = new("anisotropicfiltering", "8", null),
["async.trainThreads"] = new("async.trainThreads", "0", null),
["brakespeed"] = new("brakespeed", "3.0", null),
["brakestep"] = new("brakestep", "1.0", null),
["compresstex"] = new("compresstex", "true", null),
["convertmodels"] = new("convertmodels", "0", null),
["crashdamage"] = new("crashdamage", "true", null),
["debugmode"] = new("debugmode", "false", null),
["dynamiclights"] = new("dynamiclights", "7", "0-7"),
["enabletraction"] = new("enabletraction", "true", null),
["feedbackmode"] = new("feedbackmode", "1", "0-5"),
["feedbackport"] = new("feedbackport", "888", null),
["fieldofview"] = new("fieldofview", "45", "15-75"),
["fpslimit"] = new("fpslimit", "30", "1-100"),
["friction"] = new("friction", "1.0", null),
["fullphysics"] = new("fullphysics", "true", null),
["fullscreen"] = new("fullscreen", "false", null),
["fullscreenwindowed"] = new("fullscreenwindowed", "false", null),
["gfx.drawrange.factor.max"] = new("gfx.drawrange.factor.max", "3.0", null),
["gfx.envmap.enabled"] = new("gfx.envmap.enabled", "true", null),
["gfx.extraeffects"] = new("gfx.extraeffects", "true", null),
["gfx.postfx.chromaticaberration.enabled"] = new("gfx.postfx.chromaticaberration.enabled", "true", null),
["gfx.postfx.motionblur.enabled"] = new("gfx.postfx.motionblur.enabled", "true", null),
["gfx.reflections.fidelity"] = new("gfx.reflections.fidelity", "0", "0-2"),
["gfx.reflections.framerate"] = new("gfx.reflections.framerate", "60", null),
["gfx.resource.move"] = new("gfx.resource.move", "false", null),
["gfx.resource.sweep"] = new("gfx.resource.sweep", "true", null),
["gfx.shadergamma"] = new("gfx.shadergamma", "false", null),
["gfx.shadow.angle.min"] = new("gfx.shadow.angle.min", "0.2", null),
["gfx.shadow.rank.cutoff"] = new("gfx.shadow.rank.cutoff", "3", null),
["gfx.shadows.cab.range"] = new("gfx.shadows.cab.range", "0", null),
["gfx.skippipeline"] = new("gfx.skippipeline", "false", null),
["gfx.skiprendering"] = new("gfx.skiprendering", "false", null),
["gfx.smoke"] = new("gfx.smoke", "true", null),
["gfx.smoke.fidelity"] = new("gfx.smoke.fidelity", "1", "1-4"),
["gfx.usegles"] = new("gfx.usegles", "false", null),
["height"] = new("height", "720", null),
["inactivepause"] = new("inactivepause", "true", null),
["input.gamepad"] = new("input.gamepad", "true", null),
["lang"] = new("lang", null, null),
["livetraction"] = new("livetraction", "true", null),
["maxcabtexturesize"] = new("maxcabtexturesize", "4096", null),
["maxtexturesize"] = new("maxtexturesize", "4096", null),
["multiplelogs"] = new("multiplelogs", "false", null),
["multisampling"] = new("multisampling", "2", "0-3"),
["pause"] = new("pause", "false", null),
["physicslog"] = new("physicslog", "false", null),
["python.enabled"] = new("python.enabled", "true", null),
["python.mipmaps"] = new("python.mipmaps", "true", null),
["python.threadedupload"] = new("python.threadedupload", "true", null),
["python.updatetime"] = new("python.updatetime", "200", null),
["scalespeculars"] = new("scalespeculars", "true", null),
["shadows"] = new("shadows", "true", null),
["sound.volume"] = new("sound.volume", "1.0", null),
["sound.volume.ambient"] = new("sound.volume.ambient", "1.0", null),
["sound.volume.positional"] = new("sound.volume.positional", "1.0", null),
["sound.volume.radio"] = new("sound.volume.radio", "0.75", null),
["sound.volume.vehicle"] = new("sound.volume.vehicle", "1.0", null),
["soundenabled"] = new("soundenabled", "true", null),
["splinefidelity"] = new("splinefidelity", "1", "1-4"),
["starter.autoclose"] = new("starter.autoclose", "false", null),
["starter.batterydefault"] = new("starter.batterydefault", "0", "0-2"),
["starter.bufferscale"] = new("starter.bufferscale", "100", "50-200"),
["starter.drivableonly"] = new("starter.drivableonly", "true", null),
["starter.exe.auto"] = new("starter.exe.auto", "true", null),
["starter.exe.path"] = new("starter.exe.path", null, null),
["starter.expandtree"] = new("starter.expandtree", "false", null),
["starter.hide.archivalvehicles"] = new("starter.hide.archivalvehicles", "true", null),
["starter.ignoreirrelevant"] = new("starter.ignoreirrelevant", "false", null),
["starter.largethumbnails"] = new("starter.largethumbnails", "false", null),
["starter.last.scenery"] = new("starter.last.scenery", null, null),
["starter.logmissingvehicles"] = new("starter.logmissingvehicles", "false", null),
["starter.show.ai"] = new("starter.show.ai", "true", null),
["starter.show.archival"] = new("starter.show.archival", "true", null),
["starter.window.maximized"] = new("starter.window.maximized", "false", null),
["uart"] = new("uart", null, null),
["uartdebug"] = new("uartdebug", "false", null),
["uarttune"] = new("uarttune", null, null),
["usevbo"] = new("usevbo", "true", null),
["vsync"] = new("vsync", "false", null),
["width"] = new("width", "1280", null),
};
/// <summary>
/// The key annotation for a settings row: the key, its default and its range,
/// e.g. "dynamiclights = 7 (0-7)". Empty for a key we know nothing about, and
/// for the composite keys a row only partly owns.
/// </summary>
public static string Annotate(string? key)
{
if (string.IsNullOrEmpty(key) || !Entries.TryGetValue(key, out var info))
return key ?? "";
string text = info.Default is null ? info.Key : $"{info.Key} = {info.Default}";
return info.Range is null ? text : $"{text} ({info.Range})";
}
}

View File

@@ -55,15 +55,15 @@
<!-- Second line: the eu07.ini key this row writes, then the <!-- Second line: the eu07.ini key this row writes, then the
explanation. The key is monospaced and selectable so it explanation. The key is monospaced and selectable so it
can be matched against the file itself. --> can be copied straight into the file, and dimmed so it
stays out of the way of the setting itself. -->
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" <StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3"
Orientation="Horizontal" Spacing="8" Margin="0,3,0,0"> Orientation="Horizontal" Spacing="8" Margin="0,3,0,0">
<SelectableTextBlock Name="PART_ConfigKey" <SelectableTextBlock Name="PART_ConfigKey"
Text="{TemplateBinding ConfigKey}" Text="{TemplateBinding ConfigKey}"
FontFamily="Consolas, DejaVu Sans Mono, Liberation Mono, Menlo, Courier New" FontFamily="Consolas, DejaVu Sans Mono, Liberation Mono, Menlo, Courier New"
FontSize="11" FontSize="11"
Foreground="{StaticResource CarbonAccentBright}" Foreground="{StaticResource CarbonFgDim}"
Opacity="0.75"
VerticalAlignment="Top" /> VerticalAlignment="Top" />
<TextBlock Name="PART_Description" <TextBlock Name="PART_Description"
Text="{TemplateBinding Description}" Text="{TemplateBinding Description}"

View File

@@ -164,7 +164,7 @@
</TabItem> </TabItem>
<TabItem Header="{Binding [AboutTexture], Source={x:Static starterNg:App.Loc}}"> <TabItem Header="{Binding [AboutTexture], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="consistTexturePanel" Margin="4" Spacing="3" /> <StackPanel Name="consistTexturePanel" Margin="4" Grid.IsSharedSizeScope="True" />
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
</TabControl> </TabControl>

View File

@@ -17,6 +17,7 @@ using StarterNG.Domain;
using StarterNG.Domain.Sceneries; using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Vehicles; using StarterNG.Domain.Vehicles;
using StarterNG.Application; using StarterNG.Application;
using StarterNG.Controls;
namespace StarterNG.Views; namespace StarterNG.Views;
@@ -411,37 +412,44 @@ public sealed class VehicleBrowser
return menu; return menu;
} }
/// <summary>
/// Fills a detail panel with what is known about a livery: the skin itself,
/// then whatever its credit line carries.
/// </summary>
/// <remarks>
/// Built from the same <see cref="DetailRows"/> as the general tab, so the
/// labels line up in a column instead of each line being its own sentence.
/// Fields the credit line leaves out are dropped rather than shown as
/// blanks - most liveries fill in only a few.
/// </remarks>
public void ShowTextureInfo(VehicleTexture texture, StackPanel target) public void ShowTextureInfo(VehicleTexture texture, StackPanel target)
{ {
target.Children.Clear(); target.Children.Clear();
var full = new List<string>(); var tooltip = new List<string>();
void Row(string label, string? value)
void Reading(string label, string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) return; if (string.IsNullOrWhiteSpace(value))
string line = $"{label}: {value}"; return;
full.Add(line);
target.Children.Add(new TextBlock target.Children.Add(DetailRows.Reading(label, value));
{ tooltip.Add($"{label}: {value}");
Text = line,
FontSize = 12,
Opacity = 0.85,
TextTrimming = TextTrimming.CharacterEllipsis
});
} }
Row(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile)); Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile));
var meta = texture.Meta;
if (meta != null) if (texture.Meta is { } meta)
{ {
Row(App.Loc["TexOperator"], meta.Operator); Reading(App.Loc["TexOperator"], meta.Operator);
Row(App.Loc["TexStation"], meta.Depot); Reading(App.Loc["TexStation"], meta.Depot);
Row(App.Loc["TexRevision"], meta.RevisionDate); Reading(App.Loc["TexRevision"], meta.RevisionDate);
Row(App.Loc["TexWorks"], meta.RevisionPlace); Reading(App.Loc["TexWorks"], meta.RevisionPlace);
Row(App.Loc["TexAuthor"], meta.TextureAuthor); Reading(App.Loc["TexAuthor"], meta.TextureAuthor);
Row(App.Loc["TexPhoto"], meta.PhotoAuthor); Reading(App.Loc["TexPhoto"], meta.PhotoAuthor);
} }
ToolTip.SetTip(target, string.Join("\n", full));
ToolTip.SetTip(target, string.Join("\n", tooltip));
} }
public List<VehicleTexture> CurrentTextures() public List<VehicleTexture> CurrentTextures()

View File

@@ -147,10 +147,6 @@ public sealed class VehicleDetails
generalPanel.Children.Add(Reading($"{App.Loc["VMax"]} [km/h]", generalPanel.Children.Add(Reading($"{App.Loc["VMax"]} [km/h]",
vmax?.ToString("0", inv))); vmax?.ToString("0", inv)));
if (unit)
generalPanel.Children.Add(Reading(App.Loc["VehicleCount"],
item.Cars.Count.ToString(inv)));
generalPanel.Children.Add(Reading(App.Loc["Model"], car.MmdFile)); generalPanel.Children.Add(Reading(App.Loc["Model"], car.MmdFile));
generalPanel.Children.Add(Reading(App.Loc["Texture"], car.SkinFile)); generalPanel.Children.Add(Reading(App.Loc["Texture"], car.SkinFile));
} }
@@ -347,39 +343,12 @@ public sealed class VehicleDetails
.Any(s => s.Equals("passengers", StringComparison.OrdinalIgnoreCase)); .Any(s => s.Equals("passengers", StringComparison.OrdinalIgnoreCase));
} }
/// <summary>
/// The one row shape every detail tab uses. The label column negotiates its width
/// through the shared-size group on the host panel, so all rows in a panel line up
/// without anyone naming a pixel count - see the Compact styles in Settings.axaml.
/// Omitting <paramref name="content"/> makes it a read-only line; omitting
/// <paramref name="label"/> indents a bare widget to the control column.
/// </summary>
private static SettingRow Row(string? label = null, Control? content = null, private static SettingRow Row(string? label = null, Control? content = null,
string? value = null, string? description = null, string? value = null, string? description = null,
string? tip = null) string? tip = null) =>
{ DetailRows.Row(label, content, value, description, tip);
var row = new SettingRow
{
Label = label,
Description = description,
ValueText = value,
Content = content
};
row.Classes.Add("Compact");
if (!string.IsNullOrWhiteSpace(tip))
ToolTip.SetTip(row, tip);
return row;
}
/// <summary> private static SettingRow Reading(string label, string? value) => DetailRows.Reading(label, value);
/// A read-only label/value line. The dash keeps the row from collapsing when a
/// vehicle has no reading for the field.
/// </summary>
private static SettingRow Reading(string label, string? value)
{
bool empty = string.IsNullOrWhiteSpace(value);
return Row(label, value: empty ? "\u2013" : value, tip: empty ? null : value);
}
private static SettingsSection Section(string header, Control content, Control? meta = null) private static SettingsSection Section(string header, Control content, Control? meta = null)
{ {

View File

@@ -15,7 +15,6 @@ using Avalonia.Markup.Xaml.Styling;
using Avalonia.Media; using Avalonia.Media;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Controls; using StarterNG.Controls;
using StarterNG.Infrastructure.Settings;
using StarterNG.Infrastructure; using StarterNG.Infrastructure;
using StarterNG.Services; using StarterNG.Services;
@@ -65,7 +64,6 @@ public partial class Settings : UserControl, ISettingsCapture
AppServices.Current.SettingsStore.RegisterCapture(this); AppServices.Current.SettingsStore.RegisterCapture(this);
AnnotateConfigKeys();
PopulateProfileCombo(); PopulateProfileCombo();
ApplyToUi(); ApplyToUi();
@@ -162,19 +160,6 @@ public partial class Settings : UserControl, ISettingsCapture
public void ReloadFromSettings() => ApplyToUi(); public void ReloadFromSettings() => ApplyToUi();
/// <summary>
/// Expands each row's eu07.ini key into "key = default (range)", so the line
/// under a setting says what the file holds rather than paraphrasing what the
/// setting does.
/// </summary>
private void AnnotateConfigKeys()
{
// Logical, not visual: the rows exist as soon as the XAML is loaded,
// while categories that are not on screen yet have no visual tree.
foreach (var row in this.GetLogicalDescendants().OfType<SettingRow>())
row.ConfigKey = SettingKeyCatalog.Annotate(row.ConfigKey);
}
private void ApplyToUi() private void ApplyToUi()
{ {
var s = AppServices.Current.Settings; var s = AppServices.Current.Settings;