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 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-06 06:18:37 -08:00
parent 20abe80132
commit 4efaa8ddee
3 changed files with 289 additions and 3 deletions

View file

@ -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")
}

23
go.mod
View file

@ -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
)

41
go.sum
View file

@ -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=