rs2322tcp/internal/gui/app.go
2026-08-12 12:42:24 +02:00

660 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/gui/app.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Grafische Benutzeroberfläche des rs2322tcp-Clients.
*
* Die eigentliche Kommunikation mit dem Server sowie die Verwaltung der
* virtuellen seriellen Ports bleiben vollständig in internal/client.
*
* Die Gerätezuordnung wird in einem separaten Fenster bearbeitet.
*
* Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
* Arbeitsspeicher des Zuordnungsfensters gehalten.
*
* Beim Schließen des Zuordnungsfensters werden vorhandene Änderungen
* gespeichert.
*
* Wird dagegen das Hauptfenster geschlossen, während das Zuordnungsfenster
* noch ungespeicherte Änderungen enthält, wird der Anwender gefragt, ob
* diese Änderungen verworfen werden sollen.
* ============================================================================
*/
package gui
import (
"fmt"
"sort"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.lang-dieter.de/rs2322tcp/internal/client"
)
///////////////////////////////////////////////////////////////////////////////
// App
///////////////////////////////////////////////////////////////////////////////
// App represents the graphical rs2322tcp client application.
//
// App owns the Fyne window and the technical client application. The GUI
// does not access the Runtime or the Client directly. All technical client
// operations are performed through client.Application.
type App struct {
fyneApp fyne.App
window fyne.Window
clientApplication *client.Application
statusLabel *widget.Label
errorLabel *widget.Label
reconnectButton *widget.Button
assignmentButton *widget.Button
assignmentWindow fyne.Window
assignmentEditor *AssignmentEditor
}
///////////////////////////////////////////////////////////////////////////////
// Construction
///////////////////////////////////////////////////////////////////////////////
// NewApp creates the graphical rs2322tcp client application.
//
// No network connection is established by NewApp. Start must be called
// separately.
func NewApp(
fyneApp fyne.App,
configFile string,
) (*App, error) {
if fyneApp == nil {
return nil, fmt.Errorf("fyne application is nil")
}
application, err := client.NewApplication(configFile)
if err != nil {
return nil, err
}
window := fyneApp.NewWindow("rs2322tcp Client")
window.Resize(fyne.NewSize(600, 400))
app := &App{
fyneApp: fyneApp,
window: window,
clientApplication: application,
statusLabel: widget.NewLabel("Server: nicht verbunden"),
errorLabel: widget.NewLabel(""),
}
app.reconnectButton = widget.NewButton(
"Server neu verbinden",
app.reconnect,
)
app.assignmentButton = widget.NewButton(
"Gerätezuordnung",
app.showAssignments,
)
app.buildContent()
// Das Schließen des Hauptfensters wird abgefangen, damit ein noch
// geöffnetes Zuordnungsfenster mit ungespeicherten Änderungen nicht
// stillschweigend verworfen wird.
window.SetCloseIntercept(
app.handleMainWindowClose,
)
return app, nil
}
///////////////////////////////////////////////////////////////////////////////
// Main window
///////////////////////////////////////////////////////////////////////////////
// buildContent creates the main-window content.
func (a *App) buildContent() {
title := widget.NewLabel("rs2322tcp Client")
content := container.NewVBox(
title,
a.statusLabel,
a.errorLabel,
a.assignmentButton,
a.reconnectButton,
)
a.window.SetContent(content)
}
///////////////////////////////////////////////////////////////////////////////
// Client lifecycle
///////////////////////////////////////////////////////////////////////////////
// Start starts the technical client application in the background.
//
// The Fyne window remains responsive while the connection to the server is
// established.
func (a *App) Start() {
if a == nil {
return
}
a.statusLabel.SetText(
"Server: Verbindung wird aufgebaut ...",
)
a.errorLabel.SetText("")
a.reconnectButton.Disable()
go func() {
err := a.clientApplication.Start()
if err != nil {
fyne.Do(func() {
a.statusLabel.SetText(
"Server: nicht verbunden",
)
a.errorLabel.SetText(
fmt.Sprintf("Fehler: %v", err),
)
a.reconnectButton.Enable()
})
return
}
devices := a.clientApplication.Devices()
fyne.Do(func() {
a.statusLabel.SetText(
fmt.Sprintf(
"Server: verbunden %d Geräte verfügbar",
len(devices),
),
)
a.errorLabel.SetText("")
a.reconnectButton.Enable()
})
}()
}
// reconnect starts a complete reconnect of the technical client.
func (a *App) reconnect() {
if a == nil {
return
}
a.statusLabel.SetText(
"Server: Verbindung wird neu aufgebaut ...",
)
a.errorLabel.SetText("")
a.reconnectButton.Disable()
go func() {
err := a.clientApplication.Reconnect()
if err != nil {
fyne.Do(func() {
a.statusLabel.SetText(
"Server: nicht verbunden",
)
a.errorLabel.SetText(
fmt.Sprintf("Fehler: %v", err),
)
a.reconnectButton.Enable()
})
return
}
devices := a.clientApplication.Devices()
fyne.Do(func() {
a.statusLabel.SetText(
fmt.Sprintf(
"Server: verbunden %d Geräte verfügbar",
len(devices),
),
)
a.errorLabel.SetText("")
a.reconnectButton.Enable()
})
}()
}
///////////////////////////////////////////////////////////////////////////////
// Device assignment
///////////////////////////////////////////////////////////////////////////////
// showAssignments opens the editable device-assignment window.
func (a *App) showAssignments() {
if a == nil {
return
}
// Nur ein Zuordnungsfenster gleichzeitig.
if a.assignmentWindow != nil {
a.assignmentWindow.Show()
return
}
cfg := a.clientApplication.Config()
devices := a.clientApplication.Devices()
editor := NewAssignmentEditor(
cfg,
devices,
)
window := a.fyneApp.NewWindow(
"Gerätezuordnung",
)
window.Resize(
fyne.NewSize(650, 450),
)
a.assignmentWindow = window
a.assignmentEditor = editor
// Beim Schließen über das Fenstersymbol wird dieselbe Logik wie beim
// Button "Schließen" verwendet.
window.SetCloseIntercept(
func() {
a.handleAssignmentWindowClose(
window,
editor,
)
},
)
window.SetOnClosed(func() {
if a.assignmentWindow == window {
a.assignmentWindow = nil
a.assignmentEditor = nil
}
})
a.refreshAssignmentWindow(
window,
editor,
)
window.Show()
}
// refreshAssignmentWindow rebuilds all assignment Select widgets.
//
// This is intentional. When a device is disconnected, its former port
// becomes free and must immediately become selectable for the other
// currently unconnected devices.
func (a *App) refreshAssignmentWindow(
window fyne.Window,
editor *AssignmentEditor,
) {
if a == nil || window == nil || editor == nil {
return
}
deviceIDs := editorDeviceIDs(editor)
objects := make(
[]fyne.CanvasObject,
0,
len(deviceIDs)*2,
)
objects = append(
objects,
widget.NewLabelWithStyle(
"Server-Gerät",
fyne.TextAlignLeading,
fyne.TextStyle{Bold: true},
),
)
objects = append(
objects,
widget.NewLabelWithStyle(
"Lokale Schnittstelle",
fyne.TextAlignLeading,
fyne.TextStyle{Bold: true},
),
)
for _, deviceID := range deviceIDs {
deviceID := deviceID
deviceLabel := widget.NewLabel(deviceID)
selectBox := widget.NewSelect(
editor.Options(deviceID),
nil,
)
currentPort := editor.CurrentPort(deviceID)
if currentPort == "" {
selectBox.SetSelected(
NotConnected,
)
} else {
selectBox.SetSelected(
currentPort,
)
}
selectBox.OnChanged = func(selected string) {
if !editor.Set(
deviceID,
selected,
) {
return
}
// Nach jeder Änderung werden sämtliche Selectboxen neu
// aufgebaut. Dadurch werden gerade freigegebene Ports
// sofort bei allen anderen Geräten sichtbar.
a.refreshAssignmentWindow(
window,
editor,
)
}
objects = append(
objects,
deviceLabel,
selectBox,
)
}
grid := container.New(
layout.NewFormLayout(),
objects...,
)
info := widget.NewLabel(
"Die Änderungen werden beim Schließen gespeichert.",
)
closeButton := widget.NewButton(
"Schließen",
func() {
a.handleAssignmentWindowClose(
window,
editor,
)
},
)
footer := container.NewVBox(
info,
closeButton,
)
window.SetContent(
container.NewBorder(
nil,
footer,
nil,
nil,
container.NewVScroll(grid),
),
)
}
// editorDeviceIDs returns all server-device IDs known by the assignment
// editor in deterministic order.
func editorDeviceIDs(
editor *AssignmentEditor,
) []string {
if editor == nil {
return nil
}
deviceIDs := make(
[]string,
0,
len(editor.Current),
)
for deviceID := range editor.Current {
deviceIDs = append(
deviceIDs,
deviceID,
)
}
sort.Strings(deviceIDs)
return deviceIDs
}
///////////////////////////////////////////////////////////////////////////////
// Assignment saving
///////////////////////////////////////////////////////////////////////////////
// handleAssignmentWindowClose handles closing of the assignment window.
//
// The "Schließen" button saves changed assignments. If there are no changes,
// the window is simply closed.
func (a *App) handleAssignmentWindowClose(
window fyne.Window,
editor *AssignmentEditor,
) {
if a == nil || window == nil || editor == nil {
return
}
if !editor.Dirty() {
a.closeAssignmentWindowWithoutPrompt(
window,
)
return
}
a.saveAssignmentsAndClose(
window,
editor,
)
}
// saveAssignmentsAndClose converts the editor state into a client
// configuration, saves it through client.Application and closes the
// assignment window only after a successful save.
func (a *App) saveAssignmentsAndClose(
window fyne.Window,
editor *AssignmentEditor,
) {
if a == nil || window == nil || editor == nil {
return
}
cfg := a.clientApplication.Config()
if cfg == nil {
dialog.ShowError(
fmt.Errorf(
"Client-Konfiguration ist nicht verfügbar",
),
window,
)
return
}
updatedCfg := editor.ApplyToConfig(cfg)
if updatedCfg == nil {
dialog.ShowError(
fmt.Errorf(
"Gerätezuordnung konnte nicht übernommen werden",
),
window,
)
return
}
if err := a.clientApplication.SaveConfig(
updatedCfg,
); err != nil {
dialog.ShowError(
fmt.Errorf(
"Client-Konfiguration konnte nicht gespeichert werden: %w",
err,
),
window,
)
return
}
// Erst nach erfolgreichem Speichern schließen.
a.closeAssignmentWindowWithoutPrompt(
window,
)
}
// closeAssignmentWindowWithoutPrompt closes the assignment window without
// invoking its close intercept again.
func (a *App) closeAssignmentWindowWithoutPrompt(
window fyne.Window,
) {
if window == nil {
return
}
window.SetCloseIntercept(nil)
window.Close()
}
///////////////////////////////////////////////////////////////////////////////
// Main window closing
///////////////////////////////////////////////////////////////////////////////
// handleMainWindowClose handles closing of the main window.
//
// If the assignment window is open and contains unsaved changes, the user
// must explicitly decide whether those changes should be discarded.
func (a *App) handleMainWindowClose() {
if a == nil {
return
}
if a.assignmentEditor == nil ||
!a.assignmentEditor.Dirty() {
a.finishMainWindowClose()
return
}
confirm := dialog.NewConfirm(
"Ungespeicherte Änderungen",
"Die Gerätezuordnung enthält ungespeicherte "+
"Änderungen.\n\n"+
"Wenn das Hauptfenster geschlossen wird, "+
"gehen diese Änderungen verloren.\n\n"+
"Möchtest du die Änderungen verwerfen und "+
"den Client schließen?",
func(discard bool) {
if !discard {
return
}
a.finishMainWindowClose()
},
a.window,
)
confirm.SetConfirmText(
"Änderungen verwerfen",
)
confirm.SetDismissText(
"Abbrechen",
)
confirm.Show()
}
// finishMainWindowClose closes the assignment window first and then the
// main application window.
//
// The close intercepts are removed because the user has already confirmed
// the operation.
func (a *App) finishMainWindowClose() {
if a == nil {
return
}
if a.assignmentWindow != nil {
window := a.assignmentWindow
window.SetCloseIntercept(nil)
window.Close()
a.assignmentWindow = nil
a.assignmentEditor = nil
}
a.window.SetCloseIntercept(nil)
a.window.Close()
}
///////////////////////////////////////////////////////////////////////////////
// Public lifecycle
///////////////////////////////////////////////////////////////////////////////
// Close closes the technical client application.
//
// This method is also used by cmd/rs2322tcp-client after the Fyne main
// window has actually closed. Any remaining assignment window is closed
// without prompting because the main-window close handling has already
// taken place.
func (a *App) Close() {
if a == nil {
return
}
if a.assignmentWindow != nil {
window := a.assignmentWindow
window.SetCloseIntercept(nil)
window.Close()
a.assignmentWindow = nil
a.assignmentEditor = nil
}
_ = a.clientApplication.Close()
}
// ShowAndRun displays the main window and starts the Fyne event loop.
func (a *App) ShowAndRun() {
if a == nil {
return
}
a.window.ShowAndRun()
}
// Window returns the main application window.
func (a *App) Window() fyne.Window {
if a == nil {
return nil
}
return a.window
}