From 90189ab4dcba8eea17dbbf10672e550fc973b1c4 Mon Sep 17 00:00:00 2001 From: Dieter Lang Date: Wed, 12 Aug 2026 12:42:24 +0200 Subject: [PATCH] =?UTF-8?q?Ger=C3=A4tezuordnung=20in=20GUI=20verwalten=20u?= =?UTF-8?q?nd=20speichern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 44 +++ configs/server.json | 10 + go.mod | 3 +- go.sum | 3 +- internal/client/application.go | 29 ++ internal/config/config.go | 54 +++- internal/config/config_test.go | 4 +- internal/gui/app.go | 553 ++++++++++++++++++++++++--------- internal/gui/assignment.go | 398 +++++++++++++++++++++--- 9 files changed, 897 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c430d..9ac65cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert. +## [0.0.7] - 2026-08-12 + +### Added + +- Pflege der Gerätezuordnung über die grafische Benutzeroberfläche +- Zuordnung der vom Server angebotenen Geräte zu den konfigurierten + virtuellen seriellen Schnittstellen +- Unterstützung des Zustands „nicht verbunden“ +- Freie virtuelle Schnittstellen werden bei der Gerätezuordnung automatisch + neu angeboten +- Verhinderung der gleichzeitigen Zuordnung einer virtuellen Schnittstelle + zu mehreren Geräten +- Bereits zugeordnete Geräte können nur auf ihre aktuelle Schnittstelle oder + „nicht verbunden“ gesetzt werden +- Ungespeicherte Änderungen werden beim Schließen des Hauptfensters erkannt + und dem Anwender angezeigt +- Änderungen werden beim Schließen des Gerätezuordnungsfensters in der + Client-Konfiguration gespeichert +- Virtuelle Schnittstellen bleiben auch ohne Gerätezuordnung in der + Client-Konfiguration erhalten +- Ein leerer Wert für `remote_device` kennzeichnet eine freie virtuelle + Schnittstelle +- `Application.SaveConfig()` zum zentralen Speichern der Client-Konfiguration + +### Changed + +- Validierung der Client-Konfiguration erlaubt nun virtuelle Schnittstellen + ohne aktuelle Gerätezuordnung +- Die Gerätezuordnung arbeitet auf Basis der vorhandenen virtuellen + Schnittstellen und verändert bei einer Trennung nur deren + `remote_device`-Zuordnung + +### Tests + +- Gerätezuordnung mit mehreren Server-Geräten praktisch getestet +- Freigabe einer virtuellen Schnittstelle und anschließende erneute Auswahl + erfolgreich getestet +- Speicherung einer Gerätezuordnung über die GUI erfolgreich getestet +- Speicherung einer getrennten Gerätezuordnung mit leerem + `remote_device` erfolgreich getestet +- `go test ./internal/config` erfolgreich +- `go test ./internal/gui` erfolgreich +- `go test -race ./internal/gui` erfolgreich +- `git diff --check` erfolgreich ## [0.0.6] - 2026-08-10 ### Added diff --git a/configs/server.json b/configs/server.json index 5f1be34..299ed2c 100644 --- a/configs/server.json +++ b/configs/server.json @@ -26,6 +26,16 @@ "data_bits": 8, "parity": "none", "stop_bits": 1 + }, + { + "id": "gps", + "name": "GPS-Testgerät", + "serial_port": "/dev/ttyUSB3", + "baud_rate": 600, + "data_bits": 8, + "parity": "none", + "stop_bits": 1 } + ] } diff --git a/go.mod b/go.mod index 34e931b..14ed8a1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( golang.org/x/sys v0.43.0 ) +require github.com/FyshOS/fancyfs v0.0.1 // indirect + require ( fyne.io/fyne/v2 v2.8.0 fyne.io/systray v1.12.2 // indirect @@ -29,7 +31,6 @@ require ( github.com/hack-pad/safejs v0.1.0 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect diff --git a/go.sum b/go.sum index 098e522..4fdf5ef 100644 --- a/go.sum +++ b/go.sum @@ -4,11 +4,12 @@ fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg= +github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A= github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync= github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= diff --git a/internal/client/application.go b/internal/client/application.go index 7d5617b..602519f 100644 --- a/internal/client/application.go +++ b/internal/client/application.go @@ -298,3 +298,32 @@ func (a *Application) Running() bool { return a.runtime != nil && a.client != nil } +// SaveConfig saves the supplied client configuration to the configuration +// file used by this Application. +// +// After a successful save the supplied configuration also becomes the +// current in-memory configuration. +// +// The running Runtime is not restarted automatically. A subsequent +// Reconnect or application restart will use the saved configuration. +func (a *Application) SaveConfig( + cfg *config.ClientConfig, +) error { + if a == nil { + return fmt.Errorf("application is nil") + } + + if cfg == nil { + return fmt.Errorf("client configuration is nil") + } + + if err := config.SaveClient(a.configFile, cfg); err != nil { + return err + } + + a.mu.Lock() + a.config = cfg + a.mu.Unlock() + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 1a77774..ec076dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,9 @@ type VirtualPortRangeConfig struct { // VirtualPortConfig describes one local virtual serial port and the // remote device assigned to it. +// +// RemoteDevice may be empty. An empty RemoteDevice means that the virtual +// port exists but is currently not assigned to a remote device. type VirtualPortConfig struct { Port string `json:"port"` RemoteDevice string `json:"remote_device"` @@ -229,6 +232,9 @@ func (cfg *ServerConfig) Validate() error { } // Validate checks the client configuration for basic errors. +// +// A virtual port may exist without a remote-device assignment. This is a +// valid state and means that the virtual port is currently free. func (cfg *ClientConfig) Validate() error { if cfg == nil { return fmt.Errorf("client configuration is nil") @@ -239,30 +245,36 @@ func (cfg *ClientConfig) Validate() error { } if cfg.VirtualPortRange.First < DefaultVirtualPortFirst { - return fmt.Errorf("virtual port range first must be >= %d: %d", - DefaultVirtualPortFirst, cfg.VirtualPortRange.First) + return fmt.Errorf( + "virtual port range first must be >= %d: %d", + DefaultVirtualPortFirst, + cfg.VirtualPortRange.First, + ) } if cfg.VirtualPortRange.Last < cfg.VirtualPortRange.First { - return fmt.Errorf("invalid virtual port range: %d-%d", - cfg.VirtualPortRange.First, cfg.VirtualPortRange.Last) + return fmt.Errorf( + "invalid virtual port range: %d-%d", + cfg.VirtualPortRange.First, + cfg.VirtualPortRange.Last, + ) } ports := make(map[string]bool) for i, virtualPort := range cfg.VirtualPorts { if virtualPort.Port == "" { - return fmt.Errorf("virtual port %d: port is empty", i) - } - - if virtualPort.RemoteDevice == "" { - return fmt.Errorf("virtual port %q: remote device is empty", - virtualPort.Port) + return fmt.Errorf( + "virtual port %d: port is empty", + i, + ) } if ports[virtualPort.Port] { - return fmt.Errorf("duplicate virtual port: %q", - virtualPort.Port) + return fmt.Errorf( + "duplicate virtual port: %q", + virtualPort.Port, + ) } ports[virtualPort.Port] = true @@ -278,11 +290,19 @@ func (cfg *ClientConfig) Validate() error { func loadJSON(filename string, target interface{}) error { data, err := os.ReadFile(filename) if err != nil { - return fmt.Errorf("read configuration %q: %w", filename, err) + return fmt.Errorf( + "read configuration %q: %w", + filename, + err, + ) } if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse configuration %q: %w", filename, err) + return fmt.Errorf( + "parse configuration %q: %w", + filename, + err, + ) } return nil @@ -297,7 +317,11 @@ func saveJSON(filename string, value interface{}) error { data = append(data, '\n') if err := os.WriteFile(filename, data, 0644); err != nil { - return fmt.Errorf("write configuration %q: %w", filename, err) + return fmt.Errorf( + "write configuration %q: %w", + filename, + err, + ) } return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 878aefd..0587a1f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -391,8 +391,8 @@ func TestExampleServerConfig(t *testing.T) { t.Fatalf("LoadServer() failed: %v", err) } - if len(cfg.Devices) != 2 { - t.Fatalf("len(Devices) = %d, want 2", len(cfg.Devices)) + if len(cfg.Devices) != 3 { + t.Fatalf("len(Devices) = %d, want 3", len(cfg.Devices)) } } diff --git a/internal/gui/app.go b/internal/gui/app.go index bf9470e..0f78c87 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -7,44 +7,43 @@ * 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. + * 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. * - * 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. + * Die Gerätezuordnung wird in einem separaten Fenster bearbeitet. * - * Aktualisierungen von Fyne-Widgets aus einer Hintergrund-Goroutine werden - * über fyne.Do() auf den Fyne-GUI-Thread übertragen. + * Änderungen an der Gerätezuordnung werden zunächst ausschließlich im + * Arbeitsspeicher des Zuordnungsfensters gehalten. * - * 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. + * Beim Schließen des Zuordnungsfensters werden vorhandene Änderungen + * gespeichert. * - * Die Gerätezuordnung wird zunächst ausschließlich angezeigt. Änderungen - * an der Zuordnung und deren Speicherung werden in einem späteren - * Entwicklungsschritt ergänzt. + * 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 @@ -62,12 +61,17 @@ type App struct { assignmentButton *widget.Button assignmentWindow fyne.Window + assignmentEditor *AssignmentEditor } +/////////////////////////////////////////////////////////////////////////////// +// Construction +/////////////////////////////////////////////////////////////////////////////// + // 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. +// No network connection is established by NewApp. Start must be called +// separately. func NewApp( fyneApp fyne.App, configFile string, @@ -104,13 +108,21 @@ func NewApp( 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 } -// 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. +/////////////////////////////////////////////////////////////////////////////// +// Main window +/////////////////////////////////////////////////////////////////////////////// + +// buildContent creates the main-window content. func (a *App) buildContent() { title := widget.NewLabel("rs2322tcp Client") @@ -125,20 +137,22 @@ func (a *App) buildContent() { a.window.SetContent(content) } +/////////////////////////////////////////////////////////////////////////////// +// Client lifecycle +/////////////////////////////////////////////////////////////////////////////// + // 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. +// 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.statusLabel.SetText( + "Server: Verbindung wird aufgebaut ...", + ) a.errorLabel.SetText("") a.reconnectButton.Disable() @@ -147,7 +161,9 @@ func (a *App) Start() { if err != nil { fyne.Do(func() { - a.statusLabel.SetText("Server: nicht verbunden") + a.statusLabel.SetText( + "Server: nicht verbunden", + ) a.errorLabel.SetText( fmt.Sprintf("Fehler: %v", err), ) @@ -174,20 +190,14 @@ func (a *App) Start() { } // 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.statusLabel.SetText( + "Server: Verbindung wird neu aufgebaut ...", + ) a.errorLabel.SetText("") a.reconnectButton.Disable() @@ -196,7 +206,9 @@ func (a *App) reconnect() { if err != nil { fyne.Do(func() { - a.statusLabel.SetText("Server: nicht verbunden") + a.statusLabel.SetText( + "Server: nicht verbunden", + ) a.errorLabel.SetText( fmt.Sprintf("Fehler: %v", err), ) @@ -222,19 +234,17 @@ func (a *App) reconnect() { }() } -// 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. +/////////////////////////////////////////////////////////////////////////////// +// Device assignment +/////////////////////////////////////////////////////////////////////////////// + +// showAssignments opens the editable device-assignment window. func (a *App) showAssignments() { if a == nil { return } - // Do not create another assignment window when one is already open. + // Nur ein Zuordnungsfenster gleichzeitig. if a.assignmentWindow != nil { a.assignmentWindow.Show() return @@ -243,133 +253,395 @@ func (a *App) showAssignments() { cfg := a.clientApplication.Config() devices := a.clientApplication.Devices() - assignments := BuildAssignments(cfg, devices) - unassigned := UnassignedDevices(cfg, devices) - - content := container.NewVBox( - widget.NewLabel("Lokale Zuordnungen"), + editor := NewAssignmentEditor( + cfg, + devices, ) - 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( + window := 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 + window.Resize( + fyne.NewSize(650, 450), + ) - assignmentWindow.SetOnClosed(func() { - // Only clear the reference if this is still the current - // assignment window. - if a.assignmentWindow == assignmentWindow { + 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 } }) - assignmentWindow.SetContent( - container.NewBorder( - nil, - widget.NewButton( - "Schließen", - func() { - assignmentWindow.Close() - }, - ), - nil, - nil, - container.NewVScroll(content), + 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}, ), ) - assignmentWindow.Show() + 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), + ), + ) } -// closeAssignmentWindow closes the currently open device-assignment window. +// 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. // -// 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() { +// 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.assignmentWindow == nil { + if a.assignmentEditor == nil || + !a.assignmentEditor.Dirty() { + + a.finishMainWindowClose() + return } - window := a.assignmentWindow - a.assignmentWindow = nil - window.Close() + 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. // -// Any open child GUI window is closed first. The technical client is then -// shut down. +// 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 } - a.closeAssignmentWindow() + 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. -// -// This method blocks until the GUI application terminates. func (a *App) ShowAndRun() { if a == nil { return @@ -379,9 +651,6 @@ func (a *App) 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 diff --git a/internal/gui/assignment.go b/internal/gui/assignment.go index f54f43a..70c8815 100644 --- a/internal/gui/assignment.go +++ b/internal/gui/assignment.go @@ -7,29 +7,34 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * Beschreibung: - * GUI-unabhängige Aufbereitung der Zuordnung zwischen lokalen virtuellen - * seriellen Schnittstellen und den vom rs2322tcp-Server angebotenen Geräten. + * GUI-unabhängige Aufbereitung und Bearbeitung der Zuordnung zwischen den + * vom rs2322tcp-Server angebotenen Geräten und den lokalen virtuellen + * seriellen Schnittstellen. * * Die Datei enthält bewusst keine Fyne-Abhängigkeiten. * - * Die lokale Client-Konfiguration beschreibt ausschließlich die tatsächlich - * gewünschten Verbindungen. Physisch vorhandene /dev/ttyUSB-Schnittstellen - * außerhalb des von rs2322tcp verwalteten virtuellen Bereichs werden hier - * nicht betrachtet. + * Die Änderungen an der Gerätezuordnung werden zunächst ausschließlich im + * Arbeitsspeicher des Zuordnungsfensters gehalten. Das Speichern in die + * Client-Konfiguration erfolgt beim Schließen des Fensters. * - * Ein Server-Gerät kann daher in drei Zuständen erscheinen: + * Regeln: * - * 1. lokal zugeordnet und aktuell vom Server angeboten - * -> verfügbar / verbunden + * - Ein Server-Gerät kann höchstens einer lokalen Schnittstelle zugeordnet + * werden. * - * 2. lokal zugeordnet, aber momentan nicht vom Server angeboten - * -> nicht verfügbar + * - Eine lokale Schnittstelle kann höchstens einem Server-Gerät zugeordnet + * werden. * - * 3. vom Server angeboten, aber keiner lokalen Schnittstelle zugeordnet - * -> noch nicht verwendet + * - "nicht verbunden" ist ein gültiger Zustand. * - * Nicht verwendete Server-Geräte werden nicht Bestandteil der - * Client-Konfiguration. + * - Ist ein Gerät bereits verbunden, kann es nur auf seinen aktuellen Port + * oder auf "nicht verbunden" gesetzt werden. + * + * - Ist ein Gerät nicht verbunden, kann es auf "nicht verbunden" oder auf + * einen momentan freien konfigurierten virtuellen Port gesetzt werden. + * + * - Wird ein Gerät getrennt, bleibt der virtuelle Port erhalten und wird + * lediglich frei. * ============================================================================ */ package gui @@ -39,6 +44,15 @@ import ( "git.lang-dieter.de/rs2322tcp/internal/transport" ) +/////////////////////////////////////////////////////////////////////////////// +// Constants +/////////////////////////////////////////////////////////////////////////////// + +const ( + // NotConnected is the GUI representation of an unassigned server device. + NotConnected = "nicht verbunden" +) + /////////////////////////////////////////////////////////////////////////////// // Device assignment /////////////////////////////////////////////////////////////////////////////// @@ -49,15 +63,262 @@ import ( // // Available gibt an, ob das konfigurierte Remote-Gerät momentan in der // vom Server gelieferten Geräteliste vorhanden ist. -// -// Available == false bedeutet nicht, dass die Konfiguration ungültig ist. -// Das Gerät kann lediglich momentan nicht vom Server angeboten werden. type DeviceAssignment struct { LocalPort string RemoteDevice string Available bool } +/////////////////////////////////////////////////////////////////////////////// +// Assignment editor +/////////////////////////////////////////////////////////////////////////////// + +// AssignmentEditor enthält den momentan im GUI bearbeiteten +// Zuordnungszustand. +// +// Original enthält den Zustand beim Öffnen des Fensters. +// +// Current enthält den momentan vom Anwender bearbeiteten Zustand. +// +// Der Schlüssel ist jeweils die stabile Remote-Device-ID. Ein leerer +// Port bedeutet "nicht verbunden". +// +// Ports enthält alle konfigurierten virtuellen Ports. Diese Ports bleiben +// auch dann erhalten, wenn sie momentan keinem Remote-Gerät zugeordnet sind. +type AssignmentEditor struct { + Original map[string]string + Current map[string]string + Ports []string +} + +// NewAssignmentEditor erzeugt einen bearbeitbaren Zuordnungszustand aus +// der bestehenden Client-Konfiguration und der aktuellen Geräteliste. +// +// Es werden ausschließlich bereits konfigurierte virtuelle Ports betrachtet. +// Das Anlegen neuer virtueller Ports erfolgt in einem späteren +// Entwicklungsschritt. +func NewAssignmentEditor( + cfg *config.ClientConfig, + devices []transport.RemoteDeviceInfo, +) *AssignmentEditor { + editor := &AssignmentEditor{ + Original: make(map[string]string), + Current: make(map[string]string), + Ports: make([]string, 0), + } + + if cfg != nil { + for _, virtualPort := range cfg.VirtualPorts { + if virtualPort.Port == "" { + continue + } + + if !containsString( + editor.Ports, + virtualPort.Port, + ) { + editor.Ports = append( + editor.Ports, + virtualPort.Port, + ) + } + + if virtualPort.RemoteDevice == "" { + continue + } + + editor.Current[virtualPort.RemoteDevice] = + virtualPort.Port + } + } + + for _, device := range devices { + if _, exists := editor.Current[device.ID]; !exists { + editor.Current[device.ID] = "" + } + } + + editor.Original = cloneAssignments( + editor.Current, + ) + + return editor +} + +// CurrentPort returns the currently selected local port for a server device. +// +// An empty string means "nicht verbunden". +func (e *AssignmentEditor) CurrentPort( + deviceID string, +) string { + if e == nil { + return "" + } + + return e.Current[deviceID] +} + +// Options returns the currently valid Selectbox options for one server +// device. +// +// For an already connected device: +// +// - current port +// - nicht verbunden +// +// For an unconnected device: +// +// - nicht verbunden +// - all currently free configured virtual ports +func (e *AssignmentEditor) Options( + deviceID string, +) []string { + if e == nil { + return []string{ + NotConnected, + } + } + + currentPort := e.Current[deviceID] + + if currentPort != "" { + return []string{ + currentPort, + NotConnected, + } + } + + options := []string{ + NotConnected, + } + + for _, port := range e.Ports { + if e.portUsedByOtherDevice( + port, + deviceID, + ) { + continue + } + + options = append( + options, + port, + ) + } + + return options +} + +// Set changes the current assignment of one server device. +// +// An empty port or NotConnected disconnects the device. +// +// A non-empty port is accepted only when it is a configured virtual port +// and is not currently used by another server device. +// +// The function returns true when the current state was changed. +func (e *AssignmentEditor) Set( + deviceID string, + port string, +) bool { + if e == nil { + return false + } + + if port == NotConnected { + port = "" + } + + currentPort := e.Current[deviceID] + + if currentPort == port { + return false + } + + if port != "" { + if !containsString( + e.Ports, + port, + ) { + return false + } + + if e.portUsedByOtherDevice( + port, + deviceID, + ) { + return false + } + } + + e.Current[deviceID] = port + + return true +} + +// Dirty reports whether the current assignment state differs from the +// state that was present when the editor was opened. +func (e *AssignmentEditor) Dirty() bool { + if e == nil { + return false + } + + if len(e.Original) != len(e.Current) { + return true + } + + for deviceID, originalPort := range e.Original { + if e.Current[deviceID] != originalPort { + return true + } + } + + return false +} + +// ApplyToConfig applies the current assignment state to a copy of the +// supplied client configuration. +// +// Existing virtual ports are always preserved. Only their RemoteDevice +// value is changed. +// +// An empty RemoteDevice means that the virtual port currently has no +// server-device assignment. +// +// The original configuration is not modified. +func (e *AssignmentEditor) ApplyToConfig( + cfg *config.ClientConfig, +) *config.ClientConfig { + if e == nil || cfg == nil { + return nil + } + + result := *cfg + + result.VirtualPorts = make( + []config.VirtualPortConfig, + len(cfg.VirtualPorts), + ) + + for i, virtualPort := range cfg.VirtualPorts { + result.VirtualPorts[i] = virtualPort + + // Every existing virtual port remains in the configuration. + // It becomes unassigned unless the current GUI state assigns it + // to a server device. + result.VirtualPorts[i].RemoteDevice = "" + + for deviceID, port := range e.Current { + if port == virtualPort.Port { + result.VirtualPorts[i].RemoteDevice = deviceID + break + } + } + } + + return &result +} + /////////////////////////////////////////////////////////////////////////////// // Build assignments /////////////////////////////////////////////////////////////////////////////// @@ -67,9 +328,6 @@ type DeviceAssignment struct { // // Es werden ausschließlich die in der Client-Konfiguration vorhandenen // lokalen virtuellen Ports berücksichtigt. -// -// Physisch vorhandene serielle Schnittstellen des lokalen Rechners werden -// hier nicht gesucht und nicht bewertet. func BuildAssignments( cfg *config.ClientConfig, devices []transport.RemoteDeviceInfo, @@ -78,17 +336,24 @@ func BuildAssignments( return nil } - assignments := make([]DeviceAssignment, 0, len(cfg.VirtualPorts)) + assignments := make( + []DeviceAssignment, + 0, + len(cfg.VirtualPorts), + ) for _, virtualPort := range cfg.VirtualPorts { - assignments = append(assignments, DeviceAssignment{ - LocalPort: virtualPort.Port, - RemoteDevice: virtualPort.RemoteDevice, - Available: remoteDeviceExists( - virtualPort.RemoteDevice, - devices, - ), - }) + assignments = append( + assignments, + DeviceAssignment{ + LocalPort: virtualPort.Port, + RemoteDevice: virtualPort.RemoteDevice, + Available: remoteDeviceExists( + virtualPort.RemoteDevice, + devices, + ), + }, + ) } return assignments @@ -100,24 +365,28 @@ func BuildAssignments( // UnassignedDevices liefert die vom Server angebotenen Geräte, die aktuell // keiner lokalen virtuellen Schnittstelle zugeordnet sind. -// -// Diese Geräte werden bewusst nicht in die Client-Konfiguration übernommen. -// Erst wenn der Anwender im GUI eine Zuordnung vornimmt, entsteht daraus -// ein Eintrag in ClientConfig.VirtualPorts. func UnassignedDevices( cfg *config.ClientConfig, devices []transport.RemoteDeviceInfo, ) []transport.RemoteDeviceInfo { - assigned := assignedRemoteDevices(cfg) + assigned := assignedRemoteDevices( + cfg, + ) - unassigned := make([]transport.RemoteDeviceInfo, 0) + unassigned := make( + []transport.RemoteDeviceInfo, + 0, + ) for _, device := range devices { if assigned[device.ID] { continue } - unassigned = append(unassigned, device) + unassigned = append( + unassigned, + device, + ) } return unassigned @@ -127,14 +396,33 @@ func UnassignedDevices( // Internal helpers /////////////////////////////////////////////////////////////////////////////// +// portUsedByOtherDevice prüft, ob ein Port momentan einem anderen +// Server-Gerät zugeordnet ist. +func (e *AssignmentEditor) portUsedByOtherDevice( + port string, + deviceID string, +) bool { + for otherDeviceID, assignedPort := range e.Current { + if otherDeviceID == deviceID { + continue + } + + if assignedPort == port { + return true + } + } + + return false +} + // assignedRemoteDevices erzeugt eine Menge der Remote-Geräte, die bereits // einer lokalen virtuellen Schnittstelle zugeordnet sind. -// -// Die Remote-Device-ID ist der stabile Schlüssel der Zuordnung. func assignedRemoteDevices( cfg *config.ClientConfig, ) map[string]bool { - assigned := make(map[string]bool) + assigned := make( + map[string]bool, + ) if cfg == nil { return assigned @@ -165,3 +453,33 @@ func remoteDeviceExists( return false } + +// cloneAssignments erzeugt eine unabhängige Kopie einer Zuordnungskarte. +func cloneAssignments( + assignments map[string]string, +) map[string]string { + clone := make( + map[string]string, + len(assignments), + ) + + for deviceID, port := range assignments { + clone[deviceID] = port + } + + return clone +} + +// containsString prüft, ob ein String in einer Liste enthalten ist. +func containsString( + values []string, + value string, +) bool { + for _, current := range values { + if current == value { + return true + } + } + + return false +}