#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>
296 lines
13 KiB
C#
296 lines
13 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|