diff --git a/internal/evidence/evidence.go b/internal/evidence/evidence.go index 4ce5cb5..1d4e4c1 100644 --- a/internal/evidence/evidence.go +++ b/internal/evidence/evidence.go @@ -24,8 +24,9 @@ type WindowSnapshot struct { } // Source is the activity port. Watch runs until ctx is cancelled, invoking -// onChange on every active-window change, and once immediately with the -// current window. +// onChange once immediately with the current window and then on every change of +// the active window or its title (a tab switch changes the title but not the +// active window). type Source interface { Watch(ctx context.Context, onChange func(WindowSnapshot)) } diff --git a/internal/evidence/poll.go b/internal/evidence/poll.go new file mode 100644 index 0000000..8b4442c --- /dev/null +++ b/internal/evidence/poll.go @@ -0,0 +1,44 @@ +package evidence + +import ( + "context" + "time" +) + +// pollLoop samples the active window via read and forwards snapshots through +// onChange, but only when the observation changes — a different window OR a +// different title. The title case is the one that matters: switching a browser +// tab changes the window title without changing the active window, so a sensor +// that re-reads only on active-window events never sees it and credits the stale +// title. Polling re-reads on a fixed cadence, so a tab switch is caught within +// one interval. +// +// It emits once immediately, then samples every interval, until ctx is +// cancelled. WindowSnapshot is comparable, so the change check is a plain +// equality. Factoring the loop here keeps the change-dedup identical across +// sensors and lets it be tested without a display. (The Windows sensor predates +// this and keeps its own equivalent loop.) +func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) { + var last WindowSnapshot + var haveLast bool + emit := func() { + s := read() + if haveLast && s == last { + return + } + last, haveLast = s, true + onChange(s) + } + + emit() // immediate current window + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + emit() + } + } +} diff --git a/internal/evidence/poll_test.go b/internal/evidence/poll_test.go new file mode 100644 index 0000000..14790a9 --- /dev/null +++ b/internal/evidence/poll_test.go @@ -0,0 +1,95 @@ +package evidence + +import ( + "context" + "sync" + "testing" + "time" +) + +// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug +// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and +// so missed a browser tab switch — same window class, new title — crediting all +// the time to the stale title. The poll loop must emit when only the title +// changes. +func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) { + // Same window (class) throughout; the title changes mid-stream, like a tab + // switch. The repeated identical reads must NOT re-emit; the title change must. + titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"} + var i int + read := func() WindowSnapshot { + idx := i + if idx >= len(titles) { + idx = len(titles) - 1 + } + i++ + return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}} + } + + var mu sync.Mutex + var got []WindowSnapshot + onChange := func(s WindowSnapshot) { + mu.Lock() + got = append(got, s) + mu.Unlock() + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }() + + deadline := time.Now().Add(2 * time.Second) + for { + mu.Lock() + n := len(got) + mu.Unlock() + if n >= 2 { + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatalf("expected >=2 emits (one per distinct title), got %d", n) + } + time.Sleep(time.Millisecond) + } + cancel() + <-done + + mu.Lock() + defer mu.Unlock() + if got[0].Title != "Keel - Brave" { + t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave") + } + if got[1].Title != "Consume - Brave" { + t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave") + } +} + +// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is +// not re-emitted, so redundant samples never churn the consumer. +func TestPollLoopDedupesIdenticalObservations(t *testing.T) { + read := func() WindowSnapshot { + return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}} + } + var mu sync.Mutex + var n int + onChange := func(WindowSnapshot) { + mu.Lock() + n++ + mu.Unlock() + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }() + time.Sleep(50 * time.Millisecond) + cancel() + <-done + + mu.Lock() + defer mu.Unlock() + if n != 1 { + t.Fatalf("identical observations must emit once, got %d emits", n) + } +} diff --git a/internal/evidence/x11.go b/internal/evidence/x11.go index 7f1b9e9..fbe594d 100644 --- a/internal/evidence/x11.go +++ b/internal/evidence/x11.go @@ -4,24 +4,29 @@ package evidence import ( "context" + "time" - "github.com/jezek/xgb/xproto" "github.com/jezek/xgbutil" "github.com/jezek/xgbutil/ewmh" "github.com/jezek/xgbutil/icccm" - "github.com/jezek/xgbutil/xevent" - "github.com/jezek/xgbutil/xprop" - "github.com/jezek/xgbutil/xwindow" ) +// pollInterval is how often the X11 sensor samples the active window. ~1s +// latency on a switch is immaterial for a focus tracker, and polling is what +// catches a title change inside the same window — e.g. switching a browser tab, +// which changes the title but not the active window, so an _NET_ACTIVE_WINDOW +// event subscription would miss it and credit the stale title. Mirrors the +// Windows sensor's polling cadence. +const pollInterval = 750 * time.Millisecond + // NewSource returns the real X11 active-window sensor. func NewSource() Source { return &x11Source{} } type x11Source struct{} -// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW -// changes on the root window, and emits a snapshot immediately plus on every -// change. Any failure degrades to an Unavailable snapshot; it never panics the +// Watch opens one long-lived X connection and samples the active window every +// pollInterval, emitting a snapshot whenever the active window OR its title +// changes. Any failure degrades to an Unavailable snapshot; it never panics the // daemon. func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) { X, err := xgbutil.NewConn() @@ -32,38 +37,11 @@ func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) { } defer X.Conn().Close() - root := X.RootWin() - activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW") - if err != nil { - onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error())) - <-ctx.Done() - return - } - - // Listen for property changes on the root window. - if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil { - onChange(unavailable("cannot listen on root window: " + err.Error())) - <-ctx.Done() - return - } - - emit := func() { onChange(snapshot(X)) } - emit() // immediate current window - - xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) { - if ev.Atom == activeAtom { - emit() - } - }).Connect(X, root) - - // Run the event loop until ctx is cancelled. - go func() { - <-ctx.Done() - xevent.Quit(X) - }() - xevent.Main(X) + pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange) } +// snapshot reads the current active window's title and class. A missing active +// window (or read error) yields an Unavailable snapshot. func snapshot(X *xgbutil.XUtil) WindowSnapshot { active, err := ewmh.ActiveWindowGet(X) if err != nil || active == 0 {