diff --git a/gui_interface/fyne-ui/engine/mercury_bridge.c b/gui_interface/fyne-ui/engine/mercury_bridge.c index 251c5ab5..d08a5b89 100644 --- a/gui_interface/fyne-ui/engine/mercury_bridge.c +++ b/gui_interface/fyne-ui/engine/mercury_bridge.c @@ -182,3 +182,14 @@ void mercury_ui_get_tcp_ports(int *arq_base_port, int *broadcast_port) { ui_comm_get_tcp_ports(arq_base_port, broadcast_port); } + +void mercury_ui_get_version(char *version, int version_len, + char *git_hash, int git_hash_len) +{ + if (version && version_len > 0) { + snprintf(version, (size_t)version_len, "%s", MERCURY_VERSION); + } + if (git_hash && git_hash_len > 0) { + snprintf(git_hash, (size_t)git_hash_len, "%s", GIT_HASH); + } +} diff --git a/gui_interface/fyne-ui/engine/mercury_bridge.h b/gui_interface/fyne-ui/engine/mercury_bridge.h index 02aed13a..f2bb67ed 100644 --- a/gui_interface/fyne-ui/engine/mercury_bridge.h +++ b/gui_interface/fyne-ui/engine/mercury_bridge.h @@ -82,6 +82,10 @@ void mercury_ui_set_waterfall(bool enabled); /* Read the TNC TCP ports the engine listens on. */ void mercury_ui_get_tcp_ports(int *arq_base_port, int *broadcast_port); +/* Copy the release version string and git hash into the provided buffers. */ +void mercury_ui_get_version(char *version, int version_len, + char *git_hash, int git_hash_len); + #ifdef __cplusplus } #endif diff --git a/gui_interface/fyne-ui/hover_tooltip_button.go b/gui_interface/fyne-ui/hover_tooltip_button.go new file mode 100644 index 00000000..ae52906c --- /dev/null +++ b/gui_interface/fyne-ui/hover_tooltip_button.go @@ -0,0 +1,114 @@ +package main + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" +) + +// hoverTooltipButton wraps a *widget.Button and shows a tooltip popup while the +// pointer hovers over it. +// +// A plain wrapper does not work for a disabled button: widget.Button itself +// implements desktop.Hoverable, so it shadows the wrapper in the hover +// hit-test and the tooltip never fires. Instead an invisible hover-catcher is +// stacked on top of the button whenever it is disabled; when the button is +// enabled the catcher is hidden so the button stays clickable and no tooltip +// is shown. +type hoverTooltipButton struct { + widget.BaseWidget + btn *widget.Button + overlay *hoverCatcher + tooltip func() string + canvas fyne.Canvas + popup *widget.PopUp +} + +func newHoverTooltipButton(btn *widget.Button, canvas fyne.Canvas, tooltip func() string) *hoverTooltipButton { + h := &hoverTooltipButton{btn: btn, canvas: canvas, tooltip: tooltip} + h.ExtendBaseWidget(h) + h.overlay = newHoverCatcher( + func(pos fyne.Position) { h.show(pos) }, + func() { h.hide() }, + ) + if btn.Disabled() { + h.overlay.Show() + } else { + h.overlay.Hide() + } + return h +} + +func (h *hoverTooltipButton) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(container.NewStack(h.btn, h.overlay)) +} + +func (h *hoverTooltipButton) Enable() { + h.btn.Enable() + h.overlay.Hide() + h.hide() +} + +func (h *hoverTooltipButton) Disable() { + h.btn.Disable() + h.overlay.Show() + h.hide() +} + +func (h *hoverTooltipButton) Disabled() bool { + return h.btn.Disabled() +} + +func (h *hoverTooltipButton) show(pos fyne.Position) { + text := h.tooltip() + if text == "" { + h.hide() + return + } + if h.popup != nil { + h.popup.Hide() + } + label := widget.NewLabel(text) + h.popup = widget.NewPopUp(label, h.canvas) + h.popup.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+24)) +} + +func (h *hoverTooltipButton) hide() { + if h.popup != nil { + h.popup.Hide() + h.popup = nil + } +} + +// hoverCatcher is an invisible widget that only reports hover enter/leave. +type hoverCatcher struct { + widget.BaseWidget + onIn func(fyne.Position) + onOut func() +} + +func newHoverCatcher(onIn func(fyne.Position), onOut func()) *hoverCatcher { + c := &hoverCatcher{onIn: onIn, onOut: onOut} + c.ExtendBaseWidget(c) + return c +} + +func (c *hoverCatcher) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent)) +} + +func (c *hoverCatcher) MouseIn(ev *desktop.MouseEvent) { + c.onIn(ev.AbsolutePosition) +} + +func (c *hoverCatcher) MouseMoved(ev *desktop.MouseEvent) { + c.onIn(ev.AbsolutePosition) +} + +func (c *hoverCatcher) MouseOut() { + c.onOut() +} diff --git a/gui_interface/fyne-ui/link_engine.go b/gui_interface/fyne-ui/link_engine.go index e1fed9ae..f9ad0bed 100644 --- a/gui_interface/fyne-ui/link_engine.go +++ b/gui_interface/fyne-ui/link_engine.go @@ -284,6 +284,25 @@ func (l *engineLink) TCPPorts() (arqBase, broadcast int) { return int(a), int(b) } +// Version returns the engine's release version and git hash. +func (l *engineLink) Version() (version, gitHash string) { + cv := make([]byte, 64) + cg := make([]byte, 64) + C.mercury_ui_get_version((*C.char)(unsafe.Pointer(&cv[0])), C.int(len(cv)), + (*C.char)(unsafe.Pointer(&cg[0])), C.int(len(cg))) + return cString(cv), cString(cg) +} + +// cString converts a C buffer to a Go string, truncating at the first NUL. +func cString(b []byte) string { + for i, c := range b { + if c == 0 { + return string(b[:i]) + } + } + return string(b) +} + func (l *engineLink) Close() {} // statusFromC converts the engine's status struct into the UI's own type. This diff --git a/gui_interface/fyne-ui/link_engine_stub.go b/gui_interface/fyne-ui/link_engine_stub.go index 8a2579ac..7c74e654 100644 --- a/gui_interface/fyne-ui/link_engine_stub.go +++ b/gui_interface/fyne-ui/link_engine_stub.go @@ -35,3 +35,7 @@ func (l *engineLink) SetWaterfall(enabled bool) {} func (l *engineLink) TCPPorts() (arqBase, broadcast int) { return 8300, 8100 } + +func (l *engineLink) Version() (version, gitHash string) { + return "unknown", "unknown000" +} diff --git a/gui_interface/fyne-ui/main.go b/gui_interface/fyne-ui/main.go index f3bb9452..d51ede7f 100644 --- a/gui_interface/fyne-ui/main.go +++ b/gui_interface/fyne-ui/main.go @@ -13,6 +13,7 @@ import ( "os" "os/signal" "path/filepath" + "reflect" "runtime" "strconv" "strings" @@ -26,6 +27,7 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" ) @@ -100,6 +102,7 @@ type uiBindings struct { // canvas texts for compact telemetry display bitrateText *canvas.Text snrText *canvas.Text + snrRowLabel *canvas.Text directionText *canvas.Text userCallsText *canvas.Text destCallsText *canvas.Text @@ -211,6 +214,80 @@ const ( waterfallSplitOffset = 0.60 ) +const ( + windowWidthKey = "window.width" + windowHeightKey = "window.height" + windowXKey = "window.x" + windowYKey = "window.y" + windowPosSetKey = "window.positionSaved" + + // defaultWindowWidth/Height match the previous hard-coded launch size and + // are used until the operator has resized the window once. + defaultWindowWidth = 1280 + defaultWindowHeight = 780 +) + +// saveWindowGeometry records the current window size and position in the app +// preferences so the next launch can restore them. +func saveWindowGeometry(win fyne.Window, prefs fyne.Preferences) { + if prefs == nil { + return + } + size := win.Canvas().Size() + if size.Width > 0 && size.Height > 0 { + prefs.SetFloat(windowWidthKey, float64(size.Width)) + prefs.SetFloat(windowHeightKey, float64(size.Height)) + } + if x, y, ok := windowPosition(win); ok { + prefs.SetInt(windowXKey, x) + prefs.SetInt(windowYKey, y) + prefs.SetBool(windowPosSetKey, true) + } +} + +// restoreWindowGeometry applies a previously saved window size and position. +func restoreWindowGeometry(win fyne.Window, prefs fyne.Preferences) { + if prefs == nil { + return + } + width := prefs.FloatWithFallback(windowWidthKey, defaultWindowWidth) + height := prefs.FloatWithFallback(windowHeightKey, defaultWindowHeight) + if width <= 0 { + width = defaultWindowWidth + } + if height <= 0 { + height = defaultWindowHeight + } + win.Resize(fyne.NewSize(float32(width), float32(height))) + + if prefs.BoolWithFallback(windowPosSetKey, false) { + if dw, ok := win.(desktop.Window); ok { + dw.RequestPosition(prefs.Int(windowXKey), prefs.Int(windowYKey)) + } + } +} + +// windowPosition reads the native window position from the desktop driver's +// concrete window. Fyne's public Window interface exposes no position getter, +// so this reaches into the (pinned) driver struct via reflection. +func windowPosition(win fyne.Window) (int, int, bool) { + v := reflect.ValueOf(win) + if v.Kind() != reflect.Ptr { + return 0, 0, false + } + v = v.Elem() + if v.Kind() != reflect.Struct { + return 0, 0, false + } + xField := v.FieldByName("xpos") + yField := v.FieldByName("ypos") + if !xField.IsValid() || !yField.IsValid() || + xField.Kind() != reflect.Int || yField.Kind() != reflect.Int { + return 0, 0, false + } + return int(xField.Int()), int(yField.Int()), true +} + func main() { // Announce the version on the terminal, just like the standalone daemon. mercuryPrintVersion() @@ -225,7 +302,7 @@ func main() { // Fyne's preferences/storage have a unique identity instead of warning. myApp := app.NewWithID("org.rhizomatica.mercury") myWindow := myApp.NewWindow("Mercury Modem") - myWindow.Resize(fyne.NewSize(1280, 780)) + restoreWindowGeometry(myWindow, myApp.Preferences()) state := &appState{wsScheme: "ws", wsHost: "127.0.0.1", wsPort: "10000", waterfallPalette: myApp.Preferences().StringWithFallback("waterfallPalette", "blackblue")} @@ -376,6 +453,11 @@ func main() { snrText.TextSize = 14 bindings.snrText = snrText + // SNR row in the Telemetry card, shown only when the waterfall (which + // carries its own SNR overlay) is disabled. + snrRowLabel := canvas.NewText("SNR", color.NRGBA{R: 0xAA, G: 0xAA, B: 0xAA, A: 0xFF}) + bindings.snrRowLabel = snrRowLabel + directionText := canvas.NewText("--", color.NRGBA{R: 0xDD, G: 0xDD, B: 0xDD, A: 0xFF}) directionText.TextSize = 13 bindings.directionText = directionText @@ -518,6 +600,7 @@ func main() { } if bindings.snrText != nil { bindings.snrText.Text = fmt.Sprintf("%.1f dB", telemetry.SNR) + bindings.snrText.Color = waterfallSNRColor(telemetry.SNR) bindings.snrText.Refresh() } if bindings.directionText != nil { @@ -573,6 +656,17 @@ func main() { bindings.waterfallCard.Hide() } } + // The waterfall carries its own SNR overlay, so the Telemetry SNR row + // is only shown when the waterfall is disabled (and thus hidden). + if bindings.snrRowLabel != nil && bindings.snrText != nil { + if telemetry.Waterfall { + bindings.snrRowLabel.Hide() + bindings.snrText.Hide() + } else { + bindings.snrRowLabel.Show() + bindings.snrText.Show() + } + } }) } @@ -852,6 +946,7 @@ func main() { // compact telemetry layout matching screenshot: left labels small, right values small-bold telemetryGrid := container.NewGridWithColumns(2, canvas.NewText("Bitrate", color.NRGBA{R: 0xAA, G: 0xAA, B: 0xAA, A: 0xFF}), bindings.bitrateText, + bindings.snrRowLabel, bindings.snrText, canvas.NewText("Direction", color.NRGBA{R: 0xAA, G: 0xAA, B: 0xAA, A: 0xFF}), bindings.directionText, canvas.NewText("My callsign", color.NRGBA{R: 0xAA, G: 0xAA, B: 0xAA, A: 0xFF}), bindings.userCallsText, canvas.NewText("Target callsign", color.NRGBA{R: 0xAA, G: 0xAA, B: 0xAA, A: 0xFF}), bindings.destCallsText, @@ -1086,7 +1181,40 @@ func main() { radioConfigItem := fyne.NewMenuItem("Radio Config", showRadioDialog) waterfallItem := fyne.NewMenuItem("Waterfall", showWaterfallDialog) configMenu := fyne.NewMenu("Settings", soundcardsItem, radioConfigItem, waterfallItem) - myWindow.SetMainMenu(fyne.NewMainMenu(remoteMenu, configMenu)) + + openExternalURL := func(raw string) { + u, err := url.Parse(raw) + if err != nil { + appendLog(fmt.Sprintf("Invalid URL: %v\n", err)) + return + } + if err := myApp.OpenURL(u); err != nil { + appendLog(fmt.Sprintf("Failed to open %s: %v\n", raw, err)) + } + } + + showVersionDialog := func() { + version, gitHash := "unknown", "unknown000" + state.mu.RLock() + engLink, isEngine := state.link.(*engineLink) + state.mu.RUnlock() + if isEngine { + version, gitHash = engLink.Version() + } + dialog.ShowInformation("Version", + fmt.Sprintf("Mercury Version: %s\nGit Commit: %s", version, gitHash), myWindow) + } + + versionItem := fyne.NewMenuItem("Version", showVersionDialog) + supportItem := fyne.NewMenuItem("Support Us", func() { + openExternalURL("https://www.paypal.com/donate/?hosted_button_id=EKY4LRAH64Z9S") + }) + mailingItem := fyne.NewMenuItem("Join our mailing list", func() { + openExternalURL("https://lists.riseup.net/www/info/hermes-general") + }) + helpMenu := fyne.NewMenu("Help", versionItem, supportItem, mailingItem) + + myWindow.SetMainMenu(fyne.NewMainMenu(remoteMenu, configMenu, helpMenu)) // Single idempotent teardown, used by both the window-close handler and the // signal handler. It runs entirely OFF the GL/main thread: SetOnClosed is @@ -1100,6 +1228,10 @@ func main() { // the engine unkeys the radio and flushes on the way out. var shutdownOnce sync.Once shutdown := func() { + // Remember where and how large the window is so the next launch opens + // in the same place. Read before teardown: the window is still alive + // when SetOnClosed runs. + saveWindowGeometry(myWindow, myApp.Preferences()) shutdownOnce.Do(func() { go func() { go func() { diff --git a/gui_interface/fyne-ui/mercury.go b/gui_interface/fyne-ui/mercury.go index 71fb52bd..c177f6fc 100644 --- a/gui_interface/fyne-ui/mercury.go +++ b/gui_interface/fyne-ui/mercury.go @@ -1,8 +1,13 @@ //go:build mercury_embedded // CGo bridge to the Mercury C engine. -// mercury_bridge.c is compiled alongside this file by CGo. -// Pre-built Mercury objects live in libmercury_core.a. +// +// mercury_bridge.c is NOT compiled by CGo: the Makefile's libmercury_core.a +// rule compiles it (engine/mercury_bridge.c -> mercury_bridge.o) with the +// engine's own CFLAGS — including -DGIT_HASH — and archives it. CGo only +// links that archive (-lmercury_core), so the symbols below (and the git hash +// mercury_ui_get_version reports) come from the Makefile-built object, not +// from these #cgo CFLAGS. package main diff --git a/gui_interface/fyne-ui/mercury_chat_window.go b/gui_interface/fyne-ui/mercury_chat_window.go index 0d34c656..1fcd0b77 100644 --- a/gui_interface/fyne-ui/mercury_chat_window.go +++ b/gui_interface/fyne-ui/mercury_chat_window.go @@ -65,8 +65,13 @@ type chatWindow struct { arqAbort *widget.Button sendARQ *widget.Button sendBcast *widget.Button + sendBcastWrap *hoverTooltipButton arqMsg *widget.Entry bcastMsg *widget.Entry + + // bcastDisabledReason is shown as a hover tooltip on the Broadcast + // message button while it is disabled (e.g. during an ARQ session). + bcastDisabledReason string } func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, broadcastPort int) { @@ -114,12 +119,15 @@ func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, bro cw.sendARQ = widget.NewButton("Send message", cw.onSendARQ) cw.sendARQ.Disable() cw.sendBcast = widget.NewButton("Broadcast message", cw.onSendBroadcast) - cw.sendBcast.Disable() + cw.sendBcastWrap = newHoverTooltipButton(cw.sendBcast, cw.win.Canvas(), func() string { + return cw.bcastDisabledReason + }) + cw.sendBcastWrap.Disable() cfgForm := widget.NewForm( &widget.FormItem{Text: "My Callsign", Widget: cw.myCall}, &widget.FormItem{Text: "Target Callsign", Widget: cw.target}, - &widget.FormItem{Text: "IP", Widget: cw.ip}, + &widget.FormItem{Text: "IP/Host", Widget: cw.ip}, &widget.FormItem{Text: "ARQ Port", Widget: cw.arqPort}, &widget.FormItem{Text: "Broadcast Port", Widget: cw.bcastPort}, ) @@ -134,7 +142,7 @@ func (cw *chatWindow) build(app fyne.App, telemetry telemetryState, arqPort, bro widget.NewSeparator(), cw.arqMsg, cw.sendARQ, widget.NewSeparator(), - cw.bcastMsg, cw.sendBcast, + cw.bcastMsg, cw.sendBcastWrap, ) left := container.NewVBox(controls, layout.NewSpacer()) @@ -230,7 +238,7 @@ func (cw *chatWindow) setTCP(on bool) { cw.connectBtn.Disable() cw.disconnectBtn.Enable() cw.arqConnect.Enable() - cw.sendBcast.Enable() + cw.sendBcastWrap.Enable() } else { cw.connectBtn.Enable() cw.disconnectBtn.Disable() @@ -238,7 +246,7 @@ func (cw *chatWindow) setTCP(on bool) { cw.arqDisconnect.Disable() cw.arqAbort.Disable() cw.sendARQ.Disable() - cw.sendBcast.Disable() + cw.sendBcastWrap.Disable() } }) } @@ -250,11 +258,17 @@ func (cw *chatWindow) setARQ(on bool) { cw.arqDisconnect.Enable() cw.arqAbort.Enable() cw.sendARQ.Enable() + cw.sendBcastWrap.Disable() + cw.bcastDisabledReason = "Broadcast is disabled while an ARQ session is active." } else { cw.arqConnect.Enable() cw.arqDisconnect.Disable() cw.arqAbort.Disable() cw.sendARQ.Disable() + cw.bcastDisabledReason = "" + if cw.mc != nil && cw.mc.IsConnected() { + cw.sendBcastWrap.Enable() + } } }) } @@ -331,10 +345,12 @@ func (cw *chatWindow) onARQConnect() { if mc == nil || !mc.IsConnected() { return } + src := cw.myCall.Text + dst := cw.target.Text cw.arqConnect.Disable() - cw.logMsg("Connecting ARQ: %s -> %s", cw.myCall.Text, cw.target.Text) + cw.logMsg("Connecting ARQ: %s -> %s", src, dst) go func() { - if err := mc.ConnectARQ(); err != nil { + if err := mc.ConnectARQWith(src, dst); err != nil { cw.logMsg("ARQ connect: %v", err) // Only re-enable the button if the modem is still up: a // disconnect mid-handshake leaves cw.mc nil. diff --git a/gui_interface/mercury-client/client/client.go b/gui_interface/mercury-client/client/client.go index 28a05afb..55ed882b 100644 --- a/gui_interface/mercury-client/client/client.go +++ b/gui_interface/mercury-client/client/client.go @@ -154,13 +154,21 @@ func (c *Client) RemoteCallsign() string { // blocks until the session is established or fails, so callers should run it // in a goroutine. func (c *Client) ConnectARQ() error { + return c.ConnectARQWith(c.cfg.MyCallsign, c.cfg.TargetCallsign) +} + +// ConnectARQWith starts an ARQ session using explicit source and target +// callsigns, overriding whatever was captured in the config at New() time. +// It blocks until the session is established or fails, so callers should run +// it in a goroutine. +func (c *Client) ConnectARQWith(src, dst string) error { c.mu.Lock() mc := c.modem c.mu.Unlock() if mc == nil || !mc.IsConnected() { return fmt.Errorf("not connected to modem") } - return mc.ConnectARQ(c.cfg.MyCallsign, c.cfg.TargetCallsign) + return mc.ConnectARQ(src, dst) } // DisconnectARQ sends a clean DISCONNECT to the remote station. diff --git a/gui_interface/mercury-client/modem/modem.go b/gui_interface/mercury-client/modem/modem.go index d56b49b4..bb37760e 100644 --- a/gui_interface/mercury-client/modem/modem.go +++ b/gui_interface/mercury-client/modem/modem.go @@ -117,9 +117,14 @@ func (mc *ModemClient) Connect() (err error) { // Runs last: release the mutex, then flush the collected log lines. defer func() { + quit := mc.quit mc.mu.Unlock() for _, line := range logLines { - mc.LogCh <- line + select { + case mc.LogCh <- line: + case <-quit: + return + } } }() @@ -192,11 +197,16 @@ func (mc *ModemClient) Connect() (err error) { func (mc *ModemClient) SendCommand(cmd string) error { mc.mu.Lock() conn := mc.ARQControlConn + quit := mc.quit mc.mu.Unlock() if conn == nil { return fmt.Errorf("not connected") } - mc.LogCh <- fmt.Sprintf("TX Command: %s", cmd) + select { + case mc.LogCh <- fmt.Sprintf("TX Command: %s", cmd): + case <-quit: + return fmt.Errorf("disconnected") + } _, err := conn.Write([]byte(cmd + "\r")) return err } diff --git a/gui_interface/ui_communication.c b/gui_interface/ui_communication.c index 8bdc075a..2f4811a6 100644 --- a/gui_interface/ui_communication.c +++ b/gui_interface/ui_communication.c @@ -752,6 +752,20 @@ void ui_comm_shutdown(ui_ctx_t *ctx) { g_ui_ctx = NULL; + // Stop and join the spectrum publisher thread before the websocket server + // goes down: it was made joinable for the runtime disable path, so leave + // no joinable thread unjoined at teardown. Take cfg_mutex so this cannot + // race ui_comm_set_waterfall()'s disable path, which joins the same thread + // under the same lock — two pthread_join calls on one tid is undefined + // behaviour. This closes the window the NOTE below describes. + pthread_mutex_lock(&ctx->cfg_mutex); + atomic_store_explicit(&ctx->spec_run, false, memory_order_relaxed); + if (ctx->spec_tid != 0) { + pthread_join(ctx->spec_tid, NULL); + ctx->spec_tid = 0; + } + pthread_mutex_unlock(&ctx->cfg_mutex); + ws_shutdown(&ctx->ws); // NOTE: cfg_mutex is deliberately NOT destroyed. ui_comm_set_waterfall // may have already read g_ui_ctx before the NULL above and could still