rs2322tcp/internal/gui/app.go
2026-08-11 19:20:58 +02:00

391 lines
9.2 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:
* Grundgerüst der grafischen Benutzeroberfläche des rs2322tcp-Clients.
*
* Die GUI verwendet Fyne und stellt die Benutzeroberfläche für den
* technischen rs2322tcp-Client bereit.
*
* Die eigentliche Kommunikation mit dem Server sowie die Verwaltung der
* virtuellen seriellen Ports bleiben vollständig in internal/client.
*
* Der technische Client wird im Hintergrund gestartet, damit das Fyne-
* Fenster unmittelbar angezeigt werden kann. Dadurch bleibt die GUI auch
* dann bedienbar, wenn der Server nicht erreichbar ist oder der Aufbau
* der Verbindung längere Zeit benötigt.
*
* Aktualisierungen von Fyne-Widgets aus einer Hintergrund-Goroutine werden
* über fyne.Do() auf den Fyne-GUI-Thread übertragen.
*
* Die GUI stellt außerdem die Funktion "Server neu verbinden" bereit.
* Dabei wird die bestehende technische Client-Anwendung beendet und
* anschließend vollständig neu gestartet. Dadurch wird die aktuelle
* client.json erneut geladen.
*
* Die Gerätezuordnung wird zunächst ausschließlich angezeigt. Änderungen
* an der Zuordnung und deren Speicherung werden in einem späteren
* Entwicklungsschritt ergänzt.
* ============================================================================
*/
package gui
import (
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"git.lang-dieter.de/rs2322tcp/internal/client"
)
// 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
}
// NewApp creates the graphical rs2322tcp client application.
//
// No server connection is established by NewApp. The GUI can therefore be
// displayed immediately after this function returns.
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()
return app, nil
}
// buildContent creates the initial main-window content.
//
// The layout is deliberately simple. More detailed status information and
// the device-assignment dialog will be added in later development steps.
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)
}
// Start starts the technical client application in the background.
//
// The Fyne window is deliberately not blocked by the network connection.
// The status shown by the GUI is updated through fyne.Do(), because the
// technical client runs outside the Fyne GUI thread.
//
// While the initial connection is being established, the reconnect button
// is disabled. This prevents multiple concurrent connection attempts.
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.
//
// The reconnect operation is deliberately handled by
// client.Application.Reconnect(). The GUI therefore does not need to know
// how the technical client closes connections, reloads the configuration,
// connects to the server or starts the Runtime.
//
// While reconnecting, the button is disabled so that only one reconnect
// operation can be active at a time.
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()
})
}()
}
// showAssignments displays the current local-to-remote device assignments.
//
// This first implementation is deliberately read-only. It obtains the
// configuration and the current server device list from client.Application
// and converts them through the GUI-independent assignment functions.
//
// No configuration is changed or written by this function.
func (a *App) showAssignments() {
if a == nil {
return
}
// Do not create another assignment window when one is already open.
if a.assignmentWindow != nil {
a.assignmentWindow.Show()
return
}
cfg := a.clientApplication.Config()
devices := a.clientApplication.Devices()
assignments := BuildAssignments(cfg, devices)
unassigned := UnassignedDevices(cfg, devices)
content := container.NewVBox(
widget.NewLabel("Lokale Zuordnungen"),
)
if len(assignments) == 0 {
content.Add(
widget.NewLabel("Keine lokalen Zuordnungen vorhanden."),
)
} else {
for _, assignment := range assignments {
status := "nicht verfügbar"
if assignment.Available {
status = "verfügbar"
}
content.Add(
widget.NewLabel(
fmt.Sprintf(
"%s -> %s (%s)",
assignment.LocalPort,
assignment.RemoteDevice,
status,
),
),
)
}
}
content.Add(widget.NewSeparator())
content.Add(
widget.NewLabel("Server-Geräte ohne lokale Zuordnung"),
)
if len(unassigned) == 0 {
content.Add(
widget.NewLabel(
"Keine nicht verwendeten Server-Geräte.",
),
)
} else {
for _, device := range unassigned {
content.Add(
widget.NewLabel(
fmt.Sprintf(
"%s noch nicht verwendet",
device.ID,
),
),
)
}
}
assignmentWindow := a.fyneApp.NewWindow(
"Gerätezuordnung",
)
assignmentWindow.Resize(fyne.NewSize(650, 450))
// Remember the child window so it can be closed together with
// the main window.
a.assignmentWindow = assignmentWindow
assignmentWindow.SetOnClosed(func() {
// Only clear the reference if this is still the current
// assignment window.
if a.assignmentWindow == assignmentWindow {
a.assignmentWindow = nil
}
})
assignmentWindow.SetContent(
container.NewBorder(
nil,
widget.NewButton(
"Schließen",
func() {
assignmentWindow.Close()
},
),
nil,
nil,
container.NewVScroll(content),
),
)
assignmentWindow.Show()
}
// closeAssignmentWindow closes the currently open device-assignment window.
//
// This is deliberately kept separate from Close() so that the child window
// can be closed without changing any configuration or technical client
// state.
func (a *App) closeAssignmentWindow() {
if a == nil {
return
}
if a.assignmentWindow == nil {
return
}
window := a.assignmentWindow
a.assignmentWindow = nil
window.Close()
}
// Close closes the technical client application.
//
// Any open child GUI window is closed first. The technical client is then
// shut down.
func (a *App) Close() {
if a == nil {
return
}
a.closeAssignmentWindow()
_ = a.clientApplication.Close()
}
// ShowAndRun displays the main window and starts the Fyne event loop.
//
// This method blocks until the GUI application terminates.
func (a *App) ShowAndRun() {
if a == nil {
return
}
a.window.ShowAndRun()
}
// Window returns the main application window.
//
// The method is provided for the program entry point and for later GUI
// initialization that needs access to the window.
func (a *App) Window() fyne.Window {
if a == nil {
return nil
}
return a.window
}