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
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"
Orientation="Horizontal" Spacing="8" Margin="0,3,0,0">
<SelectableTextBlock Name="PART_ConfigKey"
Text="{TemplateBinding ConfigKey}"
FontFamily="Consolas, DejaVu Sans Mono, Liberation Mono, Menlo, Courier New"
FontSize="11"
Foreground="{StaticResource CarbonAccentBright}"
Opacity="0.75"
Foreground="{StaticResource CarbonFgDim}"
VerticalAlignment="Top" />
<TextBlock Name="PART_Description"
Text="{TemplateBinding Description}"

View File

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

View File

@@ -17,6 +17,7 @@ using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Vehicles;
using StarterNG.Application;
using StarterNG.Controls;
namespace StarterNG.Views;
@@ -411,37 +412,44 @@ public sealed class VehicleBrowser
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)
{
target.Children.Clear();
var full = new List<string>();
void Row(string label, string? value)
var tooltip = new List<string>();
void Reading(string label, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return;
string line = $"{label}: {value}";
full.Add(line);
target.Children.Add(new TextBlock
{
Text = line,
FontSize = 12,
Opacity = 0.85,
TextTrimming = TextTrimming.CharacterEllipsis
});
if (string.IsNullOrWhiteSpace(value))
return;
target.Children.Add(DetailRows.Reading(label, value));
tooltip.Add($"{label}: {value}");
}
Row(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile));
var meta = texture.Meta;
if (meta != null)
Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile));
if (texture.Meta is { } meta)
{
Row(App.Loc["TexOperator"], meta.Operator);
Row(App.Loc["TexStation"], meta.Depot);
Row(App.Loc["TexRevision"], meta.RevisionDate);
Row(App.Loc["TexWorks"], meta.RevisionPlace);
Row(App.Loc["TexAuthor"], meta.TextureAuthor);
Row(App.Loc["TexPhoto"], meta.PhotoAuthor);
Reading(App.Loc["TexOperator"], meta.Operator);
Reading(App.Loc["TexStation"], meta.Depot);
Reading(App.Loc["TexRevision"], meta.RevisionDate);
Reading(App.Loc["TexWorks"], meta.RevisionPlace);
Reading(App.Loc["TexAuthor"], meta.TextureAuthor);
Reading(App.Loc["TexPhoto"], meta.PhotoAuthor);
}
ToolTip.SetTip(target, string.Join("\n", full));
ToolTip.SetTip(target, string.Join("\n", tooltip));
}
public List<VehicleTexture> CurrentTextures()

View File

@@ -147,10 +147,6 @@ public sealed class VehicleDetails
generalPanel.Children.Add(Reading($"{App.Loc["VMax"]} [km/h]",
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["Texture"], car.SkinFile));
}
@@ -347,39 +343,12 @@ public sealed class VehicleDetails
.Any(s => s.Equals("passengers", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// The one row shape every detail tab uses. The label column negotiates its width
/// through the shared-size group on the host panel, so all rows in a panel line up
/// without anyone naming a pixel count - see the Compact styles in Settings.axaml.
/// Omitting <paramref name="content"/> makes it a read-only line; omitting
/// <paramref name="label"/> indents a bare widget to the control column.
/// </summary>
private static SettingRow Row(string? label = null, Control? content = null,
string? value = null, string? description = null,
string? tip = null)
{
var row = new SettingRow
{
Label = label,
Description = description,
ValueText = value,
Content = content
};
row.Classes.Add("Compact");
if (!string.IsNullOrWhiteSpace(tip))
ToolTip.SetTip(row, tip);
return row;
}
string? tip = null) =>
DetailRows.Row(label, content, value, description, tip);
/// <summary>
/// A read-only label/value line. The dash keeps the row from collapsing when a
/// vehicle has no reading for the field.
/// </summary>
private static SettingRow Reading(string label, string? value)
{
bool empty = string.IsNullOrWhiteSpace(value);
return Row(label, value: empty ? "\u2013" : value, tip: empty ? null : value);
}
private static SettingRow Reading(string label, string? value) => DetailRows.Reading(label, value);
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 StarterNG.Classes;
using StarterNG.Controls;
using StarterNG.Infrastructure.Settings;
using StarterNG.Infrastructure;
using StarterNG.Services;
@@ -65,7 +64,6 @@ public partial class Settings : UserControl, ISettingsCapture
AppServices.Current.SettingsStore.RegisterCapture(this);
AnnotateConfigKeys();
PopulateProfileCombo();
ApplyToUi();
@@ -162,19 +160,6 @@ public partial class Settings : UserControl, ISettingsCapture
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()
{
var s = AppServices.Current.Settings;