diff --git a/meson.build b/meson.build index d4b94d8a..e2b573d6 100644 --- a/meson.build +++ b/meson.build @@ -437,6 +437,7 @@ unit_tests = [ ['border-ring', ['tests/unit/border_ring.cpp'], []], ['floating', ['tests/unit/floating.cpp'], []], ['maximize', ['tests/unit/maximize.cpp'], []], + ['deferred-unfullscreen', ['tests/unit/deferred_unfullscreen.cpp'], []], ['config-section', ['tests/unit/config_section.cpp'], []], ['config-change', ['tests/unit/config_change.cpp'], []], ['config-load', ['tests/unit/config_load.cpp'], [umbriel_core_dep]], diff --git a/src/server/focus.cpp b/src/server/focus.cpp index 3db6b07d..1013170c 100644 --- a/src/server/focus.cpp +++ b/src/server/focus.cpp @@ -82,6 +82,10 @@ namespace umbriel { m_server.registry().promote(view); view->setUrgent(false); + if (reason == FocusReason::XdgActivation || reason == FocusReason::ForeignActivation) { + view->applyDeferredUnfullscreen(); + } + // Keep workspace focus while exclusive layer-shell holds the seat; refocus applies it later. Still clear activation // chrome so the previous window does not stay visually focused. Overview owns the seat the same way, but keeps the // chrome so card borders track the focused window; the keyboard enter replays when it closes. diff --git a/src/view/deferred_unfullscreen.h b/src/view/deferred_unfullscreen.h new file mode 100644 index 00000000..431370ef --- /dev/null +++ b/src/view/deferred_unfullscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +namespace umbriel { + + enum class FullscreenRequestDisposition { + Acknowledge, + Park, + Apply, + }; + + class DeferredUnfullscreen { + public: + using Clock = std::chrono::steady_clock; + using TimePoint = Clock::time_point; + static constexpr auto kGrace = std::chrono::milliseconds(750); + + [[nodiscard]] FullscreenRequestDisposition + observeClientRequest(bool requested, bool activated, bool scheduledFullscreen, TimePoint now = Clock::now()) { + if (requested == scheduledFullscreen) { + clear(); + return FullscreenRequestDisposition::Acknowledge; + } + if (!requested && !activated) { + m_pending = true; + m_parkedAt = now; + return FullscreenRequestDisposition::Park; + } + clear(); + return FullscreenRequestDisposition::Apply; + } + + [[nodiscard]] bool takeOnActivation(TimePoint now = Clock::now()) { + if (!m_pending) { + return false; + } + m_pending = false; + return now - m_parkedAt <= kGrace; + } + + void clear() { m_pending = false; } + [[nodiscard]] bool pending() const { return m_pending; } + + private: + bool m_pending = false; + TimePoint m_parkedAt; + }; + +} // namespace umbriel diff --git a/src/view/view.cpp b/src/view/view.cpp index 463411bc..0a80000e 100644 --- a/src/view/view.cpp +++ b/src/view/view.cpp @@ -1955,20 +1955,23 @@ namespace umbriel { ); const bool requested = m_toplevel->requested.fullscreen; + const FullscreenRequestDisposition disposition = m_deferredUnfullscreen.observeClientRequest( + requested, m_toplevel->scheduled.activated, m_toplevel->scheduled.fullscreen + ); - // Redundant request (wine spams set_fullscreen while already fullscreen): ack with a configure, but skip the - // reparent/scroll-snap/arrange churn that a full setFullscreen() would run: that churn is visible flicker. - if (requested == m_toplevel->scheduled.fullscreen) { + if (disposition == FullscreenRequestDisposition::Acknowledge) { + // Wine spams set_fullscreen while already fullscreen. Acknowledge without the visible reparent, scroll snap, and + // arrange churn that a full setFullscreen() would run. Observing this newer request also clears any parked + // unfullscreen, so activation cannot apply stale client intent. wlr_xdg_surface_schedule_configure(m_toplevel->base); return; } - // Wine unfullscreens games when they lose focus (minimize-on-focus-loss). Honoring that rips the game out of the - // fullscreen strip the moment the user scrolls away. Deny unfullscreen from deactivated windows; the scheduled - // configure re-asserts the fullscreen state (spec-compliant). - if (!requested && !m_toplevel->scheduled.activated) { + if (disposition == FullscreenRequestDisposition::Park) { + // Wine games commonly unfullscreen when they lose focus. Park that request briefly instead of ripping the game + // out of the fullscreen strip; xdg or foreign activation consumes it, while expiry preserves fullscreen. kLog.debug( - "request_fullscreen denied for deactivated '{}'", m_toplevel->app_id != nullptr ? m_toplevel->app_id : "?" + "request_fullscreen parked for deactivated '{}'", m_toplevel->app_id != nullptr ? m_toplevel->app_id : "?" ); wlr_xdg_surface_schedule_configure(m_toplevel->base); return; @@ -1994,6 +1997,19 @@ namespace umbriel { setFullscreen(!m_toplevel->scheduled.fullscreen); } + void View::applyDeferredUnfullscreen() { + if (!m_deferredUnfullscreen.takeOnActivation() || !m_toplevel->base->initialized) { + return; + } + if (m_toplevel->scheduled.fullscreen || m_toplevel->current.fullscreen) { + kLog.debug( + "deferred unfullscreen applied on activation for '{}'", + m_toplevel->app_id != nullptr ? m_toplevel->app_id : "?" + ); + setFullscreen(false); + } + } + void View::toggleFloating() { setFloating(m_tiled); } void View::restorePinnedSceneParent() { @@ -2235,6 +2251,7 @@ namespace umbriel { } void View::setFullscreen(bool fullscreen) { + m_deferredUnfullscreen.clear(); kLog.debug( "set_fullscreen '{}' [{}] -> {} (tiled={}, ws_active={})", m_toplevel->app_id != nullptr ? m_toplevel->app_id : "?", static_cast(this), fullscreen, m_tiled, diff --git a/src/view/view.h b/src/view/view.h index 9aea2162..fcf45fd0 100644 --- a/src/view/view.h +++ b/src/view/view.h @@ -3,6 +3,7 @@ #include "core/animation.h" #include "scene/node.h" #include "view/decoration.h" +#include "view/deferred_unfullscreen.h" #include "view/floating.h" #include "view/presentation.h" @@ -141,6 +142,7 @@ namespace umbriel { void applyFullscreenLayout(bool animate = false); // Compositor-driven fullscreen toggle (keybind); client requests use handleRequestFullscreen. void toggleFullscreen(); + void applyDeferredUnfullscreen(); void setMaximizedToEdges(bool maximized); void toggleMaximizedToEdges(); // Detach from the scrolling layout (float) or re-insert as a tiled column. @@ -356,6 +358,9 @@ namespace umbriel { // 0 until the first frame tick after arming; the grace deadline counts // from there so a stalled frame clock cannot expire it instantly. uint64_t m_unfullscreenGraceStartMsec = 0; + // Inactive client unfullscreen requests wait briefly for xdg or foreign activation. Any later client request or + // compositor-driven fullscreen change clears the parked request. + DeferredUnfullscreen m_deferredUnfullscreen; // Geometry at unfullscreen time; a commit with a different geometry means // the client accepted windowed mode and the grace can end early. wlr_box m_unfullscreenGeometry{}; diff --git a/tests/unit/deferred_unfullscreen.cpp b/tests/unit/deferred_unfullscreen.cpp new file mode 100644 index 00000000..31295eca --- /dev/null +++ b/tests/unit/deferred_unfullscreen.cpp @@ -0,0 +1,49 @@ +#include "view/deferred_unfullscreen.h" + +#include "check.h" + +using umbriel::DeferredUnfullscreen; +using umbriel::FullscreenRequestDisposition; + +namespace { + using namespace std::chrono_literals; + constexpr DeferredUnfullscreen::TimePoint kStart{}; +} // namespace + +UMBRIEL_TEST(inactiveUnfullscreenRequestIsParked) { + DeferredUnfullscreen state; + CHECK(state.observeClientRequest(false, false, true, kStart) == FullscreenRequestDisposition::Park); + CHECK(state.pending()); +} + +UMBRIEL_TEST(activationConsumesAFreshParkedRequest) { + DeferredUnfullscreen state; + static_cast(state.observeClientRequest(false, false, true, kStart)); + CHECK(state.takeOnActivation(kStart + DeferredUnfullscreen::kGrace)); + CHECK(!state.pending()); + CHECK(!state.takeOnActivation(kStart + DeferredUnfullscreen::kGrace)); +} + +UMBRIEL_TEST(expiredParkedRequestIsDiscarded) { + DeferredUnfullscreen state; + static_cast(state.observeClientRequest(false, false, true, kStart)); + CHECK(!state.takeOnActivation(kStart + DeferredUnfullscreen::kGrace + 1ms)); + CHECK(!state.pending()); +} + +UMBRIEL_TEST(laterFullscreenRequestSupersedesParkedUnfullscreen) { + DeferredUnfullscreen state; + static_cast(state.observeClientRequest(false, false, true, kStart)); + CHECK(state.observeClientRequest(true, false, true, kStart + 1ms) == FullscreenRequestDisposition::Acknowledge); + CHECK(!state.pending()); + CHECK(!state.takeOnActivation(kStart + 2ms)); +} + +UMBRIEL_TEST(applyingAnotherRequestClearsParkedUnfullscreen) { + DeferredUnfullscreen state; + static_cast(state.observeClientRequest(false, false, true, kStart)); + CHECK(state.observeClientRequest(true, true, false, kStart + 1ms) == FullscreenRequestDisposition::Apply); + CHECK(!state.pending()); +} + +int main() { return RUN_TESTS(); }