348 lines
8.2 KiB
Go
348 lines
8.2 KiB
Go
/*
|
||
* ============================================================================
|
||
* 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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))
|
||
assignmentWindow.SetContent(
|
||
container.NewBorder(
|
||
nil,
|
||
widget.NewButton(
|
||
"Schließen",
|
||
func() {
|
||
assignmentWindow.Close()
|
||
},
|
||
),
|
||
nil,
|
||
nil,
|
||
container.NewVScroll(content),
|
||
),
|
||
)
|
||
assignmentWindow.Show()
|
||
}
|
||
|
||
// Close closes the technical client application.
|
||
//
|
||
// The Runtime and the server connection are closed before the GUI window
|
||
// itself is closed.
|
||
func (a *App) Close() {
|
||
if a == nil {
|
||
return
|
||
}
|
||
|
||
_ = 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
|
||
}
|