From afae3c9c4ada8fd7929b1db12a7f1f5a445199b2 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 7 Aug 2026 10:18:44 +0200 Subject: [PATCH] L3: Avalonia-Shell und Core-Ansichten - die Oberflaeche laeuft auf Linux Neues Projekt IBKRTrader.App.Avalonia (net10.0, plattformneutral) mit Launcher, Fenster-Menue, Beenden-Abfrage und den vier Core-Ansichten Dashboard, Workers, Logs und Settings. Nutzt denselben AppHostBuilder wie der Daemon; ShellServices ergaenzt nur, was ohne Oberflaeche nicht existiert. Versionen bewusst auf der 11er-Linie: Avalonia 11.3.19, DataGrid 11.3.13. LiveCharts2 2.0.5 ist gegen Avalonia 11 gebaut und bricht unter 12 (Gestures.PinchEvent gibt es dort nicht mehr). Diagramme kommen mit den neuen Modulen - bis LiveCharts2 Avalonia 12 unterstuetzt, darf hier nicht angehoben werden. Erfahrung aus PolytraderSharp, im csproj vermerkt. Ersatz fuer WinForms-Bausteine ohne Gegenstueck: - PropertyGrid -> SettingsModelBuilder erzeugt die Maske aus den bereits vorhandenen Category-/DisplayName-/Description-Attributen von AppSettings. Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die Oberflaeche anfasst - genau der Vorteil des PropertyGrid. Kennwortfelder (DB-Passwort, Flex-Token) werden verdeckt. Gemessen: 11 Abschnitte, 41 Felder. - MessageBox.Show -> ShutdownConfirmWindow (Avalonia bringt keinen Meldungsdialog mit). Schliessen ueber das X zaehlt als Abbruch, damit ein versehentlicher Klick nie den Handelsbetrieb stoppt. - RichTextBox mit SelectionColor -> eingefaerbte Elemente je Logzeile, mit Filter, Auto-Scroll und Zeilenbegrenzung (im Dauerbetrieb waere die Liste sonst unbegrenzt gewachsen). - ToolStrip/StatusStrip -> zentrale Stilklassen in App.axaml (toolbar, statusbar, kpi, section). Die neuen Module setzen darauf auf. Gemeinsame Bausteine bewusst jetzt schon zentral, weil die neuen Module direkt in Avalonia entwickelt werden sollen. Kompilierte Bindings sind aktiv (x:DataType je Datenkontext) - Tippfehler in Bindings fallen damit beim Kompilieren auf statt erst zur Laufzeit. Dafuer brauchte es benannte Record-Zeilentypen statt der anonymen Typen, die DataGridView.DataSource frueher bekommen hat. --smoke-ui laeuft jetzt ueber SetupWithoutStarting, also OHNE Anzeigegeraet und ohne laufende Dienste. Das war mit WinForms nicht moeglich und macht die Konstruktionspruefung erstmals CI-tauglich. Der Host wird dabei bewusst nicht gestartet, sonst liefen Worker und Broker-Verbindungen gegen die echten Endpunkte an. AVLN3001 unterdrueckt: die Fenster haben absichtlich keinen parameterlosen Konstruktor. Einer wuerde sie ohne ihre Dienste konstruierbar machen und genau den Fehler verdecken, den die Konstruktionspruefung finden soll. Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Avalonia-Smoke-UI konstruiert alle 6 Fenster, publish -r linux-x64 liefert 32 MB mit ELF-Launcher samt libSkiaSharp.so/libHarfBuzzSharp.so und ohne Windows-Abhaengigkeiten. Die WinForms-Shell ist unveraendert lauffaehig. Offen fuer L4: die drei Modul-Fenster (ModuleViews ist noch leer). Co-Authored-By: Claude Opus 5 --- IBKRTrader.slnx | 1 + NuGet.config | 9 + src/IBKRTrader.App.Avalonia/App.axaml | 74 ++++++++ src/IBKRTrader.App.Avalonia/App.axaml.cs | 50 ++++++ .../IBKRTrader.App.Avalonia.csproj | 56 ++++++ src/IBKRTrader.App.Avalonia/Program.cs | 81 +++++++++ .../Shell/AvaloniaUiHost.cs | 119 +++++++++++++ .../Shell/CoreViews.cs | 47 +++++ .../Shell/ModuleViews.cs | 32 ++++ .../Shell/ShellServices.cs | 18 ++ src/IBKRTrader.App.Avalonia/Shell/SmokeUi.cs | 95 +++++++++++ .../Shell/ViewIcons.cs | 69 ++++++++ .../Shell/WindowMenu.cs | 92 ++++++++++ .../ViewModels/Rows.cs | 18 ++ .../ViewModels/SettingsModel.cs | 87 ++++++++++ .../Views/DashboardWindow.axaml | 44 +++++ .../Views/DashboardWindow.axaml.cs | 97 +++++++++++ .../Views/LauncherWindow.axaml | 44 +++++ .../Views/LauncherWindow.axaml.cs | 107 ++++++++++++ .../Views/LogsWindow.axaml | 45 +++++ .../Views/LogsWindow.axaml.cs | 106 ++++++++++++ .../Views/SettingsWindow.axaml | 28 +++ .../Views/SettingsWindow.axaml.cs | 160 ++++++++++++++++++ .../Views/ShutdownConfirmWindow.axaml | 22 +++ .../Views/ShutdownConfirmWindow.axaml.cs | 31 ++++ .../Views/WorkersWindow.axaml | 45 +++++ .../Views/WorkersWindow.axaml.cs | 61 +++++++ src/IBKRTrader.App.Avalonia/appsettings.json | 11 ++ 28 files changed, 1649 insertions(+) create mode 100644 src/IBKRTrader.App.Avalonia/App.axaml create mode 100644 src/IBKRTrader.App.Avalonia/App.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/IBKRTrader.App.Avalonia.csproj create mode 100644 src/IBKRTrader.App.Avalonia/Program.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/AvaloniaUiHost.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/CoreViews.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/ModuleViews.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/ShellServices.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/SmokeUi.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/ViewIcons.cs create mode 100644 src/IBKRTrader.App.Avalonia/Shell/WindowMenu.cs create mode 100644 src/IBKRTrader.App.Avalonia/ViewModels/Rows.cs create mode 100644 src/IBKRTrader.App.Avalonia/ViewModels/SettingsModel.cs create mode 100644 src/IBKRTrader.App.Avalonia/Views/DashboardWindow.axaml create mode 100644 src/IBKRTrader.App.Avalonia/Views/DashboardWindow.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/Views/LauncherWindow.axaml create mode 100644 src/IBKRTrader.App.Avalonia/Views/LauncherWindow.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/Views/LogsWindow.axaml create mode 100644 src/IBKRTrader.App.Avalonia/Views/LogsWindow.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/Views/SettingsWindow.axaml create mode 100644 src/IBKRTrader.App.Avalonia/Views/SettingsWindow.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/Views/ShutdownConfirmWindow.axaml create mode 100644 src/IBKRTrader.App.Avalonia/Views/ShutdownConfirmWindow.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/Views/WorkersWindow.axaml create mode 100644 src/IBKRTrader.App.Avalonia/Views/WorkersWindow.axaml.cs create mode 100644 src/IBKRTrader.App.Avalonia/appsettings.json diff --git a/IBKRTrader.slnx b/IBKRTrader.slnx index 4b57690..3a81a0c 100644 --- a/IBKRTrader.slnx +++ b/IBKRTrader.slnx @@ -3,6 +3,7 @@ + diff --git a/NuGet.config b/NuGet.config index 2cc6ca5..5ced69e 100644 --- a/NuGet.config +++ b/NuGet.config @@ -22,6 +22,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/IBKRTrader.App.Avalonia/App.axaml.cs b/src/IBKRTrader.App.Avalonia/App.axaml.cs new file mode 100644 index 0000000..40ca153 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/App.axaml.cs @@ -0,0 +1,50 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using IBKRTrader.App.Avalonia.Shell; +using IBKRTrader.App.Avalonia.Views; +using IBKRTrader.Core.Modularity; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.App.Avalonia; + +/// +/// Avalonia-Anwendungsobjekt. Verbindet den bereits laufenden Generic Host (Trading-Dienste, +/// Module, Persistenz) mit der Oberfläche: beim Start wird der Launcher erzeugt und die Views von +/// Core und Modulen werden bei der Shell registriert. +/// +public partial class App : Application +{ + /// + /// Wird von vor StartWithClassicDesktopLifetime gesetzt. + /// Bewusst statisch: Avalonia erzeugt die Application-Instanz selbst, ein Konstruktorparameter + /// ist deshalb nicht möglich. + /// + public static IServiceProvider? Services { get; set; } + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && Services != null) + { + var uiHost = Services.GetRequiredService(); + + CoreViews.Register(uiHost, Services); + foreach (var module in Services.GetServices()) + module.RegisterUi(uiHost, Services); + ModuleViews.Register(uiHost, Services); + ViewIcons.AssignDefaults(uiHost); + + var launcher = new LauncherWindow(uiHost, Services); + uiHost.SetMainWindow(launcher); + desktop.MainWindow = launcher; + + // Das Schließen des Launchers läuft über die Sicherheitsabfrage (wie bisher das X). + desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/IBKRTrader.App.Avalonia/IBKRTrader.App.Avalonia.csproj b/src/IBKRTrader.App.Avalonia/IBKRTrader.App.Avalonia.csproj new file mode 100644 index 0000000..ec9f17d --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/IBKRTrader.App.Avalonia.csproj @@ -0,0 +1,56 @@ + + + + + WinExe + net10.0 + enable + enable + IBKRTrader.App.Avalonia + IBKRTrader.App.Avalonia + en + + true + + $(NoWarn);AVLN3001 + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/IBKRTrader.App.Avalonia/Program.cs b/src/IBKRTrader.App.Avalonia/Program.cs new file mode 100644 index 0000000..a6e7407 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Program.cs @@ -0,0 +1,81 @@ +using Avalonia; +using IBKRTrader.App.Avalonia.Shell; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.App.Avalonia; + +/// +/// Einstiegspunkt der plattformneutralen Oberfläche. +/// +/// Bewusst zweigeteilt: stellt den Host mit Persistenz, +/// Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia +/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst +/// (IBKRTrader.Daemon). +/// +internal static class Program +{ + [STAThread] + public static int Main(string[] args) + { + // Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste. + if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run(); + + var modules = AppHostBuilder.CreateModules(); + using var host = AppHostBuilder.Build(modules, ShellServices.Register); + + AppHostBuilder.RunStartupChecks(host.Services); + + var logger = host.Services.GetRequiredService(); + logger.Info("Core", "=== IBKRTrader startet ==="); + logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}"); + + host.Start(); + StartModules(modules, logger); + + try + { + App.Services = host.Services; + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + return 0; + } + finally + { + // Geordnetes Herunterfahren – erst nachdem die Oberfläche beendet ist. + StopModules(modules, logger); + host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult(); + } + } + + /// Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt). + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + + private static void StartModules(IReadOnlyList modules, LoggingService logger) + { + foreach (var module in modules) + { + try { module.StartAsync(default).GetAwaiter().GetResult(); } + catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); } + } + logger.Info("Core", "IBKRTrader bereit."); + } + + private static void StopModules(IReadOnlyList modules, LoggingService logger) + { + foreach (var module in modules) + { + try { module.StopAsync(default).GetAwaiter().GetResult(); } + catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); } + } + } + + private static bool HasFlag(string[] args, string flag) => + args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/AvaloniaUiHost.cs b/src/IBKRTrader.App.Avalonia/Shell/AvaloniaUiHost.cs new file mode 100644 index 0000000..98eee24 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/AvaloniaUiHost.cs @@ -0,0 +1,119 @@ +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using IBKRTrader.App.Avalonia.Views; +using IBKRTrader.Core.Modularity; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// +/// Avalonia-Umsetzung von . Verhält sich wie der frühere +/// WinForms-ShellUiHost: je View höchstens ein Fenster, ein bereits offenes wird nach vorn +/// geholt, der Offen-Status wird gemeldet (für die Markierung im Launcher). +/// +/// Der Core-Contract ist toolkit-neutral – liefert +/// . Hier wird auf gecastet: ein anderer Typ ist ein +/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster ergeben. +/// +public sealed class AvaloniaUiHost : IModuleUiHost +{ + private readonly List _views = []; + private readonly Dictionary _open = new(StringComparer.OrdinalIgnoreCase); + + private Window? _mainWindow; + private bool _shutdownDialogOpen; + + /// True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde. + public bool ShutdownConfirmed { get; private set; } + + public event Action? OpenStateChanged; + + public IReadOnlyList Views => _views; + + public void RegisterView(ModuleView view) => _views.Add(view); + + /// Setzt das Hauptfenster (Launcher) – Ziel für . + public void SetMainWindow(Window main) => _mainWindow = main; + + public bool IsOpen(string viewId) => _open.ContainsKey(viewId); + + public void ActivateMain() + { + if (_mainWindow is null) return; + if (_mainWindow.WindowState == WindowState.Minimized) + _mainWindow.WindowState = WindowState.Normal; + _mainWindow.Activate(); + } + + public void OpenView(string viewId) + { + var view = _views.FirstOrDefault(v => v.Id == viewId); + if (view is not null) OpenView(view); + } + + private void OpenView(ModuleView view) + { + if (_open.TryGetValue(view.Id, out var existing)) + { + if (existing.WindowState == WindowState.Minimized) + existing.WindowState = WindowState.Normal; + existing.Activate(); + return; + } + + var window = (Window)view.CreateView(); + if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title; + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + + _open[view.Id] = window; + window.Closed += (_, _) => + { + _open.Remove(view.Id); + OpenStateChanged?.Invoke(); + }; + + window.Show(); + OpenStateChanged?.Invoke(); + } + + /// + /// Zeigt die Sicherheitsabfrage und fährt bei Bestätigung herunter. Aus jedem Fenster + /// aufrufbar – auch aus Modul-Fenstern, die nur den Core-Contract kennen. + /// + /// async void ist hier korrekt: die Methode ist ein Ereignis-Handler hinter dem + /// synchronen Contract , und der Dialog muss + /// erwartet werden. Ausnahmen können nicht entweichen – der Dialog wirft nicht, und der + /// finally-Block gibt die Sperre in jedem Fall frei. + /// + public async void RequestShutdown() + { + if (ShutdownConfirmed || _shutdownDialogOpen) return; + _shutdownDialogOpen = true; + try + { + var owner = _mainWindow; + if (owner is null) return; + + var confirmed = await new ShutdownConfirmWindow().ShowDialog(owner); + if (!confirmed) return; + + ShutdownConfirmed = true; + CloseAllViews(); + + if (global::Avalonia.Application.Current?.ApplicationLifetime + is IClassicDesktopStyleApplicationLifetime desktop) + desktop.Shutdown(); + } + finally + { + _shutdownDialogOpen = false; + } + } + + /// Schließt alle offenen View-Fenster (beim Herunterfahren). + public void CloseAllViews() + { + foreach (var window in _open.Values.ToList()) + window.Close(); + _open.Clear(); + } +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/CoreViews.cs b/src/IBKRTrader.App.Avalonia/Shell/CoreViews.cs new file mode 100644 index 0000000..65c357c --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/CoreViews.cs @@ -0,0 +1,47 @@ +using IBKRTrader.App.Avalonia.Views; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Trading; +using IBKRTrader.Core.Workers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// Registriert die Core-Ansichten (Dashboard, Workers, Logs, Settings) bei der Shell. +public static class CoreViews +{ + public static void Register(IModuleUiHost host, IServiceProvider sp) + { + host.RegisterView(new ModuleView + { + Id = "core.dashboard", Title = "Dashboard", Group = "Core", Order = 5, + CreateView = () => new DashboardWindow( + host, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetServices(), + sp.GetRequiredService(), + sp.GetServices()) + }); + + host.RegisterView(new ModuleView + { + Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10, + CreateView = () => new WorkersWindow(host, sp.GetRequiredService()) + }); + + host.RegisterView(new ModuleView + { + Id = "core.logs", Title = "Logs", Group = "Core", Order = 20, + CreateView = () => new LogsWindow(host, sp.GetRequiredService()) + }); + + host.RegisterView(new ModuleView + { + Id = "core.settings", Title = "Settings", Group = "Core", Order = 30, + CreateView = () => new SettingsWindow(host, sp.GetRequiredService()) + }); + } +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/ModuleViews.cs b/src/IBKRTrader.App.Avalonia/Shell/ModuleViews.cs new file mode 100644 index 0000000..9c139cf --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/ModuleViews.cs @@ -0,0 +1,32 @@ +using IBKRTrader.Core.Modularity; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// +/// Registriert die Fenster der Module bei der Shell. +/// +/// Warum hier und nicht im Modul? Ein Modul, das sein eigenes Fenster erzeugt, müsste +/// Avalonia referenzieren – und wäre damit nicht mehr kopflos auf Linux lauffähig. Die +/// Modulprojekte bleiben deshalb frei von UI-Code; ihr RegisterUi ist leer, und die Shell +/// verdrahtet die Fenster zentral. Die Modul-Dienste kommen unverändert aus dem DI-Container. +/// +/// Stand: noch leer – die drei Modul-Fenster (CongressTrading, Supervisor, +/// Accounting) werden in der nächsten Etappe portiert. Bis dahin zeigt die Avalonia-Shell nur die +/// Core-Ansichten; die WinForms-Shell bleibt daneben vollständig nutzbar. +/// +public static class ModuleViews +{ + public static void Register(IModuleUiHost host, IServiceProvider sp) + { + // Noch keine portierten Modul-Fenster. Das Muster steht in der WinForms-Fassung + // (UI/ModuleViews.cs) und wird hier eins zu eins übernommen. + } + + /// Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist. + internal static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register) + { + if (sp.GetServices().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase))) + register(); + } +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/ShellServices.cs b/src/IBKRTrader.App.Avalonia/Shell/ShellServices.cs new file mode 100644 index 0000000..18004da --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/ShellServices.cs @@ -0,0 +1,18 @@ +using IBKRTrader.Core.Modularity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// +/// Die Dienste, die es nur mit Oberfläche gibt. Alles andere kommt aus +/// AppHostBuilder und ist mit dem kopflosen Daemon geteilt. +/// +public static class ShellServices +{ + public static void Register(IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/SmokeUi.cs b/src/IBKRTrader.App.Avalonia/Shell/SmokeUi.cs new file mode 100644 index 0000000..9144ff1 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/SmokeUi.cs @@ -0,0 +1,95 @@ +using IBKRTrader.App.Avalonia.ViewModels; +using IBKRTrader.App.Avalonia.Views; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Settings; +using IBKRTrader.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// +/// Konstruktionsprüfung aller Fenster – ohne Message-Loop und ohne laufende Dienste. +/// +/// Nachfolger des --smoke-ui-Laufs der WinForms-Shell, der Konstruktionsfehler +/// zuverlässig gefangen hat. Der Host wird bewusst nicht gestartet: sonst liefen Worker, +/// Broker-Verbindungen und Marktdaten-Abrufe gegen die echten Endpunkte an – für eine reine +/// Konstruktionsprüfung unerwünscht, auf einem Build-Server schlicht falsch. +/// +/// Im Gegensatz zu WinForms braucht Avalonia dafür kein Anzeigegerät: +/// SetupWithoutStarting initialisiert das Framework, ohne ein Fenster zu zeigen. Damit ist +/// diese Prüfung erstmals CI-tauglich. +/// +public static class SmokeUi +{ + public static int Run() + { + Program.BuildAvaloniaApp().SetupWithoutStarting(); + + var modules = AppHostBuilder.CreateModules(); + using var host = AppHostBuilder.Build(modules, ShellServices.Register); + App.Services = host.Services; + + var uiHost = host.Services.GetRequiredService(); + CoreViews.Register(uiHost, host.Services); + foreach (var module in modules) + module.RegisterUi(uiHost, host.Services); + ModuleViews.Register(uiHost, host.Services); + ViewIcons.AssignDefaults(uiHost); + + var failures = 0; + Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ==="); + + foreach (var view in uiHost.Views) + failures += Check(view.Id, view.Title, () => view.CreateView()); + + failures += Check("shell.launcher", "Launcher", () => new LauncherWindow(uiHost, host.Services)); + failures += Check("shell.shutdown", "Beenden-Abfrage", () => new ShutdownConfirmWindow()); + + // Die Einstellungsmaske entsteht aus den Attributen von AppSettings. Ein Fenster kann + // fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen – + // deshalb hier gegen die tatsächliche Feldzahl prüfen. + failures += CheckSettingsForm(host.Services.GetRequiredService()); + + Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ==="); + return failures == 0 ? 0 : 1; + } + + private static int Check(string id, string title, Func construct) + { + try + { + _ = construct(); + Console.WriteLine($"[OK] {id} ({title})"); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}"); + return 1; + } + } + + private static int CheckSettingsForm(SettingsService settings) + { + try + { + var sections = SettingsModelBuilder.Build(settings.Settings); + var fieldCount = sections.Sum(s => s.Fields.Count); + + if (sections.Count == 0 || fieldCount == 0) + { + Console.WriteLine("[FEHLER] Einstellungsmaske: keine Felder aus AppSettings ermittelt " + + "(Category-/DisplayName-Attribute verloren?)."); + return 1; + } + + Console.WriteLine($"[OK] Einstellungsmaske: {sections.Count} Abschnitte, {fieldCount} Felder"); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"[FEHLER] Einstellungsmaske: {ex.GetType().Name}: {ex.Message}"); + return 1; + } + } +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/ViewIcons.cs b/src/IBKRTrader.App.Avalonia/Shell/ViewIcons.cs new file mode 100644 index 0000000..9b289f8 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/ViewIcons.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using IBKRTrader.Core.Modularity; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// +/// Löst die toolkit-neutralen -Schlüssel gegen die +/// Avalonia-Bildressourcen auf. Gegenstück zum gleichnamigen Helfer der WinForms-Shell – +/// dieselben Schlüssel, dieselben PNG-Dateien, sodass Core und Module unverändert bleiben. +/// +public static class ViewIcons +{ + /// Symbol-Schlüssel → Dateiname unter Resources/ (als Avalonia-Asset eingebettet). + private static readonly Dictionary FileByKey = new(StringComparer.OrdinalIgnoreCase) + { + ["dashboard"] = "dashboard.png", + ["workers"] = "system_time.png", + ["logs"] = "error_log.png", + ["settings"] = "setting_tools.png", + ["congresstrading"] = "cross_reference.png", + ["accounting"] = "coins_in_hand.png", + ["supervisor"] = "token_quantifier.png", + }; + + /// Standard-Symbolschlüssel je View-ID – identisch zur WinForms-Shell. + private static readonly Dictionary DefaultKeyByViewId = new(StringComparer.OrdinalIgnoreCase) + { + ["core.dashboard"] = "dashboard", + ["core.workers"] = "workers", + ["core.logs"] = "logs", + ["core.settings"] = "settings", + ["congresstrading.main"] = "congresstrading", + ["accounting.main"] = "accounting", + ["supervisor.main"] = "supervisor", + }; + + private static readonly ConcurrentDictionary Cache = new(); + + /// Bild zum Schlüssel, oder null (kein Symbol / Datei fehlt). + public static Bitmap? Resolve(string? iconKey) + { + if (iconKey is null || !FileByKey.TryGetValue(iconKey, out var file)) return null; + + return Cache.GetOrAdd(iconKey, _ => + { + try + { + using var stream = AssetLoader.Open( + new Uri($"avares://IBKRTrader.App.Avalonia/Assets/{file}")); + return new Bitmap(stream); + } + catch + { + // Ein fehlendes Symbol darf die Oberfläche nie aufhalten – dann eben nur Text. + return null; + } + }); + } + + /// Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist. + public static void AssignDefaults(IModuleUiHost host) + { + foreach (var view in host.Views) + if (view.IconKey is null && DefaultKeyByViewId.TryGetValue(view.Id, out var key)) + view.IconKey = key; + } +} diff --git a/src/IBKRTrader.App.Avalonia/Shell/WindowMenu.cs b/src/IBKRTrader.App.Avalonia/Shell/WindowMenu.cs new file mode 100644 index 0000000..d16980a --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Shell/WindowMenu.cs @@ -0,0 +1,92 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using IBKRTrader.Core.Modularity; + +namespace IBKRTrader.App.Avalonia.Shell; + +/// +/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen +/// allen Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract +/// und funktioniert deshalb auch aus Modul-Fenstern. +/// +/// Gegenstück zum gleichnamigen WinForms-Helfer – gleiches Verhalten, anderes Toolkit. +/// +public static class WindowMenu +{ + /// + /// Verdrahtet ein mit der Fensterliste: füllt es sofort und baut es bei + /// jeder Änderung des Offen-Status neu auf. Die Registrierung wird beim Entladen gelöst. + /// + /// ID der eigenen View, oder null im Launcher. + public static void Wire(Menu menu, IModuleUiHost host, string? currentViewId) + { + void Refresh() + { + // Der Offen-Status kann aus einem beliebigen Fenster gemeldet werden – der Aufbau + // der Menüleiste gehört aber auf den UI-Thread. + if (Dispatcher.UIThread.CheckAccess()) Populate(menu, host, currentViewId); + else Dispatcher.UIThread.Post(() => Populate(menu, host, currentViewId)); + } + + Populate(menu, host, currentViewId); + host.OpenStateChanged += Refresh; + menu.DetachedFromVisualTree += (_, _) => host.OpenStateChanged -= Refresh; + } + + /// Baut die Menüleiste komplett neu auf. + public static void Populate(Menu menu, IModuleUiHost host, string? currentViewId) + { + var items = new List + { + BuildItem("Launcher", null, isCurrent: currentViewId is null, + isOpen: false, onClick: host.ActivateMain) + }; + + foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title)) + { + var id = view.Id; + items.Add(BuildItem(view.Title, view.IconKey, + isCurrent: id == currentViewId, + isOpen: host.IsOpen(id), + onClick: () => host.OpenView(id))); + } + + // Kontextabhängige rechte Aktion: nur der Launcher darf die Anwendung beenden; jedes + // andere Fenster bietet nur „Fenster schließen" (Module laufen weiter). + if (currentViewId is null) + { + items.Add(BuildItem("Beenden", null, false, false, host.RequestShutdown, alignRight: true)); + } + else + { + items.Add(BuildItem("Fenster schließen", null, false, false, + () => (menu.GetVisualRoot() as Window)?.Close(), alignRight: true)); + } + + menu.ItemsSource = items; + } + + private static MenuItem BuildItem(string title, string? iconKey, bool isCurrent, bool isOpen, + Action onClick, bool alignRight = false) + { + var item = new MenuItem + { + Header = title, + FontWeight = isCurrent ? FontWeight.Bold : FontWeight.Normal, + // Offene Fenster werden hervorgehoben – Ersatz für das Häkchen der WinForms-Leiste. + Foreground = isOpen && !isCurrent ? Brushes.SteelBlue : null, + }; + + if (alignRight) item.HorizontalAlignment = HorizontalAlignment.Right; + + var icon = ViewIcons.Resolve(iconKey); + if (icon is not null) + item.Icon = new Image { Source = icon, Width = 16, Height = 16 }; + + item.Click += (_, _) => onClick(); + return item; + } +} diff --git a/src/IBKRTrader.App.Avalonia/ViewModels/Rows.cs b/src/IBKRTrader.App.Avalonia/ViewModels/Rows.cs new file mode 100644 index 0000000..6574320 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/ViewModels/Rows.cs @@ -0,0 +1,18 @@ +namespace IBKRTrader.App.Avalonia.ViewModels; + +/// +/// Zeilentypen für die DataGrids der Core-Ansichten. +/// +/// Bewusst benannte Records statt der anonymen Typen, die die WinForms-Fassung an +/// DataGridView.DataSource gehängt hat: Avalonias DataGrid bindet über +/// kompilierte Bindings gegen einen bekannten Typ. Anonyme Typen sind internal und +/// funktionieren dort nur über Reflexion – mit benannten Records bleibt die Spaltendefinition +/// im XAML prüfbar. +/// +public sealed record ModuleRow(string Name, string Prefix, string Status); + +/// +/// Eine Zeile im Live-Log. Die Farbe hängt am Eintrag statt an einer Selektion – WinForms färbte +/// über SelectionColor der RichTextBox ein, in Avalonia wird je Element gebunden. +/// +public sealed record LogRow(string Text, global::Avalonia.Media.IBrush Color); diff --git a/src/IBKRTrader.App.Avalonia/ViewModels/SettingsModel.cs b/src/IBKRTrader.App.Avalonia/ViewModels/SettingsModel.cs new file mode 100644 index 0000000..0e83866 --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/ViewModels/SettingsModel.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Reflection; + +namespace IBKRTrader.App.Avalonia.ViewModels; + +/// Ein bearbeitbares Einzelfeld der Einstellungen. +public sealed class SettingsField +{ + public required string DisplayName { get; init; } + public required string Description { get; init; } + public required Type ValueType { get; init; } + public required bool IsPassword { get; init; } + + public required Func Get { get; init; } + public required Action Set { get; init; } +} + +/// Ein Abschnitt (entspricht einem aufklappbaren Knoten des früheren PropertyGrid). +public sealed record SettingsSection(string Title, IReadOnlyList Fields); + +/// +/// Baut die Eingabemaske der Einstellungen aus den Attributen von AppSettings. +/// +/// Warum aus Attributen: Die WinForms-Fassung zeigte AppSettings in einem +/// PropertyGrid. Avalonia hat dafür kein Gegenstück. Die Klassen tragen bereits +/// , und +/// – daraus lässt sich die Maske erzeugen, statt sie von Hand +/// zu pflegen. Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die Oberfläche +/// anfasst; genau das war der Vorteil des PropertyGrid, und er bleibt erhalten. +/// +public static class SettingsModelBuilder +{ + /// Typen, die als Eingabefeld dargestellt werden. Alles andere gilt als Unterabschnitt. + private static bool IsLeaf(Type t) => + t == typeof(string) || t.IsEnum || + t == typeof(int) || t == typeof(long) || t == typeof(double) || + t == typeof(decimal) || t == typeof(bool); + + /// Zerlegt das Einstellungsobjekt in Abschnitte mit Feldern. + public static IReadOnlyList Build(object root) + { + var sections = new List(); + Walk(root, prefix: null, sections); + return sections; + } + + private static void Walk(object owner, string? prefix, List sections) + { + var fields = new List(); + + foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue; + + var title = prop.GetCustomAttribute()?.DisplayName ?? prop.Name; + + if (IsLeaf(prop.PropertyType)) + { + if (!prop.CanWrite) continue; // z. B. berechnete Eigenschaften + + var target = owner; // für den Abschluss festhalten + fields.Add(new SettingsField + { + DisplayName = title, + Description = prop.GetCustomAttribute()?.Description ?? "", + ValueType = prop.PropertyType, + IsPassword = prop.GetCustomAttribute()?.Password == true, + Get = () => prop.GetValue(target), + Set = v => prop.SetValue(target, v) + }); + continue; + } + + // Verschachteltes Einstellungsobjekt → eigener Abschnitt. Nur eigene Typen verfolgen, + // damit die Rekursion nicht in Framework-Typen abbiegt. + if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader") == true) + { + var child = prop.GetValue(owner); + if (child is not null) + Walk(child, prefix is null ? title : $"{prefix} · {title}", sections); + } + } + + if (fields.Count > 0) + sections.Add(new SettingsSection(prefix ?? "Allgemein", fields)); + } +} diff --git a/src/IBKRTrader.App.Avalonia/Views/DashboardWindow.axaml b/src/IBKRTrader.App.Avalonia/Views/DashboardWindow.axaml new file mode 100644 index 0000000..41ee7bf --- /dev/null +++ b/src/IBKRTrader.App.Avalonia/Views/DashboardWindow.axaml @@ -0,0 +1,44 @@ + + + + + + + +