From 5663fb6b36647472bebfeb032f29b5387a4cba98 Mon Sep 17 00:00:00 2001 From: Peter Edley Date: Sat, 19 Sep 2026 10:13:23 +0100 Subject: [PATCH] feat: add monitor picker to the GUI settings Enumerate monitors via hyprctl (Hyprland) and present them as a dropdown in the settings window instead of a raw index. The picker is enabled only for the screen source and persists the selected monitor as stream_index. Falls back to a single indexed option when enumeration is unavailable (non-Hyprland). --- internal/capture/monitors.go | 79 +++++++++++++++++++++++++++++++ internal/capture/monitors_test.go | 42 ++++++++++++++++ internal/gui/gui.go | 72 +++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 internal/capture/monitors.go create mode 100644 internal/capture/monitors_test.go diff --git a/internal/capture/monitors.go b/internal/capture/monitors.go new file mode 100644 index 0000000..a4d0895 --- /dev/null +++ b/internal/capture/monitors.go @@ -0,0 +1,79 @@ +// Monitor enumeration for the settings UI. +// +// The screen-capture backend itself selects a monitor by index; this file +// provides a way to list the available monitors so the GUI can present a +// friendly picker instead of a raw index. +// +// Hyprland exposes monitor info via the `hyprctl monitors` command. Other +// Wayland compositors would need a portal-based enumeration; for now we only +// implement the Hyprland path (the primary dev environment) and return a +// clear error elsewhere. + +package capture + +import ( + "encoding/json" + "errors" + "os/exec" +) + +// Monitor describes one capturable output. +type Monitor struct { + Index int // capture StreamIndex to pass to OpenPipeWire + Name string // compositor name, e.g. "eDP-1" + Width int + Height int + Primary bool +} + +// ErrNoMonitors is returned when monitor enumeration is unsupported or fails. +var ErrNoMonitors = errors.New("capture: monitor enumeration unavailable on this compositor") + +// ListMonitors returns the available monitors for the GUI picker. +func ListMonitors() ([]Monitor, error) { + if hyprctlAvailable() { + return hyprctlMonitors() + } + return nil, ErrNoMonitors +} + +// hyprctlAvailable reports whether the Hyprland monitor command exists. +func hyprctlAvailable() bool { + _, err := exec.LookPath("hyprctl") + return err == nil +} + +// hyprctlMonitor is the JSON shape emitted by `hyprctl monitors -j`. +type hyprctlMonitor struct { + ID int `json:"id"` + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` + Description string `json:"description"` + Focused bool `json:"focused"` +} + +// hyprctlMonitors lists monitors via the Hyprland IPC command. +func hyprctlMonitors() ([]Monitor, error) { + out, err := exec.Command("hyprctl", "monitors", "-j").Output() + if err != nil { + return nil, ErrNoMonitors + } + + var raw []hyprctlMonitor + if err := json.Unmarshal(out, &raw); err != nil { + return nil, ErrNoMonitors + } + + monitors := make([]Monitor, 0, len(raw)) + for _, m := range raw { + monitors = append(monitors, Monitor{ + Index: m.ID, + Name: m.Name, + Width: m.Width, + Height: m.Height, + Primary: m.Focused, + }) + } + return monitors, nil +} diff --git a/internal/capture/monitors_test.go b/internal/capture/monitors_test.go new file mode 100644 index 0000000..cb2a14d --- /dev/null +++ b/internal/capture/monitors_test.go @@ -0,0 +1,42 @@ +package capture + +import ( + "encoding/json" + "testing" +) + +// TestHyprctlMonitors parses a representative `hyprctl monitors -j` payload. +func TestHyprctlMonitors(t *testing.T) { + payload := `[ + { + "id": 0, + "name": "eDP-1", + "description": "BOE 0x094C", + "width": 1920, + "height": 1200, + "focused": true + }, + { + "id": 1, + "name": "HDMI-A-1", + "description": "Samsung", + "width": 2560, + "height": 1440, + "focused": false + } +]` + + var raw []hyprctlMonitor + if err := json.Unmarshal([]byte(payload), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(raw) != 2 { + t.Fatalf("got %d monitors, want 2", len(raw)) + } + if raw[0].ID != 0 || raw[0].Name != "eDP-1" || raw[0].Width != 1920 { + t.Errorf("monitor 0 wrong: %+v", raw[0]) + } + if raw[1].ID != 1 || raw[1].Focused { + t.Errorf("monitor 1 wrong: %+v", raw[1]) + } +} diff --git a/internal/gui/gui.go b/internal/gui/gui.go index bf72301..a83465b 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -8,6 +8,7 @@ package gui import ( "errors" + "fmt" "image/color" "log" "strconv" @@ -22,6 +23,7 @@ import ( "fyne.io/systray" "teleportfling/assets" + "teleportfling/internal/capture" "teleportfling/internal/config" "teleportfling/internal/flinger" ) @@ -63,6 +65,8 @@ type App struct { audioChk *widget.Check announceChk *widget.Check srcSel *widget.Select + monSel *widget.Select + monitors []capture.Monitor statsDone chan struct{} } @@ -133,6 +137,7 @@ func (g *App) buildUI() { // Source. g.srcSel = widget.NewSelect([]string{"screen", "pattern"}, func(string) {}) g.srcSel.SetSelected(g.cfg.Source) + g.setupMonitorPicker() // Quality. g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, func(string) {}) @@ -167,6 +172,7 @@ func (g *App) buildUI() { {Text: "Name", Widget: g.nameEnt}, {Text: "Port", Widget: g.portEnt}, {Text: "Source", Widget: g.srcSel}, + {Text: "Monitor", Widget: g.monSel}, {Text: "Quality", Widget: g.qualitySel}, {Text: "Frame rate", Widget: g.fpsSel}, {Text: "", Widget: g.audioChk}, @@ -185,6 +191,56 @@ func (g *App) buildUI() { g.win.Resize(fyne.NewSize(380, 0)) } +// setupMonitorPicker populates the monitor dropdown and ties its visibility +// to the source selector. When monitor enumeration is unavailable (non- +// Hyprland), it falls back to a raw index entry driven by the saved config. +func (g *App) setupMonitorPicker() { + // Populate monitor names for the picker. + g.monitors, _ = capture.ListMonitors() + names := make([]string, 0, len(g.monitors)) + for _, m := range g.monitors { + names = append(names, fmt.Sprintf("%s (%dx%d)", m.Name, m.Width, m.Height)) + } + + // If we could not enumerate, present the saved index as a single option. + if len(names) == 0 { + names = []string{fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)} + g.monitors = []capture.Monitor{{Index: g.cfg.StreamIndex, Name: fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}} + } + + g.monSel = widget.NewSelect(names, func(string) {}) + if len(g.monitors) > 0 { + // Preselect the configured index if it's within range. + for i, m := range g.monitors { + if m.Index == g.cfg.StreamIndex { + g.monSel.SetSelectedIndex(i) + break + } + } + } + + // Monitor picker only applies to the "screen" source. + g.srcSel.OnChanged = func(string) { + g.monSel.Disable() + if g.srcSel.Selected == "screen" { + g.monSel.Enable() + } + } + g.monSel.Disable() + if g.cfg.Source == "screen" { + g.monSel.Enable() + } +} + +// selectedMonitorIndex returns the monitor index chosen in the picker, or the +// saved config value when the picker is unavailable/disabled. +func (g *App) selectedMonitorIndex() int { + if g.monSel != nil && g.monSel.SelectedIndex() >= 0 && g.monSel.SelectedIndex() < len(g.monitors) { + return g.monitors[g.monSel.SelectedIndex()].Index + } + return g.cfg.StreamIndex +} + // toggleStream starts or stops the engine based on current UI state. func (g *App) toggleStream() { if g.lock { @@ -211,7 +267,7 @@ func (g *App) start() { FPS: fps, Source: g.srcSel.Selected, Audio: g.audioChk.Checked, - StreamIndex: g.cfg.StreamIndex, + StreamIndex: g.selectedMonitorIndex(), Announce: &announce, } if g.configPath != "" { @@ -258,7 +314,16 @@ func (g *App) watchStats() { // Fyne UI calls must run on the main thread. fyne.Do(func() { g.statusLab.SetText(formatStatus(st)) - g.setTrayState(true, "TeleportFling · "+frames+" frames, "+dropped+" dropped") + if st.Err != nil { + g.statusLab.Importance = widget.DangerImportance + } else { + g.statusLab.Importance = widget.SuccessImportance + } + tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped" + if st.Err != nil { + tip += " · error" + } + g.setTrayState(true, tip) }) case <-done: return @@ -273,6 +338,9 @@ func formatStatus(st flinger.Status) string { if st.Dropped > 0 { base += " · " + itoa(int(st.Dropped)) + " dropped" } + if st.Err != nil { + base += "\nError: " + st.Err.Error() + } return base }