Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs
T
RichardandClaude Opus 5 28b3aedec7 Vier Befunde der Sichtpruefung behoben: Farbschema, Grids, Diagramme, Kontrast
#383 Farbschema "System" kehrte sein Versprechen um. ThemeManager.IsDark verglich
ActualThemeVariant der Anwendung mit Dark; bei "System" steht dort Default, waehrend
die Fenster selbst nach hell oder dunkel aufloesen. IsDark war damit im
Auslieferungszustand immer false - Symbol, Beschriftung und Toggle() haengen alle an
diesem Wert. Gelesen wird jetzt die angeforderte Variante, bei "System" die
Betriebssystem-Einstellung. Derselbe Fehler steckte in Brush(): die Methode suchte bei
"System" in den hellen Farben, waehrend dunkel gezeichnet wurde - betroffen war genau
das, wofuer es sie gibt (Zeilenfarben, KPI-Vorzeichen). Neu ist ausserdem, dass eine
Umstellung am Betriebssystem ThemeChanged ausloest, solange "System" gilt.

#384 Sternspalten quetschten die Endspalten weg. Der Merksatz aus #56 galt bisher nur
fuer gridTraders. Angewandt auf gridOpen und gridClosed sowie auf die uebrigen Grids,
deren Spaltensumme den sichtbaren Bereich uebersteigt (gridTrades, gridJobs, gridLedger,
gridCandidates, gridPositions, gridHistory). Auto statt Stern, dazu MaxWidth 360 - anders
als "Name" in #56 traegt "Markt" ganze Fragesaetze und wuerde die Zahlenspalten sonst aus
dem Blick schieben. Grids mit weniger Spalten als Platz behalten ihre Sternspalte.

#385 Gewinntage wurden rot gezeichnet. Eine ColumnSeries hat genau ein Fill fuer alle
ihre Balken; gefaerbt wurde deshalb nach der Summe ueber alle Tage. Jetzt zwei Reihen an
denselben Positionen - Gewinne in der einen, Verluste in der anderen, Luecken als null.
"PnL je Modul" faerbt ebenfalls nach Vorzeichen: zwei Diagramme nebeneinander mit
verschiedenen Leseweisen waeren schlechter als beide ohne.

#386 Drei Kleinigkeiten. Die Equity-Achse beschriftete zehn Teilstriche mit zwei Daten -
unter drei Tagen kommt jetzt die Uhrzeit dazu, darueber gilt hoechstens ein Teilstrich je
Tag. "Deaktivierte Module" sah aus, als fehle das Bedienelement: leere Nur-Lese-Felder
zeigen jetzt "—" in einer abgesetzten Flaeche. Die Abschnittsbeschriftungen der
Seitenleiste lagen bei 3,65:1 statt der geforderten 4,5:1 (im Bild sogar 1,83:1, weil
10 px mit Sperrung ausduennen) - AppCaptionTextBrush erreicht 4,61:1 hell und 6,21:1
dunkel und bleibt deutlich leiser als die Eintraege darunter.

Build gruen, 486 Tests gruen. Die Sichtpruefung auf der Linux-VM steht noch aus; die
drei rein visuellen Punkte sind aus dem Quelltext begruendet, nicht im Bild belegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:17:49 +02:00

296 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace PolyTrader.App.Avalonia.ViewModels
{
/// <summary>Ein Eingabefeld der Einstellungen. Schreibt Änderungen direkt in das Zielobjekt.</summary>
public abstract class SettingsField : INotifyPropertyChanged
{
protected readonly object Target;
protected readonly PropertyInfo Property;
protected SettingsField(object target, PropertyInfo property, string label, string? description)
{
Target = target;
Property = property;
Label = label;
Description = description ?? string.Empty;
}
public string Label { get; }
/// <summary>Erklärungstext aus dem <see cref="DescriptionAttribute"/> – erscheint klein unter dem Feld.</summary>
public string Description { get; }
public bool HasDescription => Description.Length > 0;
/// <summary>Falsch bei Eigenschaften ohne Setter (reine Statusanzeigen).</summary>
public bool IsEditable => Property.CanWrite;
/// <summary>Typ der dahinterliegenden Eigenschaft – die Konstruktionsprüfung schaut darauf.</summary>
public Type ValueType => Property.PropertyType;
protected void Raise([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
public event PropertyChangedEventHandler? PropertyChanged;
}
/// <summary>Freitext (string).</summary>
public sealed class TextSettingsField : SettingsField
{
public TextSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get => Property.GetValue(Target) as string ?? string.Empty;
set { if (IsEditable) { Property.SetValue(Target, value ?? string.Empty); Raise(); } }
}
}
/// <summary>
/// Freitext über mehrere Zeilen. Gleiche Bindung wie <see cref="TextSettingsField"/>, nur mit
/// eigener Darstellung: Bis zum 26.08.2026 standen auch „Beschreibung" und „Begründung"
/// (je bis 1000 Zeichen) in einzeiligen Feldern, in denen nur ein Bruchteil sichtbar war
/// (Befund B-14 des Linux-Sichttests). Ausgezeichnet wird das am Modell mit
/// <c>[DataType(DataType.MultilineText)]</c>.
/// </summary>
public sealed class MultilineTextSettingsField : SettingsField
{
public MultilineTextSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get => Property.GetValue(Target) as string ?? string.Empty;
set { if (IsEditable) { Property.SetValue(Target, value ?? string.Empty); Raise(); } }
}
}
/// <summary>
/// Dezimalzahl (decimal/double/float) – wie <see cref="IntSettingsField"/> bewusst als Text:
/// eine ungültige Eingabe lässt den bisherigen Wert stehen, statt still eine 0 zu schreiben.
///
/// <para>Vor dem 27.08.2026 fehlte dieser Feldtyp: alle Dezimal-Einstellungen fielen auf
/// <see cref="ReadOnlySettingsField"/> zurück und waren damit in der Oberfläche gar nicht mehr
/// änderbar – betroffen war die komplette Risikokonfiguration (Positionsgrößen, Preisgrenzen,
/// Kill-Switch-Schwellen), die unter WinForms im PropertyGrid noch editierbar war.</para>
///
/// <para>Beim Lesen wird die Anzeigekultur verwendet (de-DE: „5,0"), beim Schreiben werden
/// Komma und Punkt akzeptiert – sonst scheitert ein aus der Dokumentation kopierter Wert.</para>
/// </summary>
public sealed class DecimalSettingsField : SettingsField
{
public DecimalSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get
{
object? raw = Property.GetValue(Target);
return raw switch
{
decimal m => m.ToString("0.############", CultureInfo.CurrentCulture),
double db => db.ToString("0.############", CultureInfo.CurrentCulture),
float f => f.ToString("0.############", CultureInfo.CurrentCulture),
_ => string.Empty
};
}
set
{
if (!IsEditable) return;
if (TryParse(value, out decimal parsed))
{
Type t = Nullable.GetUnderlyingType(Property.PropertyType) ?? Property.PropertyType;
if (t == typeof(decimal)) Property.SetValue(Target, parsed);
else if (t == typeof(double)) Property.SetValue(Target, (double)parsed);
else if (t == typeof(float)) Property.SetValue(Target, (float)parsed);
}
Raise();
}
}
/// <summary>Nimmt „5,0" und „5.0" an – unabhängig von der eingestellten Kultur.</summary>
internal static bool TryParse(string? text, out decimal value)
{
value = 0m;
if (string.IsNullOrWhiteSpace(text)) return false;
const NumberStyles styles = NumberStyles.Float;
return decimal.TryParse(text, styles, CultureInfo.CurrentCulture, out value)
|| decimal.TryParse(text, styles, CultureInfo.InvariantCulture, out value)
|| decimal.TryParse(text.Replace(',', '.'), styles, CultureInfo.InvariantCulture, out value);
}
}
/// <summary>Ja/Nein (bool) – als Kontrollkästchen.</summary>
public sealed class BoolSettingsField : SettingsField
{
public BoolSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public bool Value
{
get => Property.GetValue(Target) is true;
set { if (IsEditable) { Property.SetValue(Target, value); Raise(); } }
}
}
/// <summary>
/// Ganzzahl. Bewusst als Text mit invarianter Umwandlung: eine ungültige Eingabe lässt den
/// bisherigen Wert stehen, statt still eine 0 zu schreiben.
/// </summary>
public sealed class IntSettingsField : SettingsField
{
public IntSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get => Convert.ToInt32(Property.GetValue(Target) ?? 0).ToString(CultureInfo.InvariantCulture);
set
{
if (!IsEditable) return;
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
Property.SetValue(Target, parsed);
Raise();
}
}
}
/// <summary>Auswahl aus festen Werten (Enum) – als Aufklappliste.</summary>
public sealed class ChoiceSettingsField : SettingsField
{
public ChoiceSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d)
{
Options = Enum.GetNames(Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType);
}
public IReadOnlyList<string> Options { get; }
public string? Value
{
get => Property.GetValue(Target)?.ToString();
set
{
if (!IsEditable || value == null) return;
Property.SetValue(Target, Enum.Parse(
Nullable.GetUnderlyingType(Property.PropertyType) ?? Property.PropertyType, value));
Raise();
}
}
}
/// <summary>Nur-Lese-Anzeige (Eigenschaft ohne Setter, oder nicht editierbarer Typ).</summary>
public sealed class ReadOnlySettingsField : SettingsField
{
/// <summary>Steht für „hier steht nichts" – dieselbe Schreibweise wie in der Account-Übersicht.</summary>
public const string EmptyPlaceholder = "—";
public ReadOnlySettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get
{
object? raw = Property.GetValue(Target);
if (raw is System.Collections.IEnumerable seq and not string)
return string.Join(", ", seq.Cast<object?>().Select(o => o?.ToString()));
return raw?.ToString() ?? string.Empty;
}
}
/// <summary>
/// Der Wert für die Oberfläche: leer wird zum Platzhalter.
/// </summary>
/// <remarks>
/// „Deaktivierte Module" ist im Auslieferungszustand eine leere Liste. Ein leeres Nur-Lese-Feld
/// ist von einem fehlenden Bedienelement nicht zu unterscheiden – in der Zeile stand links die
/// Beschriftung, rechts der Hilfetext und dazwischen nichts, während jede andere Zeile des
/// Dialogs dort ein Feld hat (Befund #386). Der Platzhalter beantwortet die Frage „ist hier
/// etwas kaputt?" ohne Blick in den Quelltext.
/// </remarks>
public string DisplayValue
{
get
{
string v = Value;
return string.IsNullOrWhiteSpace(v) ? EmptyPlaceholder : v;
}
}
}
/// <summary>Eine Kategorie mit ihren Feldern – entspricht einer Überschrift im Dialog.</summary>
public sealed class SettingsSection
{
public string Title { get; init; } = string.Empty;
public IReadOnlyList<SettingsField> Fields { get; init; } = Array.Empty<SettingsField>();
}
/// <summary>
/// Baut aus einem Einstellungsobjekt die Abschnitte für den <c>SettingsEditor</c>.
///
/// <para>Nutzt die Attribute, die für das frühere <c>PropertyGrid</c> ohnehin schon gepflegt
/// wurden: <see cref="CategoryAttribute"/> gruppiert, <see cref="DisplayNameAttribute"/> liefert
/// die Beschriftung, <see cref="DescriptionAttribute"/> den Hinweistext unter dem Feld, und
/// <c>[Browsable(false)]</c> blendet aus (z.B. Watchdog-Token und Lizenzschlüssel – die gehören
/// nicht offen in die Oberfläche).</para>
///
/// <para>Damit ist der Ersatz des PropertyGrid keine Fleißarbeit je Feld: die Beschreibung der
/// Felder steht weiterhin am Modell, nur die Darstellung ist jetzt eine eigene, gestaltbare
/// Oberfläche statt einer generischen Reflection-Liste.</para>
/// </summary>
public static class SettingsModelBuilder
{
public static IReadOnlyList<SettingsSection> Build(object target)
{
var fields = target.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
.Select(p => new
{
Property = p,
Category = p.GetCustomAttribute<CategoryAttribute>()?.Category ?? "Allgemein",
Label = p.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? p.Name,
Description = p.GetCustomAttribute<DescriptionAttribute>()?.Description
})
.ToList();
return fields
.GroupBy(f => f.Category)
.Select(g => new SettingsSection
{
Title = g.Key,
Fields = g.Select(f => Create(target, f.Property, f.Label, f.Description)).ToList()
})
.ToList();
}
private static SettingsField Create(object target, PropertyInfo p, string label, string? description)
{
Type t = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType;
if (!p.CanWrite) return new ReadOnlySettingsField(target, p, label, description);
if (t.IsEnum) return new ChoiceSettingsField(target, p, label, description);
if (t == typeof(bool)) return new BoolSettingsField(target, p, label, description);
if (t == typeof(int) || t == typeof(long)) return new IntSettingsField(target, p, label, description);
if (t == typeof(decimal) || t == typeof(double) || t == typeof(float))
return new DecimalSettingsField(target, p, label, description);
if (t == typeof(string))
{
bool multiline = p.GetCustomAttribute<DataTypeAttribute>()?.DataType == DataType.MultilineText;
return multiline
? new MultilineTextSettingsField(target, p, label, description)
: new TextSettingsField(target, p, label, description);
}
// Listen u.a. werden angezeigt, aber nicht hier bearbeitet (z.B. DisabledModules –
// die pflegt der Modul-Tab des Dashboards).
return new ReadOnlySettingsField(target, p, label, description);
}
}
}