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:
parent
20abe80132
commit
4efaa8ddee
3 changed files with 289 additions and 3 deletions
228
cmd/force-quit-gui-nucular/main.go
Normal file
228
cmd/force-quit-gui-nucular/main.go
Normal 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
23
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
|
||||
)
|
||||
|
|
|
|||
41
go.sum
41
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=
|
||||
|
|
|
|||
Loading…
Reference in a new issue