initial commit

This commit is contained in:
Levi Neely 2026-06-24 13:43:01 +02:00
commit c64cf63125
4 changed files with 574 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/wmon

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module wmon
go 1.26.2
require github.com/gotk3/gotk3 v0.6.3

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/gotk3/gotk3 v0.6.3 h1:+Ke4WkM1TQUNOlM2TZH6szqknqo+zNbX3BZWVXjSHYw=
github.com/gotk3/gotk3 v0.6.3/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/gotk3/gotk3 v0.6.4 h1:5ur/PRr86PwCG8eSj98D1eXvhrNNK6GILS2zq779dCg=
github.com/gotk3/gotk3 v0.6.4/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=

564
main.go Normal file
View File

@ -0,0 +1,564 @@
package main
import (
"encoding/json"
"fmt"
"log"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
type Monitor struct {
Name string
Model string
Width int
Height int
X int
Y int
Enabled bool
Refresh float64
Scale float64
}
type App struct {
window *gtk.Window
canvas *gtk.DrawingArea
monitors []Monitor
dragging int // index of monitor being dragged, -1 if none
dragOffX float64
dragOffY float64
scale float64 // view scale (pixels per monitor-pixel)
offsetX float64 // view pan offset
offsetY float64
panning bool
panStartX float64
panStartY float64
panOffX float64
panOffY float64
layoutSet bool
}
func parseWlrRandr() ([]Monitor, error) {
out, err := exec.Command("wlr-randr").Output()
if err != nil {
return nil, err
}
var monitors []Monitor
var cur *Monitor
lines := strings.Split(string(out), "\n")
nameRe := regexp.MustCompile(`^(\S+)\s+"(.+)"`)
modeRe := regexp.MustCompile(`^\s+(\d+)x(\d+)\s+px,\s+([\d.]+)\s+Hz\s*(.*)`)
posRe := regexp.MustCompile(`^\s+Position:\s+(\d+),(\d+)`)
enabledRe := regexp.MustCompile(`^\s+Enabled:\s+(\w+)`)
scaleRe := regexp.MustCompile(`^\s+Scale:\s+([\d.]+)`)
for _, line := range lines {
if m := nameRe.FindStringSubmatch(line); m != nil {
if cur != nil {
monitors = append(monitors, *cur)
}
cur = &Monitor{Name: m[1], Model: m[2], Scale: 1.0}
} else if cur != nil {
if m := enabledRe.FindStringSubmatch(line); m != nil {
cur.Enabled = m[1] == "yes"
} else if m := modeRe.FindStringSubmatch(line); m != nil {
if strings.Contains(m[4], "current") {
cur.Width, _ = strconv.Atoi(m[1])
cur.Height, _ = strconv.Atoi(m[2])
cur.Refresh, _ = strconv.ParseFloat(m[3], 64)
}
} else if m := posRe.FindStringSubmatch(line); m != nil {
cur.X, _ = strconv.Atoi(m[1])
cur.Y, _ = strconv.Atoi(m[2])
} else if m := scaleRe.FindStringSubmatch(line); m != nil {
cur.Scale, _ = strconv.ParseFloat(m[1], 64)
}
}
}
if cur != nil {
monitors = append(monitors, *cur)
}
return monitors, nil
}
func (app *App) fitToCanvas() {
alloc := app.canvas.GetAllocation()
canvasW := float64(alloc.GetWidth())
canvasH := float64(alloc.GetHeight())
if canvasW < 10 || canvasH < 10 {
canvasW, canvasH = 900, 600
}
// Find bounding box of all monitors (enabled ones for position, disabled get parked)
minX, minY := math.MaxFloat64, math.MaxFloat64
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
hasEnabled := false
for _, m := range app.monitors {
if !m.Enabled {
continue
}
hasEnabled = true
if float64(m.X) < minX {
minX = float64(m.X)
}
if float64(m.Y) < minY {
minY = float64(m.Y)
}
if float64(m.X+m.Width) > maxX {
maxX = float64(m.X + m.Width)
}
if float64(m.Y+m.Height) > maxY {
maxY = float64(m.Y + m.Height)
}
}
if !hasEnabled {
app.scale = 0.15
app.offsetX = canvasW / 2
app.offsetY = canvasH / 3
return
}
totalW := maxX - minX
totalH := maxY - minY
padding := 80.0
scaleX := (canvasW - padding) / totalW
scaleY := (canvasH*0.7 - padding) / totalH
app.scale = math.Min(scaleX, scaleY)
// Center
cx := (minX + maxX) / 2
cy := (minY + maxY) / 2
app.offsetX = canvasW/2 - cx*app.scale
app.offsetY = canvasH*0.35 - cy*app.scale
}
// Convert monitor coords to canvas coords
func (app *App) monToCanvas(mx, my float64) (float64, float64) {
return mx*app.scale + app.offsetX, my*app.scale + app.offsetY
}
// Convert canvas coords to monitor coords
func (app *App) canvasToMon(cx, cy float64) (float64, float64) {
return (cx - app.offsetX) / app.scale, (cy - app.offsetY) / app.scale
}
func (app *App) monRect(m Monitor) (float64, float64, float64, float64) {
x, y := app.monToCanvas(float64(m.X), float64(m.Y))
w := float64(m.Width) * app.scale
h := float64(m.Height) * app.scale
return x, y, w, h
}
func (app *App) inactiveY() float64 {
alloc := app.canvas.GetAllocation()
return float64(alloc.GetHeight()) * 0.78
}
func (app *App) inactiveRect(idx int) (float64, float64, float64, float64) {
inactiveIdx := 0
for i := 0; i < idx; i++ {
if !app.monitors[i].Enabled {
inactiveIdx++
}
}
w := 150.0
h := 100.0
x := 20.0 + float64(inactiveIdx)*(w+20)
y := app.inactiveY() + 30
return x, y, w, h
}
func (app *App) hitTest(cx, cy float64) int {
for i := len(app.monitors) - 1; i >= 0; i-- {
m := app.monitors[i]
var x, y, w, h float64
if !m.Enabled {
x, y, w, h = app.inactiveRect(i)
} else {
x, y, w, h = app.monRect(m)
}
if cx >= x && cx <= x+w && cy >= y && cy <= y+h {
return i
}
}
return -1
}
func (app *App) draw(da *gtk.DrawingArea, cr *cairo.Context) {
alloc := da.GetAllocation()
canvasW := float64(alloc.GetWidth())
canvasH := float64(alloc.GetHeight())
if !app.layoutSet {
app.fitToCanvas()
app.layoutSet = true
}
// Background
cr.SetSourceRGB(0.15, 0.15, 0.18)
cr.Rectangle(0, 0, canvasW, canvasH)
cr.Fill()
// Inactive area
iy := app.inactiveY()
cr.SetSourceRGB(0.1, 0.1, 0.12)
cr.Rectangle(0, iy, canvasW, canvasH-iy)
cr.Fill()
cr.SetSourceRGB(0.4, 0.4, 0.4)
cr.SetLineWidth(1)
cr.MoveTo(0, iy)
cr.LineTo(canvasW, iy)
cr.Stroke()
cr.SetSourceRGB(0.5, 0.5, 0.5)
cr.SetFontSize(12)
cr.MoveTo(10, iy+18)
cr.ShowText("Inactive / Off — drag monitors here to disable")
// Draw enabled monitors
for i, m := range app.monitors {
if !m.Enabled {
continue
}
x, y, w, h := app.monRect(m)
app.drawMonitor(cr, x, y, w, h, m, i == app.dragging)
}
// Draw inactive monitors
for i, m := range app.monitors {
if m.Enabled {
continue
}
x, y, w, h := app.inactiveRect(i)
app.drawMonitor(cr, x, y, w, h, m, i == app.dragging)
}
}
func (app *App) drawMonitor(cr *cairo.Context, x, y, w, h float64, m Monitor, selected bool) {
bezel := 4.0
if selected {
cr.SetSourceRGB(0.3, 0.6, 1.0)
} else if m.Enabled {
cr.SetSourceRGB(0.3, 0.3, 0.35)
} else {
cr.SetSourceRGB(0.25, 0.25, 0.28)
}
cr.Rectangle(x-bezel, y-bezel, w+bezel*2, h+bezel*2+15)
cr.Fill()
if m.Enabled {
cr.SetSourceRGB(0.2, 0.25, 0.35)
} else {
cr.SetSourceRGB(0.12, 0.12, 0.15)
}
cr.Rectangle(x, y, w, h)
cr.Fill()
// Stand
standW := w * 0.3
cr.SetSourceRGB(0.25, 0.25, 0.28)
cr.Rectangle(x+w/2-standW/2, y+h+bezel, standW, 12)
cr.Fill()
// Labels
cr.SetSourceRGB(0.9, 0.9, 0.9)
fontSize := math.Max(10, math.Min(14, w/10))
cr.SetFontSize(fontSize)
cr.MoveTo(x+5, y+fontSize+3)
cr.ShowText(m.Name)
cr.SetSourceRGB(0.6, 0.6, 0.6)
cr.SetFontSize(fontSize * 0.8)
cr.MoveTo(x+5, y+fontSize*2+3)
cr.ShowText(fmt.Sprintf("%dx%d", m.Width, m.Height))
if m.Enabled {
cr.SetSourceRGB(0.5, 0.5, 0.5)
cr.SetFontSize(fontSize * 0.7)
cr.MoveTo(x+5, y+fontSize*2.8+3)
cr.ShowText(fmt.Sprintf("pos: %d,%d", m.X, m.Y))
}
}
func (app *App) onPress(ev *gdk.Event) {
btn := gdk.EventButtonNewFromEvent(ev)
x, y := btn.X(), btn.Y()
switch btn.Button() {
case 1: // Left click - drag monitor
app.dragging = app.hitTest(x, y)
if app.dragging >= 0 {
m := app.monitors[app.dragging]
if m.Enabled {
mx, my, _, _ := app.monRect(m)
app.dragOffX = x - mx
app.dragOffY = y - my
} else {
ix, iy, _, _ := app.inactiveRect(app.dragging)
app.dragOffX = x - ix
app.dragOffY = y - iy
}
}
case 2: // Middle click - pan
app.panning = true
app.panStartX = x
app.panStartY = y
app.panOffX = app.offsetX
app.panOffY = app.offsetY
}
}
func (app *App) onRelease(ev *gdk.Event) {
btn := gdk.EventButtonNewFromEvent(ev)
switch btn.Button() {
case 1:
app.dragging = -1
case 2:
app.panning = false
}
}
func (app *App) onMotion(ev *gdk.Event) {
motion := gdk.EventMotionNewFromEvent(ev)
x, y := motion.MotionVal()
if app.panning {
app.offsetX = app.panOffX + (x - app.panStartX)
app.offsetY = app.panOffY + (y - app.panStartY)
app.canvas.QueueDraw()
return
}
if app.dragging < 0 {
return
}
// Check if dragging into/out of inactive area
if y > app.inactiveY() {
app.monitors[app.dragging].Enabled = false
app.canvas.QueueDraw()
return
}
// Enable if was inactive
app.monitors[app.dragging].Enabled = true
// Convert canvas position to monitor coords
newCX := x - app.dragOffX
newCY := y - app.dragOffY
mx, my := app.canvasToMon(newCX, newCY)
// Snap to 10px grid
snapX := int(math.Round(mx/10)) * 10
snapY := int(math.Round(my/10)) * 10
app.monitors[app.dragging].X = snapX
app.monitors[app.dragging].Y = snapY
app.canvas.QueueDraw()
}
func (app *App) onScroll(ev *gdk.Event) {
scroll := gdk.EventScrollNewFromEvent(ev)
dy := scroll.DeltaY()
// Also handle discrete scroll (non-smooth)
if dy == 0 {
switch scroll.Direction() {
case gdk.SCROLL_UP:
dy = -1
case gdk.SCROLL_DOWN:
dy = 1
default:
return
}
}
// Zoom toward cursor
x, y := scroll.X(), scroll.Y()
oldScale := app.scale
if dy < 0 {
app.scale *= 1.15
} else {
app.scale /= 1.15
}
// Clamp
app.scale = math.Max(0.02, math.Min(1.0, app.scale))
// Adjust offset so point under cursor stays fixed
app.offsetX = x - (x-app.offsetX)*(app.scale/oldScale)
app.offsetY = y - (y-app.offsetY)*(app.scale/oldScale)
app.canvas.QueueDraw()
}
func configDir() string {
dir, _ := os.UserConfigDir()
p := filepath.Join(dir, "wmon")
os.MkdirAll(p, 0755)
return p
}
func (app *App) saveConfig() {
dialog, _ := gtk.FileChooserDialogNewWith2Buttons("Save Layout", app.window,
gtk.FILE_CHOOSER_ACTION_SAVE, "Cancel", gtk.RESPONSE_CANCEL, "Save", gtk.RESPONSE_ACCEPT)
dialog.SetCurrentFolder(configDir())
dialog.SetCurrentName("layout.json")
dialog.SetDoOverwriteConfirmation(true)
filter, _ := gtk.FileFilterNew()
filter.AddPattern("*.json")
filter.SetName("JSON files")
dialog.AddFilter(filter)
if dialog.Run() == gtk.RESPONSE_ACCEPT {
path := dialog.GetFilename()
data, _ := json.MarshalIndent(app.monitors, "", " ")
if err := os.WriteFile(path, data, 0644); err != nil {
log.Printf("Save error: %v", err)
}
}
dialog.Destroy()
}
func (app *App) loadConfig() {
dialog, _ := gtk.FileChooserDialogNewWith2Buttons("Load Layout", app.window,
gtk.FILE_CHOOSER_ACTION_OPEN, "Cancel", gtk.RESPONSE_CANCEL, "Open", gtk.RESPONSE_ACCEPT)
dialog.SetCurrentFolder(configDir())
filter, _ := gtk.FileFilterNew()
filter.AddPattern("*.json")
filter.SetName("JSON files")
dialog.AddFilter(filter)
if dialog.Run() == gtk.RESPONSE_ACCEPT {
path := dialog.GetFilename()
data, err := os.ReadFile(path)
if err != nil {
log.Printf("Load error: %v", err)
dialog.Destroy()
return
}
var monitors []Monitor
if err := json.Unmarshal(data, &monitors); err != nil {
log.Printf("Parse error: %v", err)
dialog.Destroy()
return
}
app.monitors = monitors
app.layoutSet = false
app.canvas.QueueDraw()
}
dialog.Destroy()
}
func (app *App) apply() {
var args []string
for _, m := range app.monitors {
if m.Enabled {
args = append(args, "--output", m.Name, "--on", "--pos",
fmt.Sprintf("%d,%d", m.X, m.Y))
} else {
args = append(args, "--output", m.Name, "--off")
}
}
cmd := exec.Command("wlr-randr", args...)
out, err := cmd.CombinedOutput()
if err != nil {
log.Printf("wlr-randr error: %v\n%s", err, out)
dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR,
gtk.BUTTONS_OK, "wlr-randr failed: %s\n%s", err, string(out))
dialog.Run()
dialog.Destroy()
} else {
log.Printf("Applied: wlr-randr %s", strings.Join(args, " "))
}
}
func main() {
gtk.Init(nil)
app := &App{dragging: -1, scale: 0.15}
var err error
app.monitors, err = parseWlrRandr()
if err != nil {
log.Fatalf("Failed to parse wlr-randr: %v", err)
}
app.window, _ = gtk.WindowNew(gtk.WINDOW_TOPLEVEL)
app.window.SetTitle("wmon")
app.window.SetDefaultSize(900, 600)
app.window.Connect("destroy", gtk.MainQuit)
vbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
app.window.Add(vbox)
app.canvas, _ = gtk.DrawingAreaNew()
app.canvas.SetHExpand(true)
app.canvas.SetVExpand(true)
app.canvas.Connect("draw", app.draw)
app.canvas.AddEvents(int(gdk.BUTTON_PRESS_MASK | gdk.BUTTON_RELEASE_MASK |
gdk.POINTER_MOTION_MASK | gdk.SCROLL_MASK | gdk.SMOOTH_SCROLL_MASK))
app.canvas.Connect("button-press-event", func(da *gtk.DrawingArea, ev *gdk.Event) {
app.onPress(ev)
})
app.canvas.Connect("button-release-event", func(da *gtk.DrawingArea, ev *gdk.Event) {
app.onRelease(ev)
})
app.canvas.Connect("motion-notify-event", func(da *gtk.DrawingArea, ev *gdk.Event) {
app.onMotion(ev)
})
app.canvas.Connect("scroll-event", func(da *gtk.DrawingArea, ev *gdk.Event) {
app.onScroll(ev)
})
vbox.PackStart(app.canvas, true, true, 0)
btnBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
btnBox.SetMarginTop(5)
btnBox.SetMarginBottom(5)
btnBox.SetMarginStart(5)
btnBox.SetMarginEnd(5)
applyBtn, _ := gtk.ButtonNewWithLabel("Apply")
applyBtn.Connect("clicked", func() { app.apply() })
reloadBtn, _ := gtk.ButtonNewWithLabel("Reload")
reloadBtn.Connect("clicked", func() {
app.monitors, _ = parseWlrRandr()
app.layoutSet = false
app.canvas.QueueDraw()
})
btnBox.PackEnd(applyBtn, false, false, 0)
btnBox.PackEnd(reloadBtn, false, false, 0)
saveBtn, _ := gtk.ButtonNewWithLabel("Save")
saveBtn.Connect("clicked", func() { app.saveConfig() })
btnBox.PackStart(saveBtn, false, false, 0)
loadBtn, _ := gtk.ButtonNewWithLabel("Load")
loadBtn.Connect("clicked", func() { app.loadConfig() })
btnBox.PackStart(loadBtn, false, false, 0)
vbox.PackStart(btnBox, false, false, 0)
app.window.ShowAll()
gtk.Main()
}