Settings nach Avalonia - PropertyGrid durch kategorisierten Editor ersetzt

Der PropertyGrid-Ersatz ist EIN wiederverwendbares Steuerelement statt Handarbeit je Feld:
Controls/SettingsEditor zeigt ein beliebiges Einstellungsobjekt nach Richards Vorlage -
Kategorie-Ueberschrift, Beschriftung links, Feld rechts, Erklaerung klein darunter.

- SettingsModelBuilder liest die Attribute, die fuers PropertyGrid ohnehin gepflegt waren:
  [Category] gruppiert, [DisplayName] beschriftet, [Description] wird zum Hinweistext,
  [Browsable(false)] blendet aus (Watchdog-Token, Lizenzschluessel bleiben unsichtbar).
  Ergebnis fuer ServerSettings: 6 Abschnitte, 15 Felder - ohne eine Zeile Feld-Code.
- Layout-Regel gewahrt: WELCHE Felder es gibt, kommt als Daten; WIE ein Feld aussieht,
  steht deklarativ als DataTemplate je Feldtyp (Text/Zahl/Ja-Nein/Auswahl/Nur-Lese).
- Damit sind auch die drei restlichen PropertyGrid-Stellen (Account-Einstellungen,
  Master-Trader, ResolutionFarming) mit je einem Aufruf erledigt.

SettingsWindow: Server-Settings + Polymarket-Accounts, Master-Key erzeugen,
OpenRouter-Key/Watchdog-Token setzen, Test-Heartbeat. Rueckmeldungen laufen ueber die
Statuszeile statt ueber Dialoge - nur echte Entscheidungen bekommen einen Dialog
(neu: Views/DialogWindow fuer Hinweis/Rueckfrage/maskierte Eingabe, Avalonia hat keine MessageBox).

Nebenbei einen offenen Punkt der Linux-Analyse erledigt: Schluesseldateien (master.key,
openrouter.key) werden jetzt per File.SetUnixFileMode auf 600 gesetzt. Auf Linux legt
File.WriteAllText sonst mit ueblicher umask 644 an - weltweit lesbar.

Lizenzdialog bewusst NICHT portiert: haengt am alten LicenseLabrador-SDK, das mit der
Deploymentcenter-Anbindung (P3c) ohnehin ersetzt wird - waere Wegwerfarbeit.

Smoke-UI prueft jetzt zusaetzlich die Feldzahl des Editors: ein Fenster kann fehlerfrei
konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen.

Verifiziert: Solution baut, 442 Tests gruen, --smoke-ui gruen (5 Fenster + Editor-Pruefung),
Linux-Publish laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 20:55:47 +02:00
co-authored by Claude Opus 5
parent 0b8728b25f
commit 4ba8149e64
9 changed files with 873 additions and 1 deletions
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
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;
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>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
{
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>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(string)) return 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);
}
}
}