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 @@ + + + + + + + +