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() }