From 4efaa8ddee1a4fbe46d7579b0f96cc6ce6c666aa Mon Sep 17 00:00:00 2001 From: Philip Peterson <1326208+philip-peterson@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:18:37 -0800 Subject: [PATCH] Add nucular-based prototype of force-quit-gui Evaluates dropping the GTK3 dependency in favor of a pure-Go, no-cgo toolkit. Built with -tags nucular_shiny to force the x11driver backend (nucular's default backend now pulls in Gio/cgo/EGL, which would defeat the purpose). Reads Xft.dpi directly via xgb for HiDPI scaling, since nucular has no built-in DPI awareness. Known gap: x11driver exposes no iconify/withdraw call, so unlike the GTK version, closing the window exits the process instead of hiding it. Tracked as a TODO in the file header for follow-up. Co-Authored-By: Claude Sonnet 5 --- cmd/force-quit-gui-nucular/main.go | 228 +++++++++++++++++++++++++++++ go.mod | 23 ++- go.sum | 41 +++++- 3 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 cmd/force-quit-gui-nucular/main.go diff --git a/cmd/force-quit-gui-nucular/main.go b/cmd/force-quit-gui-nucular/main.go new file mode 100644 index 0000000..2e91e65 --- /dev/null +++ b/cmd/force-quit-gui-nucular/main.go @@ -0,0 +1,228 @@ +// force-quit-gui-nucular is a prototype rebuild of force-quit-gui on top of +// nucular instead of GTK3, to evaluate dropping the GTK runtime dependency. +// +// Must be built with `-tags nucular_shiny`. Without that tag nucular +// defaults to a Gio-based backend that links cgo + EGL/GLES, which defeats +// the point of the switch; with it, nucular runs on x/exp/shiny's x11driver, +// which is pure Go with no cgo at all. +// +// KNOWN GAP vs. the GTK version: x11driver exposes no iconify/withdraw call, +// and nucular's OnClose only fires as a cleanup hook after the window +// manager's close request has already begun tearing the window down — there +// is no way to intercept delete-and-cancel like GTK's delete-event handler. +// So, unlike force-quit-gui, clicking the window's close button here exits +// the process instead of hiding it. Whatever reveals this window (currently +// swap-watchdog, expecting an always-resident process) would need to relaunch +// it rather than un-hide it. See TODO below for the planned fix. +// +// TODO: investigate extending nucular (or vendoring a thin wrapper around +// x11driver) to support withdrawing/remapping the X11 window directly via +// its xgb connection, so close-to-hide can work without pulling in cgo. +package main + +import ( + "bufio" + "fmt" + "image" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/aarzilli/nucular" + + "github.com/jezek/xgb" + "github.com/jezek/xgb/xproto" +) + +// uiScale reads Xft.dpi from the X RESOURCE_MANAGER property (the same value +// GTK/Qt use to scale HiDPI desktops) and converts it to a nucular style +// scale factor. nucular's x11driver backend has no HiDPI awareness of its +// own — without this, text renders at a fixed size regardless of the +// desktop's configured scale (e.g. KDE's 144 DPI == 1.5x on this machine), +// which is why the first prototype build looked too small. +func uiScale() float64 { + const defaultDPI = 96.0 + + conn, err := xgb.NewConn() + if err != nil { + return 1.0 + } + defer conn.Close() + + root := xproto.Setup(conn).DefaultScreen(conn).Root + reply, err := xproto.GetProperty(conn, false, root, xproto.AtomResourceManager, xproto.AtomString, 0, (1<<32)-1).Reply() + if err != nil || reply == nil { + return 1.0 + } + + for _, line := range strings.Split(string(reply.Value), "\n") { + if name, value, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(name) == "Xft.dpi" { + if dpi, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && dpi > 0 { + return dpi / defaultDPI + } + } + } + return 1.0 +} + +const refreshInterval = 2 * time.Second + +type procInfo struct { + pid int + name string + rssKB int64 + swapKB int64 + totalKB int64 +} + +func readProcesses() []procInfo { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil + } + var procs []procInfo + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + status, err := os.Open(filepath.Join("/proc", e.Name(), "status")) + if err != nil { + continue + } + var rss, swap int64 + scanner := bufio.NewScanner(status) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "VmRSS:": + rss, _ = strconv.ParseInt(fields[1], 10, 64) + case "VmSwap:": + swap, _ = strconv.ParseInt(fields[1], 10, 64) + } + } + status.Close() + + if rss == 0 && swap == 0 { + continue + } + + name := e.Name() + if commBytes, err := os.ReadFile(filepath.Join("/proc", e.Name(), "comm")); err == nil { + name = strings.TrimSpace(string(commBytes)) + } + + procs = append(procs, procInfo{pid: pid, name: name, rssKB: rss, swapKB: swap, totalKB: rss + swap}) + } + sort.Slice(procs, func(i, j int) bool { return procs[i].totalKB > procs[j].totalKB }) + return procs +} + +type appState struct { + mu sync.Mutex + procs []procInfo + selected int // pid, -1 for none + statusText string +} + +func (s *appState) refresh() { + procs := readProcesses() + s.mu.Lock() + s.procs = procs + s.mu.Unlock() +} + +func main() { + state := &appState{selected: -1} + state.refresh() + + var mw nucular.MasterWindow + + update := func(w *nucular.Window) { + state.mu.Lock() + procs := state.procs + selected := state.selected + statusText := state.statusText + state.mu.Unlock() + + w.Row(20).Dynamic(1) + w.Label("Sorted by memory + swap used, highest first. Select a process and force quit it.", "LC") + + // Static() scales its width slice in place, so a shared slice must + // never be passed via `widths...` to more than one row — each call + // would compound the previous call's scaling. Pass fresh literals. + w.Row(22).Static(70, 260, 90, 90, 90) + w.Label("PID", "LC") + w.Label("Name", "LC") + w.Label("RSS (MB)", "LC") + w.Label("Swap (MB)", "LC") + w.Label("Total (MB)", "LC") + + for _, p := range procs { + w.Row(22).Static(70, 260, 90, 90, 90) + isSelected := p.pid == selected + wasSelected := isSelected + w.SelectableLabel(strconv.Itoa(p.pid), "LC", &isSelected) + if isSelected && !wasSelected { + state.mu.Lock() + state.selected = p.pid + state.mu.Unlock() + } + w.Label(p.name, "LC") + w.Label(fmt.Sprintf("%.1f", float64(p.rssKB)/1024), "LC") + w.Label(fmt.Sprintf("%.1f", float64(p.swapKB)/1024), "LC") + w.Label(fmt.Sprintf("%.1f", float64(p.totalKB)/1024), "LC") + } + + w.Row(20).Dynamic(1) + w.Label(statusText, "LC") + + w.Row(28).Dynamic(1) + if w.ButtonText("Force Quit Selected") { + state.mu.Lock() + pid := state.selected + state.mu.Unlock() + + var msg string + if pid < 0 { + msg = "No process selected." + } else if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + msg = fmt.Sprintf("Failed to kill PID %d: %v", pid, err) + } else { + msg = fmt.Sprintf("Killed PID %d.", pid) + } + + state.mu.Lock() + state.statusText = msg + state.mu.Unlock() + state.refresh() + } + } + + mw = nucular.NewMasterWindowOptions(0, nucular.NewWindowOptions{ + Title: "Force Quit Monitor", + Size: image.Point{X: 700, Y: 450}, + }, update) + mw.Style().Scale(uiScale()) + + go func() { + ticker := time.NewTicker(refreshInterval) + defer ticker.Stop() + for range ticker.C { + state.refresh() + mw.Changed() + } + }() + + mw.Main() + log.Println("force-quit-gui-nucular exiting") +} diff --git a/go.mod b/go.mod index 6541ca4..2320381 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,25 @@ module github.com/quine-global-labs/swap-watchdog go 1.27.1 -require github.com/gotk3/gotk3 v0.6.3 // indirect +require ( + github.com/aarzilli/nucular v0.0.0-20260922071552-58b808aa5772 + github.com/gotk3/gotk3 v0.6.3 + github.com/jezek/xgb v1.1.1 +) + +require ( + dmitri.shuralyov.com/gpu/mtl v0.0.0-20221208032759-85de2813cf6b // indirect + gioui.org v0.10.2 // indirect + gioui.org/shader v1.0.9 // indirect + github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20231223183121-56fa3ac82ce7 // indirect + github.com/go-text/typesetting v0.3.4 // indirect + github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745 // indirect + github.com/hashicorp/golang-lru v0.5.1 // indirect + golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/image v0.38.0 // indirect + golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/go.sum b/go.sum index 7b006a9..c9c5075 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,41 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20221208032759-85de2813cf6b h1:a26Bdkl2B9PmYN6vGXnnfB2UGKjz0Moif1aEg+xTd7M= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20221208032759-85de2813cf6b/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.10.2 h1:bZU5CORROwc51sNha0zYdE2qWVaDncOp5EjV5nrZQZ8= +gioui.org v0.10.2/go.mod h1:iKILKNq6+LHMWhP/HjGDW/wDidUzRnb7B6c7ZD9y1Mg= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.9 h1:XxnqIfmClWpN49kizxH2W0JcCFrrEP4q3jZmNYaltbs= +gioui.org/shader v1.0.9/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/aarzilli/nucular v0.0.0-20260922071552-58b808aa5772 h1:wUpmd+1KtaT41+bIa+RJ9WLM3vlfSmKMmpXppkRcDl4= +github.com/aarzilli/nucular v0.0.0-20260922071552-58b808aa5772/go.mod h1:F2nhfn1CjYcUcl760I0F7+cF8kpgI0k59VuqOCluQ/M= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20231223183121-56fa3ac82ce7 h1:7tf/0aw5DxRQjr7WaNqgtjidub6v21L2cogKIbMcTYw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20231223183121-56fa3ac82ce7/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745 h1:0d9whnMsm0iklqvoBXNEgHPt8pkXdfDplBAswA/F8YA= +github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 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= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=