/* * ============================================================================ * 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 }