660 lines
14 KiB
Go
660 lines
14 KiB
Go
/*
|
||
* ============================================================================
|
||
* 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
|
||
}
|