Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions gui_interface/fyne-ui/engine/mercury_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions gui_interface/fyne-ui/engine/mercury_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions gui_interface/fyne-ui/hover_tooltip_button.go
Original file line number Diff line number Diff line change
@@ -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()
}
19 changes: 19 additions & 0 deletions gui_interface/fyne-ui/link_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions gui_interface/fyne-ui/link_engine_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
136 changes: 134 additions & 2 deletions gui_interface/fyne-ui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"os/signal"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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")}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}
})
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down
Loading
Loading