commit c64cf631253bef2f19b8b9f50ebaab99633c9e8a Author: Levi Neely Date: Wed Jun 24 13:43:01 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c7e231 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/wmon diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bc02043 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module wmon + +go 1.26.2 + +require github.com/gotk3/gotk3 v0.6.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7b006a9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..0408a4a --- /dev/null +++ b/main.go @@ -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() +}