// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "strings" "git.stormux.org/storm/cthulhu/internal/atspi" ) // windowOwner keeps application, top-level window, and object focus as // separate lifecycles. The event loop remains the sole owner of this state. type windowOwner struct { applicationBus string window atspi.ObjectID focus atspi.ObjectID activated bool title string } func (owner *windowOwner) activate(event atspi.Event) (duplicate bool) { window := eventWindow(event) if window.Valid() && window == owner.window { if owner.activated { return true } owner.activated = true owner.title = activationTitle(event) return false } owner.window = window owner.applicationBus = window.BusName owner.focus = event.Source.ID() owner.activated = true owner.title = activationTitle(event) return false } func (owner *windowOwner) follow(event atspi.Event) bool { window := eventWindow(event) if owner.window.Valid() && window.Valid() && window != owner.window { // A focused object can precede window:activate during an actual X11 // handoff. The native adapter has already rejected objects in a known // inactive window, so confirmed focused state may establish the new owner. if !event.Source.Focused { return false } owner.window = window owner.applicationBus = window.BusName owner.activated = false owner.title = "" } if !owner.window.Valid() && window.Valid() { owner.window = window owner.applicationBus = window.BusName owner.activated = false owner.title = "" } owner.focus = event.Source.ID() return true } func (owner *windowOwner) end(event atspi.Event, consumedCommand bool) bool { window := eventWindow(event) if !owner.window.Valid() || !window.Valid() || window != owner.window { return false } // Python rechecks active state because passive X11 grabs can emit a // transient deactivate/activate pair while the same window stays active. if event.Type == atspi.WindowDeactivatedEvent { if consumedCommand || event.Source.StatesKnown && event.Source.Active { return false } } owner.clear() return true } // containsLoad permits a document replacement only inside the existing focus // context. A load completion is not a window activation, even when a provider // still reports focused or active state for a background document. func (owner *windowOwner) containsLoad(event atspi.Event) bool { window := eventWindow(event) if owner.window.Valid() { // The old document may become defunct before its replacement loads. // Its window still owns the context even though object focus is gone. return window == owner.window && event.Source.BusName == owner.applicationBus } return owner.focus.Valid() && event.Source.BusName == owner.focus.BusName } func (owner *windowOwner) defunct(event atspi.Event) (windowEnded, focusEnded bool) { if event.Detail1 == 0 { return false, false } id := event.Source.ID() if owner.window.Valid() && id == owner.window { owner.clear() return true, true } if owner.focus.Valid() && id == owner.focus { owner.focus = atspi.ObjectID{} return false, true } return false, false } func (owner *windowOwner) clear() { owner.applicationBus = "" owner.window = atspi.ObjectID{} owner.focus = atspi.ObjectID{} owner.activated = false owner.title = "" } func activationTitle(event atspi.Event) string { if title := strings.TrimSpace(event.WindowTitle); title != "" { return title } if event.Source.ID() == eventWindow(event) { return strings.TrimSpace(event.Source.Name) } return "" } // Track empty names as well so restoring an earlier title is a real change. func (owner *windowOwner) changeTitle(event atspi.Event) bool { if !owner.window.Valid() || event.Source.ID() != owner.window { return false } title := strings.TrimSpace(event.Source.Name) if title == owner.title { return false } owner.title = title return title != "" } func eventWindow(event atspi.Event) atspi.ObjectID { if event.Window.Valid() { return event.Window } // Window lifecycle fixtures and older adapters identify the top-level in // OriginPath. For object events OriginPath is the object itself, so it must // not be promoted to a window owner. switch event.Type { case atspi.WindowActivatedEvent, atspi.WindowDeactivatedEvent, atspi.WindowDestroyedEvent: return atspi.NewObjectID(event.Source.BusName, event.OriginPath) default: return atspi.ObjectID{} } }