// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/wine" "log/slog" ) type wineReviewFocusRegistry interface { Focuses() []wine.ReviewFocusNotice Current(atspi.ObjectID) bool } // Only the event loop calls this controller. Inputs are copied metadata; // foreground queries read the adapter's atomic cache and perform no native I/O. type wineReviewFocusController struct { registry wineReviewFocusRegistry logger *slog.Logger state *eventLoopState foreground foregroundMonitor titles windowTitleSource gate *focusGate worker *review.Worker clear func() active wine.ReviewFocusNotice epoch uint64 } func wineReviewBoundsMatch(bounds atspi.Rectangle, client xorg.Foreground) bool { return client.Known && client.Window != 0 && bounds.Valid() && ((int64(bounds.X) == int64(client.X) && int64(bounds.Y) == int64(client.Y) && int64(bounds.Width) == int64(client.Width) && int64(bounds.Height) == int64(client.Height)) || (int64(bounds.X) == int64(client.OuterX) && int64(bounds.Y) == int64(client.OuterY) && int64(bounds.Width) == int64(client.OuterWidth) && int64(bounds.Height) == int64(client.OuterHeight))) } func (c *wineReviewFocusController) eligible(notice wine.ReviewFocusNotice, fg atspi.ForegroundState, title atspi.ForegroundTitle) bool { metadata := title.Title if !fg.Client.Known || fg.Client.Window == 0 || title.Epoch != fg.Epoch || metadata.Window != fg.Client.Window || !metadata.Wine || !metadata.Focused || !equivalentWindowTitles(metadata.Title, notice.Title) { return false } if metadata.TitleWindow == metadata.Window { return wineReviewBoundsMatch(notice.Bounds, fg.Client) } if metadata.TitleWindow == 0 || metadata.Client.Window != metadata.TitleWindow { return false } if wineReviewBoundsMatch(notice.Bounds, metadata.Client) { return true } // Wine's virtual desktop uses desktop-local screen coordinates. Corroborate // a translated rectangle against the actual focused X11 child, not a title. return int64(notice.Bounds.X)+int64(fg.Client.X) == int64(metadata.Client.X) && int64(notice.Bounds.Y)+int64(fg.Client.Y) == int64(metadata.Client.Y) && int64(notice.Bounds.Width) == int64(metadata.Client.Width) && int64(notice.Bounds.Height) == int64(metadata.Client.Height) && notice.Bounds.Valid() } func (c *wineReviewFocusController) changed() { if c == nil || c.registry == nil || c.worker == nil || c.foreground == nil || c.titles == nil || c.gate.Locked() { if c != nil { c.retire() } return } fg, title := c.foreground.Foreground(), c.titles.ForegroundTitle() var selected wine.ReviewFocusNotice count := 0 for _, notice := range c.registry.Focuses() { eligible := c.eligible(notice, fg, title) if !eligible && c.logger != nil { c.logger.Debug("Wine review foreground mismatch", "sender", notice.Sender, "window", notice.Window, "focus", notice.Focus, "revision", notice.Revision, "source_bounds", notice.Bounds, "native_foreground", fg.Client, "foreground_epoch", fg.Epoch, "title_epoch", title.Epoch, "title_metadata", title.Title) } if eligible { selected = notice count++ } } if count != 1 || !c.registry.Current(selected.ID()) { c.retire() return } c.gate.SetSuspended(false) transition := c.active.ID() != selected.ID() || c.active.Revision != selected.Revision || c.active.Protected != selected.Protected || c.epoch != fg.Epoch if transition { if c.clear != nil { c.clear() } else { c.worker.ClearFocus() } c.worker.SyncFocus(selected.ID()) } c.active, c.epoch = selected, fg.Epoch c.state.current = atspi.Snapshot{BusName: selected.Sender, Path: selected.ID().Path, Role: "text"} if selected.Protected { c.state.current.Role = "password text" } c.state.focusedID = selected.ID() c.state.owner = windowOwner{applicationBus: selected.Sender, window: wine.ReviewID(selected.Sender, selected.Instance, selected.Window, selected.Window), focus: selected.ID(), activated: true, title: selected.Title} } func (c *wineReviewFocusController) foregroundChanged() { c.changed() } func (c *wineReviewFocusController) retire() { if c == nil || c.active.Sender == "" { return } if c.worker != nil && c.worker.Focus() == c.active.ID() { if c.clear != nil { c.clear() } else { c.worker.ClearFocus() } } if c.state != nil && c.state.owner.applicationBus == c.active.Sender { c.state.owner.clear() } c.active = wine.ReviewFocusNotice{} c.epoch = 0 } func (c *wineReviewFocusController) owns(id atspi.ObjectID) bool { return c != nil && c.active.Sender != "" && c.active.ID() == id && c.registry.Current(id) } func (c *wineReviewFocusController) resultCurrent(result review.Result) bool { c.changed() return c.owns(result.FlatFocus) && flatReviewResultCurrent(c.worker, result) }