// ConfigForm.cs // Hauptfenster der VersaPad-Konfiguration. // // Layout: // ┌── Tasten (4 × 5 Grid) ──────────────────────────────────────────────┐ // │ Jede Taste = Button mit Hintergrundfarbe (= LED-Base) und │ // │ Aktionsbeschriftung. Klick öffnet ActionDialog. │ // └────────────────────────────────────────────────────────────────────┘ // ┌── Encoder ─────────────────────────────────────────────────────────┐ // │ 4 Zeilen à [SW][CW][CCW] – ohne LED-Farbe │ // └────────────────────────────────────────────────────────────────────┘ // [Auf Board speichern] [Schließen] // // "Auf Board speichern" sendet die Config in 6-Byte-Chunks über Serial. // Firmware-Seite (WRITE_CONFIG) ist noch TODO → Schaltfläche ist // deaktiviert wenn das Board nicht verbunden ist. namespace VersaGUI; public class ConfigForm : Form { private readonly DeviceConfig _config; private readonly MacroTable _macros; private readonly SerialManager _serial; private readonly Button[] _btnGrid = new Button[20]; private readonly Button[,] _encBtns = new Button[4, 3]; private Button _saveBtn = null!; public ConfigForm(DeviceConfig config, MacroTable macros, SerialManager serial) { _config = config; _macros = macros; _serial = serial; Text = "VersaPad – Konfiguration"; FormBorderStyle = FormBorderStyle.FixedSingle; StartPosition = FormStartPosition.CenterScreen; MaximizeBox = false; ClientSize = new Size(520, 480); int y = 12; y = BuildButtonGrid(y); y = BuildEncoderPanel(y); BuildFooter(y); // Verbindungsstatus → Save-Button _serial.Connected += (_, _) => InvokeOnUi(() => _saveBtn.Enabled = true); _serial.Disconnected += (_, _) => InvokeOnUi(() => _saveBtn.Enabled = false); _saveBtn.Enabled = _serial.IsConnected; } // ── Tasten-Grid (4 Spalten × 5 Zeilen) ─────────────────────────────────── private int BuildButtonGrid(int startY) { var group = new GroupBox { Text = "Tasten", Location = new Point(12, startY), Size = new Size(496, 220), }; var grid = new TableLayoutPanel { Location = new Point(8, 20), Size = new Size(480, 192), ColumnCount = 4, RowCount = 5, CellBorderStyle = TableLayoutPanelCellBorderStyle.Single, }; // Gleichmäßige Spalten/Zeilen for (int c = 0; c < 4; c++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25f)); for (int r = 0; r < 5; r++) grid.RowStyles.Add(new RowStyle(SizeType.Percent, 20f)); // MX-Buttons: col=0..3 (physisch COL1..4), row=0..4 // mx_idx = col * 5 + row (entspricht key_id - 5) for (int col = 0; col < 4; col++) { for (int row = 0; row < 5; row++) { int mxIdx = col * 5 + row; var btn = new Button { Dock = DockStyle.Fill, Margin = new Padding(2), FlatStyle = FlatStyle.Flat, Tag = mxIdx, }; btn.FlatAppearance.BorderSize = 0; btn.Click += OnMxButtonClick; _btnGrid[mxIdx] = btn; grid.Controls.Add(btn, col, row); RefreshMxButton(mxIdx); } } group.Controls.Add(grid); Controls.Add(group); return startY + group.Height + 8; } // ── Encoder-Panel ───────────────────────────────────────────────────────── private int BuildEncoderPanel(int startY) { var group = new GroupBox { Text = "Encoder", Location = new Point(12, startY), Size = new Size(496, 145), }; string[] labels = { "SW", "CW", "CCW" }; // Header-Zeile for (int i = 0; i < 3; i++) { group.Controls.Add(new Label { Text = labels[i], Location = new Point(110 + i * 122, 22), AutoSize = true, Font = new Font(Font, FontStyle.Bold), TextAlign = ContentAlignment.MiddleCenter, }); } for (int enc = 0; enc < 4; enc++) { group.Controls.Add(new Label { Text = $"ENC {enc}:", Location = new Point(10, 44 + enc * 26), AutoSize = true, }); for (int act = 0; act < 3; act++) { int e = enc, a = act; var btn = new Button { Location = new Point(80 + act * 122, 40 + enc * 26), Size = new Size(116, 22), FlatStyle = FlatStyle.Flat, Tag = (e, a), }; btn.FlatAppearance.BorderColor = Color.Gray; btn.Click += OnEncButtonClick; _encBtns[enc, act] = btn; group.Controls.Add(btn); RefreshEncButton(enc, act); } } Controls.Add(group); return startY + group.Height + 8; } // ── Fußzeile ────────────────────────────────────────────────────────────── private void BuildFooter(int startY) { _saveBtn = new Button { Text = "Auf Board speichern", Location = new Point(12, startY), Size = new Size(160, 30), }; _saveBtn.Click += OnSave; var pingBtn = new Button { Text = "Ping", Location = new Point(184, startY), Size = new Size(60, 30), }; pingBtn.Click += (_, _) => _serial.Send(new SerialPacket(Protocol.CmdPing)); var exportBtn = new Button { Text = "Exportieren", Location = new Point(256, startY), Size = new Size(80, 30), }; exportBtn.Click += OnExport; var importBtn = new Button { Text = "Importieren", Location = new Point(340, startY), Size = new Size(80, 30), }; importBtn.Click += OnImport; var closeBtn = new Button { Text = "Schließen", Location = new Point(424, startY), Size = new Size(84, 30), }; closeBtn.Click += (_, _) => Close(); Controls.AddRange(new Control[] { _saveBtn, pingBtn, exportBtn, importBtn, closeBtn }); // Fensterhöhe anpassen ClientSize = new Size(ClientSize.Width, startY + 50); } // ── Klick-Handler ───────────────────────────────────────────────────────── private void OnMxButtonClick(object? sender, EventArgs e) { if (sender is not Button btn || btn.Tag is not int mxIdx) return; int slot = MacroTable.SlotForMx(mxIdx); using var dlg = new ActionDialog( _config.MxActions[mxIdx], _config.LedBase[mxIdx], _config.LedAnim[mxIdx], _config.LedPeriod[mxIdx], _macros, slot, showColor: true); if (dlg.ShowDialog(this) != DialogResult.OK) return; _config.MxActions[mxIdx] = dlg.ResultAction; _config.LedBase[mxIdx] = dlg.ResultColor; _config.LedAnim[mxIdx] = dlg.ResultAnim; _config.LedPeriod[mxIdx] = dlg.ResultPeriod; RefreshMxButton(mxIdx); } private void OnEncButtonClick(object? sender, EventArgs e) { if (sender is not Button btn || btn.Tag is not (int enc, int act)) return; int slot = MacroTable.SlotForEncoder(enc, act); using var dlg = new ActionDialog( _config.EncActions[enc, act], Color.Black, LedAnimType.Static, 0, _macros, slot, showColor: false); if (dlg.ShowDialog(this) != DialogResult.OK) return; _config.EncActions[enc, act] = dlg.ResultAction; RefreshEncButton(enc, act); } private void OnSave(object? sender, EventArgs e) { _saveBtn.Enabled = false; _saveBtn.Text = "Wird gesendet..."; // SendConfig + SendMacros blockieren ~400ms → Background-Thread Task.Run(() => { _serial.SendConfig(_config); Thread.Sleep(50); // kurze Pause zwischen Config- und Makro-Transfer _serial.SendMacros(_macros); InvokeOnUi(() => { _saveBtn.Text = "Auf Board speichern"; _saveBtn.Enabled = _serial.IsConnected; }); }); } // ── Anzeige aktualisieren ───────────────────────────────────────────────── // Alle Buttons neu zeichnen (z.B. nach Config-Laden vom Board) public void RefreshAll() { for (int i = 0; i < 20; i++) RefreshMxButton(i); for (int enc = 0; enc < 4; enc++) for (int act = 0; act < 3; act++) RefreshEncButton(enc, act); } private void RefreshMxButton(int mxIdx) { var btn = _btnGrid[mxIdx]; var action = _config.MxActions[mxIdx]; var color = _config.LedBase[mxIdx]; var anim = _config.LedAnim[mxIdx]; // Regenbogen: Hintergrund als Verlauf andeuten (dunkles Grau als Neutral) if (anim == LedAnimType.ColorCycle) { btn.BackColor = Color.FromArgb(40, 40, 40); btn.ForeColor = Color.White; } else { btn.BackColor = color; btn.ForeColor = Luminance(color) > 128 ? Color.Black : Color.White; } string animName = anim switch { LedAnimType.Blink => " [Blinken]", LedAnimType.Pulse => " [Pulsieren]", LedAnimType.ColorCycle => " [Regenbogen]", _ => "", }; btn.Text = action.Display + animName; btn.ToolTipText(action.TypeDescription()); } private void RefreshEncButton(int enc, int act) { var btn = _encBtns[enc, act]; var action = _config.EncActions[enc, act]; btn.Text = action.Display; } // Helligkeit einer Farbe (0–255) für Kontrast-Entscheidung private static int Luminance(Color c) => (c.R * 299 + c.G * 587 + c.B * 114) / 1000; // ── Import / Export ─────────────────────────────────────────────────────── private void OnExport(object? sender, EventArgs e) { using var dlg = new SaveFileDialog { Title = "Konfiguration exportieren", Filter = "VersaPad Config (*.json)|*.json", DefaultExt = "json", FileName = "versapad_config.json", }; if (dlg.ShowDialog(this) != DialogResult.OK) return; try { string json = ConfigJson.Serialize(_config); File.WriteAllText(dlg.FileName, json, System.Text.Encoding.UTF8); } catch (Exception ex) { MessageBox.Show($"Export fehlgeschlagen:\n{ex.Message}", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OnImport(object? sender, EventArgs e) { using var dlg = new OpenFileDialog { Title = "Konfiguration importieren", Filter = "VersaPad Config (*.json)|*.json", }; if (dlg.ShowDialog(this) != DialogResult.OK) return; try { string json = File.ReadAllText(dlg.FileName, System.Text.Encoding.UTF8); ConfigJson.Deserialize(json, _config); RefreshAll(); } catch (Exception ex) { MessageBox.Show($"Import fehlgeschlagen:\n{ex.Message}", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void InvokeOnUi(Action a) { if (InvokeRequired) Invoke(a); else a(); } } // ── Erweiterungsmethoden ────────────────────────────────────────────────────── internal static class Extensions { // Button.ToolTipText existiert in WinForms nicht direkt – ToolTip-Komponente nötig. // Wir verwenden einen statischen ToolTip für alle Buttons. private static readonly ToolTip _tip = new(); public static void ToolTipText(this Control ctrl, string text) => _tip.SetToolTip(ctrl, text); public static string TypeDescription(this DeviceAction a) => a.Type switch { ActionType.HidKey => "HID Tastatur-Keycode", ActionType.HidConsumer => "HID Consumer Control (Media/Volume)", ActionType.HostCommand => "Host Command – App führt aus", _ => "Keine Aktion", }; }