L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows

Core, alle drei Module und das Testprojekt tragen keinen UI-Code mehr und
bauen fuer linux-x64. Nur noch IBKRTrader.App ist Windows-gebunden.

UI-Contract toolkit-neutral (Vorbild: PolytraderSharp):
- ModuleView.CreateForm (Func<Form>) -> CreateView (Func<object>)
- ModuleView.Icon (System.Drawing.Image) -> IconKey (string).
  System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux.
- WindowMenu.cs war reine WinForms-Umsetzung -> in die Shell verschoben.

LoggingService haelt keine RichTextBox mehr, sondern meldet Eintraege ueber
event EntryWritten. Einfaerbung und UI-Thread-Wechsel liegen jetzt im
LogPanelController der Shell. Nebenbei: ToUpper() -> ToUpperInvariant()
(tr-TR haette aus "info" ein "İNFO" gemacht) und \r\n -> Environment.NewLine.

Die drei Modul-Fenster liegen jetzt unter UI/Views/Modules/; RegisterUi der
Module ist bewusst leer, die Shell registriert sie zentral ueber
UI/ModuleViews.cs (nur fuer tatsaechlich geladene Module). ViewIcons loest
IconKey gegen die PNG-Ressourcen auf - dieselben Schluessel bekommt spaeter
die Avalonia-Shell.

UiConstructionTests entfernt: die Konstruktionspruefung deckt --smoke-ui ab,
das Testprojekt braucht dafuer keine UI-Referenz mehr. Der Test
RegisterUi_RegistersMainView haelt jetzt das Gegenteil fest - das Modul darf
nichts registrieren, sonst waere es wieder toolkit-gebunden.

Verifiziert: Build 0 Fehler/0 Warnungen, 163 Tests gruen, --smoke-ui
konstruiert alle 7 Fenster, und Core + 3 Module + Tests bauen fuer linux-x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:38:01 +02:00
co-authored by Claude Opus 5
parent 18b1059fa9
commit 123f38ab6f
22 changed files with 302 additions and 259 deletions
+5 -4
View File
@@ -1,12 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- Plattformneutral: derselbe Core läuft unter Windows und Linux. Der UI-Contract
(ModuleView/IModuleUiHost) ist toolkit-neutral (Func<object> statt Func<Form>, IconKey
statt System.Drawing.Image) – hier hängt weder WinForms noch System.Drawing.Common,
letzteres ist seit .NET 7 Windows-only und wirft auf Linux. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Der Core stellt den UI-Contract (ModuleFormBase/WindowManager, später IModuleUiHost/ModuleView)
bereit, damit Module designbare Forms beitragen können. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
+35 -46
View File
@@ -4,22 +4,32 @@ namespace IBKRTrader.Core.Logging;
/// <summary>
/// Thread-sicherer Logging-Service.
/// – Schreibt farbig in die RichTextBox (UI-Thread-safe via BeginInvoke)
/// – Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt
/// – Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt sowie strukturiert nach Logs\{Datum}.jsonl
/// – Meldet jeden Eintrag über <see cref="EntryWritten"/> an interessierte Senken (z. B. die
/// Live-Log-Ansicht der Oberfläche)
///
/// <para><b>Bewusst ohne UI-Bezug:</b> Früher hielt dieser Dienst direkt eine
/// <c>RichTextBox</c> samt <c>System.Drawing.Color</c> und marshallte selbst auf den UI-Thread.
/// Damit hing der Core an WinForms. Jetzt kennt er nur noch das Ereignis; Einfärbung und
/// Thread-Wechsel sind Sache der jeweiligen Oberfläche.</para>
/// </summary>
public class LoggingService
{
private RichTextBox? _rtb;
private AppLogLevel _minLevel = AppLogLevel.Info;
private readonly object _fileLock = new();
private readonly object _jsonlLock = new();
private static readonly string LogBaseDir =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
Path.Combine(AppContext.BaseDirectory, "Logs");
// ─── Konfiguration ────────────────────────────────────────────────────────
public void AttachRichTextBox(RichTextBox rtb) => _rtb = rtb;
/// <summary>
/// Feuert für jeden geschriebenen Eintrag (nach der Mindest-Level-Prüfung). Die Oberfläche
/// hängt sich hier ein; das Marshalling auf den UI-Thread übernimmt sie selbst, weil dieser
/// Dienst aus beliebigen Worker-Threads schreibt.
/// </summary>
public event Action<LogEntry>? EntryWritten;
public void SetMinLevel(AppLogLevel level) => _minLevel = level;
@@ -51,7 +61,13 @@ public class LoggingService
var entry = new LogEntry(DateTime.Now, level, module, message, ex);
WriteToFile(entry);
WriteToJsonl(entry, cid);
WriteToRtb(entry);
NotifySinks(entry);
}
private void NotifySinks(LogEntry e)
{
// Eine hängende Senke darf den schreibenden Worker nicht mitreißen.
try { EntryWritten?.Invoke(e); } catch { /* Logging darf niemals abstürzen */ }
}
// ─── Datei ────────────────────────────────────────────────────────────────
@@ -66,10 +82,10 @@ public class LoggingService
var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt");
var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}";
if (e.Exception != null)
line += $"\r\n {e.Exception}";
line += $"{Environment.NewLine} {e.Exception}";
lock (_fileLock)
File.AppendAllText(file, line + "\r\n");
File.AppendAllText(file, line + Environment.NewLine);
}
catch { /* Logging darf niemals abstürzen */ }
}
@@ -95,45 +111,18 @@ public class LoggingService
catch { /* Logging darf niemals abstürzen */ }
}
// ─── RichTextBox ──────────────────────────────────────────────────────────
// ─── Anzeigeformat ────────────────────────────────────────────────────────
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60);
private static readonly Color ColorError = Color.FromArgb(255, 80, 80);
private void WriteToRtb(LogEntry e)
/// <summary>
/// Einzeilige Darstellung für Log-Ansichten. Liegt hier, damit jede Oberfläche dieselbe Zeile
/// zeigt. <c>ToUpperInvariant</c> ist Absicht: <c>ToUpper()</c> würde unter tr-TR aus "info"
/// ein "İNFO" machen.
/// </summary>
public static string Format(LogEntry e)
{
if (_rtb == null) return;
try
{
var color = e.Level switch
{
AppLogLevel.Warn => ColorWarn,
AppLogLevel.Error => ColorError,
_ => ColorInfo
};
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpper(),-5}] [{e.Module}] {e.Message}";
if (e.Exception != null)
text += $"\r\n {e.Exception.Message}";
text += "\r\n";
if (_rtb.InvokeRequired)
_rtb.BeginInvoke(() => AppendColored(text, color));
else
AppendColored(text, color);
}
catch { }
}
private void AppendColored(string text, Color color)
{
if (_rtb == null) return;
_rtb.SelectionStart = _rtb.TextLength;
_rtb.SelectionLength = 0;
_rtb.SelectionColor = color;
_rtb.AppendText(text);
_rtb.SelectionColor = _rtb.ForeColor;
if (_rtb.TextLength > 0)
_rtb.ScrollToCaret();
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpperInvariant(),-5}] [{e.Module}] {e.Message}";
if (e.Exception != null)
text += $"{Environment.NewLine} {e.Exception.Message}";
return text;
}
}
+23 -11
View File
@@ -1,18 +1,23 @@
using System.Windows.Forms;
namespace IBKRTrader.Core.Modularity;
/// <summary>
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein
/// <see cref="Form"/>, das über <see cref="CreateForm"/> erzeugt wird (mit DI-Abhängigkeiten).
/// Die Shell zeigt je View höchstens eine Instanz und holt ein offenes Fenster wieder nach vorne.
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View höchstens
/// eine Instanz und holt ein offenes Fenster wieder nach vorne.
///
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
/// keinen konkreten Fenstertyp, und <see cref="IconKey"/> ist ein Schlüssel statt eines Bildes.
/// Dadurch trägt der Core keine UI-Abhängigkeit und bleibt plattformneutral – Voraussetzung für den
/// kopflosen Linux-Betrieb. Insbesondere hängt hier kein <c>System.Drawing.Image</c> mehr:
/// <c>System.Drawing.Common</c> ist seit .NET 7 Windows-only und wirft auf Linux. Die jeweilige
/// Shell kennt ihr Toolkit und castet – die WinForms-Shell auf <c>Form</c>, die Avalonia-Shell
/// auf <c>Window</c>.</para>
/// </summary>
public sealed class ModuleView
{
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
public string Id { get; init; } = Guid.NewGuid().ToString();
/// <summary>Titel (Fallback-Fenstertitel, falls das Form keinen eigenen setzt).</summary>
/// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
public string Title { get; init; } = "Fenster";
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
@@ -21,17 +26,24 @@ public sealed class ModuleView
/// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary>
public int Order { get; init; } = 0;
/// <summary>Optionales Icon für Menü/Buttons.</summary>
public System.Drawing.Image? Icon { get; set; }
/// <summary>
/// Logischer Schlüssel des Symbols für Menü/Buttons (z. B. "dashboard", "logs"). Die Shell löst
/// ihn gegen ihre eigenen Bildressourcen auf. Settable, damit die Shell den von Modulen
/// registrierten Views zentral ein Symbol zuweisen kann – Module kennen die Shell-Ressourcen nicht.
/// </summary>
public string? IconKey { get; set; }
/// <summary>Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung).</summary>
public Func<Form> CreateForm { get; init; } = () => new Form();
/// <summary>
/// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
/// <see cref="object"/> – siehe Klassen-Doku zur Toolkit-Neutralität.
/// </summary>
public Func<object> CreateView { get; init; } = () => new object();
}
/// <summary>
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core kennen)
/// das gemeinsame „Fenster"-Menü bauen (siehe <see cref="WindowMenu"/>).
/// das gemeinsame „Fenster"-Menü bauen.
/// </summary>
public interface IModuleUiHost
{
@@ -1,79 +0,0 @@
using System.Drawing;
using System.Windows.Forms;
namespace IBKRTrader.Core.Modularity;
/// <summary>
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen allen
/// Fenstern (Launcher + Core + Module) erlaubt. Da es nur den Core-Contract <see cref="IModuleUiHost"/>
/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen).
/// </summary>
public static class WindowMenu
{
/// <summary>
/// Verdrahtet einen <see cref="MenuStrip"/> mit der Fensterliste: füllt ihn sofort und baut ihn bei
/// jeder Offen-Status-Änderung neu auf. Die Registrierung wird beim Entsorgen sauber gelöst.
/// </summary>
public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId)
{
void Refresh()
{
if (menu.IsDisposed) return;
if (menu.IsHandleCreated && menu.InvokeRequired)
{
try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); }
catch { /* Fenster wird gerade geschlossen */ }
return;
}
Populate(menu, host, currentViewId);
}
Populate(menu, host, currentViewId);
host.OpenStateChanged += Refresh;
menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh;
}
/// <summary>Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts).</summary>
public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId)
{
menu.Items.Clear();
var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null };
if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold);
launcher.Click += (_, _) => host.ActivateMain();
menu.Items.Add(launcher);
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
bool isCurrent = view.Id == currentViewId;
var item = new ToolStripMenuItem(view.Title)
{
Image = view.Icon,
ImageScaling = ToolStripItemImageScaling.SizeToFit,
DisplayStyle = view.Icon != null
? ToolStripItemDisplayStyle.ImageAndText
: ToolStripItemDisplayStyle.Text,
Checked = isCurrent || host.IsOpen(view.Id)
};
if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold);
string id = view.Id;
item.Click += (_, _) => host.OpenView(id);
menu.Items.Add(item);
}
// Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden; jedes andere Fenster
// bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter).
if (currentViewId == null)
{
var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right };
exit.Click += (_, _) => host.RequestShutdown();
menu.Items.Add(exit);
}
else
{
var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right };
close.Click += (_, _) => menu.FindForm()?.Close();
menu.Items.Add(close);
}
}
}