From 28b3aedec7017e5cec2dde3bd2a155a093316062 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 3 Sep 2026 10:17:28 +0200 Subject: [PATCH] Vier Befunde der Sichtpruefung behoben: Farbschema, Grids, Diagramme, Kontrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- src/PolyTrader.App.Avalonia/App.axaml | 12 ++- .../Controls/SettingsEditor.axaml | 11 +- .../Shell/ThemeManager.cs | 59 +++++++++- .../ViewModels/DashboardChartsModel.cs | 101 ++++++++++++------ .../ViewModels/SettingsFields.cs | 22 ++++ .../Views/DashboardWindow.axaml | 2 +- .../Views/JobsWindow.axaml | 2 +- .../Views/Modules/AccountingWindow.axaml | 2 +- .../Views/Modules/CopyTradingWindow.axaml | 4 +- .../Modules/ResolutionFarmingWindow.axaml | 6 +- 10 files changed, 176 insertions(+), 45 deletions(-) diff --git a/src/PolyTrader.App.Avalonia/App.axaml b/src/PolyTrader.App.Avalonia/App.axaml index fdc3c0a..c2b51f0 100644 --- a/src/PolyTrader.App.Avalonia/App.axaml +++ b/src/PolyTrader.App.Avalonia/App.axaml @@ -409,11 +409,21 @@ + + + + diff --git a/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs b/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs index b7f509f..a82965f 100644 --- a/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs +++ b/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs @@ -2,7 +2,9 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Platform; using Avalonia.Styling; +using Avalonia.Threading; using PolyTraderSharp.Models; using PolyTraderSharp.Services; @@ -23,13 +25,37 @@ namespace PolyTrader.App.Avalonia.Shell private static ServerSettings? _settings; private static string? _settingsPath; private static TerminalLogger? _logger; + private static bool _colorHookAttached; /// Feuert nach jedem Wechsel – Fenster zeichnen daraufhin ihre Code-Farben neu. public static event Action? ThemeChanged; /// Ist gerade das dunkle Schema aktiv (auch wenn es über „System" kam)? - public static bool IsDark => - global::Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + /// + /// Gelesen wird die angeforderte Variante, nicht ActualThemeVariant der Anwendung: + /// Bei „System" steht dort – die Fenster lösen das + /// selbst nach hell oder dunkel auf, die Anwendung erfährt davon nichts. Der Vergleich mit + /// lieferte deshalb im Auslieferungszustand immer + /// false, obwohl dunkel gezeichnet wurde: Beschriftung, Symbol und + /// sagten geschlossen das Gegenteil dessen, was zu sehen war. + /// + public static bool IsDark + { + get + { + var app = global::Avalonia.Application.Current; + if (app == null) return false; + + // Feste Wahl: die Antwort steht bereits in der angeforderten Variante. + var requested = app.RequestedThemeVariant; + if (requested == ThemeVariant.Dark) return true; + if (requested == ThemeVariant.Light) return false; + + // „System": Die Betriebssystem-Einstellung ist die Quelle, aus der auch die Fenster + // auflösen – also dort nachsehen statt auf Anwendungsebene zu raten. + return app.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark; + } + } /// /// Einmalig beim Start aufrufen: übernimmt das gespeicherte Schema und merkt sich, wohin @@ -41,6 +67,28 @@ namespace PolyTrader.App.Avalonia.Shell _settingsPath = settingsPath; _logger = logger; ApplyVariant(settings.Theme); + HookSystemColorChanges(); + } + + /// + /// Bei „System" wechselt das Schema, ohne dass jemand bei uns klickt. Avalonia stellt die + /// Standard-Steuerelemente dann selbst um – die im Code gesetzten Farben und die Anzeige in + /// der Kopf- und Statusleiste bleiben aber stehen, bis feuert. + /// + private static void HookSystemColorChanges() + { + if (_colorHookAttached) return; + + var platform = global::Avalonia.Application.Current?.PlatformSettings; + if (platform == null) return; + + platform.ColorValuesChanged += (_, _) => + { + // Nur wer dem Betriebssystem folgt, ist von dessen Umstellung betroffen. + if (_settings?.Theme != AppThemeVariant.System) return; + Dispatcher.UIThread.Post(() => ThemeChanged?.Invoke()); + }; + _colorHookAttached = true; } /// Wechselt hell ↔ dunkel und speichert die Wahl. „System" wird dabei zu einer festen Wahl. @@ -92,7 +140,12 @@ namespace PolyTrader.App.Avalonia.Shell public static IBrush Brush(string key) { var app = global::Avalonia.Application.Current; - if (app != null && app.TryFindResource(key, app.ActualThemeVariant, out object? value) && value is IBrush b) + + // Nicht ActualThemeVariant: bei „System" ist das Default, und die Ressourcensuche + // liefert dann die hellen Farben, während die Fenster dunkel zeichnen. IsDark kennt + // die tatsächlich gezeichnete Variante – dieselbe Quelle wie für Symbol und Beschriftung. + var variant = IsDark ? ThemeVariant.Dark : ThemeVariant.Light; + if (app != null && app.TryFindResource(key, variant, out object? value) && value is IBrush b) return b; // Unbekannter Schlüssel ist ein Programmierfehler, darf die Oberfläche aber nicht kippen. diff --git a/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs b/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs index 8dae7a9..3aa05dd 100644 --- a/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs +++ b/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs @@ -71,29 +71,46 @@ namespace PolyTrader.App.Avalonia.ViewModels } }; + // Ohne Trades hat die Achse keine Werte. LiveCharts nahm dann einen Standardbereich um + // 0 an - und weil Tick 0 dem 01.01.0001 entspricht, stand an der Achse zwoelfmal + // "01.01." (Befund B-8 des Linux-Sichttests). Jetzt spannt sie im Leerzustand den + // tatsaechlich gewaehlten Zeitraum auf. + DateTime? emptyFrom = null, emptyTo = null; + if (curve.Count == 0) + { + emptyTo = DateTime.UtcNow.Date.AddDays(1); + emptyFrom = (since ?? emptyTo.Value.AddDays(-30)).Date; + if (emptyFrom >= emptyTo) emptyFrom = emptyTo.Value.AddDays(-1); + } + + // LiveCharts setzt rund zehn Teilstriche, gleich wie kurz der Zeitraum ist. Bei sieben + // Trades an zwei Tagen stand deshalb fuenfmal "25.08." und fuenfmal "26.08." an der + // Achse - fachlich richtig, gelesen aber als Fehler (Befund #386). Zwei Wege zu einem + // Ziel: keine zwei Teilstriche mit derselben Beschriftung. + TimeSpan span = curve.Count > 0 + ? curve[^1].At - curve[0].At + : emptyTo!.Value - emptyFrom!.Value; + bool shortSpan = span < TimeSpan.FromDays(3); + var axis = new Axis { // Ticks kommen als DateTime-Ticks – zurueckrechnen und kurz formatieren. // Die Bereichspruefung ist Pflicht: LiveCharts fragt auch Werte ausserhalb des // DateTime-Bereichs ab, und new DateTime(...) wuerde dort werfen. Labeler = value => value >= 0 && value <= DateTime.MaxValue.Ticks - ? new DateTime((long)value).ToString("dd.MM.") + ? new DateTime((long)value).ToString(shortSpan ? "dd.MM. HH:mm" : "dd.MM.") : string.Empty, UnitWidth = TimeSpan.FromDays(1).Ticks, - LabelsRotation = 0 + // Kurzer Zeitraum: die Uhrzeit unterscheidet die Teilstriche. Laengerer Zeitraum: + // das Datum genuegt, dann aber hoechstens ein Teilstrich je Tag. + MinStep = shortSpan ? 0d : TimeSpan.FromDays(1).Ticks, + LabelsRotation = shortSpan ? 45 : 0 }; - // Ohne Trades hat die Achse keine Werte. LiveCharts nahm dann einen Standardbereich um - // 0 an - und weil Tick 0 dem 01.01.0001 entspricht, stand an der Achse zwoelfmal - // "01.01." (Befund B-8 des Linux-Sichttests). Jetzt spannt sie im Leerzustand den - // tatsaechlich gewaehlten Zeitraum auf. - if (curve.Count == 0) + if (emptyFrom.HasValue) { - DateTime end = DateTime.UtcNow.Date.AddDays(1); - DateTime start = (since ?? end.AddDays(-30)).Date; - if (start >= end) start = end.AddDays(-1); - axis.MinLimit = start.Ticks; - axis.MaxLimit = end.Ticks; + axis.MinLimit = emptyFrom.Value.Ticks; + axis.MaxLimit = emptyTo!.Value.Ticks; } EquityXAxes = new[] { axis }; @@ -104,15 +121,7 @@ namespace PolyTrader.App.Avalonia.ViewModels var byMod = TradeAnalytics.PnlByKey(scoped, t => string.IsNullOrEmpty(t.ModuleName) ? "—" : t.ModuleName); - ModuleSeries = new ISeries[] - { - new ColumnSeries - { - Name = "PnL", - Values = byMod.Select(x => (double)x.Pnl).ToArray(), - Fill = new SolidColorPaint(Accent) - } - }; + ModuleSeries = SignedColumns(byMod.Select(x => x.Pnl).ToArray()); ModuleXAxes = new[] { new Axis { Labels = byMod.Select(x => x.Key).ToArray(), LabelsRotation = 0 } }; } @@ -120,16 +129,7 @@ namespace PolyTrader.App.Avalonia.ViewModels { var byDay = TradeAnalytics.PnlByDay(scoped); - DaySeries = new ISeries[] - { - new ColumnSeries - { - Name = "PnL", - Values = byDay.Select(x => (double)x.Pnl).ToArray(), - // Verlusttage rot, Gewinntage gruen – dieselbe Leseweise wie in den Trade-Listen. - Fill = new SolidColorPaint(byDay.Sum(x => x.Pnl) >= 0m ? Positive : Negative) - } - }; + DaySeries = SignedColumns(byDay.Select(x => x.Pnl).ToArray()); DayXAxes = new[] { new Axis @@ -140,6 +140,45 @@ namespace PolyTrader.App.Avalonia.ViewModels }; } + /// + /// Balken nach ihrem Vorzeichen faerben: Gewinn gruen, Verlust rot – dieselbe Leseweise wie + /// in den Trade-Listen. + /// + /// + /// Eine ColumnSeries hat genau ein Fill fuer alle ihre Balken; je Balken faerben + /// geht damit nicht. Frueher stand hier deshalb ein Ausdruck ueber die SUMME aller Werte – + /// der faerbte im Verlustfall auch die Gewinntage rot und sagte beim Ueberfliegen das + /// Gegenteil der Zahl darunter (Befund #385). + /// + /// Stattdessen zwei Reihen an denselben Positionen: die eine traegt die Gewinne, die andere + /// die Verluste, die jeweils andere laesst dort eine Luecke (null, wird nicht + /// gezeichnet). IgnoresBarPosition haelt sie uebereinander statt nebeneinander. + /// + private static ISeries[] SignedColumns(IReadOnlyList values) + { + double?[] OnlyWhere(bool positive) => values + .Select(v => positive == (v >= 0m) ? (double?)v : null) + .ToArray(); + + return new ISeries[] + { + new ColumnSeries + { + Name = "Gewinn", + Values = OnlyWhere(true), + Fill = new SolidColorPaint(Positive), + IgnoresBarPosition = true + }, + new ColumnSeries + { + Name = "Verlust", + Values = OnlyWhere(false), + Fill = new SolidColorPaint(Negative), + IgnoresBarPosition = true + } + }; + } + private void Set(ref T field, T value, [CallerMemberName] string? name = null) { field = value; diff --git a/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs b/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs index ae19b38..9046c13 100644 --- a/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs +++ b/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs @@ -186,6 +186,9 @@ namespace PolyTrader.App.Avalonia.ViewModels /// Nur-Lese-Anzeige (Eigenschaft ohne Setter, oder nicht editierbarer Typ). public sealed class ReadOnlySettingsField : SettingsField { + /// Steht für „hier steht nichts" – dieselbe Schreibweise wie in der Account-Übersicht. + public const string EmptyPlaceholder = "—"; + public ReadOnlySettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { } public string Value @@ -198,6 +201,25 @@ namespace PolyTrader.App.Avalonia.ViewModels return raw?.ToString() ?? string.Empty; } } + + /// + /// Der Wert für die Oberfläche: leer wird zum Platzhalter. + /// + /// + /// „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. + /// + public string DisplayValue + { + get + { + string v = Value; + return string.IsNullOrWhiteSpace(v) ? EmptyPlaceholder : v; + } + } } /// Eine Kategorie mit ihren Feldern – entspricht einer Überschrift im Dialog. diff --git a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml index a092ccd..c150af6 100644 --- a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml @@ -121,7 +121,7 @@ - + diff --git a/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml b/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml index 53c50bb..ff5a955 100644 --- a/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml @@ -40,7 +40,7 @@ - diff --git a/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml b/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml index 3d2d500..435aafc 100644 --- a/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml @@ -116,7 +116,7 @@ - + diff --git a/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml b/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml index 42ce71d..bfaa195 100644 --- a/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml @@ -103,7 +103,7 @@ - + @@ -146,7 +146,7 @@ - + diff --git a/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml b/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml index 4891d81..48234d0 100644 --- a/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml @@ -22,7 +22,7 @@ - + @@ -41,7 +41,7 @@ - + @@ -61,7 +61,7 @@ - +