diff --git a/assets/translations/en.json b/assets/translations/en.json index f4240284a3..7e61facd9b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1257,7 +1257,8 @@ "wallpaper": "Wallpaper", "weather": "Weather", "widget-list": "Widget List", - "widgets": "Widgets" + "widgets": "Widgets", + "window-switcher": "Window Switcher" }, "sections": { "accessibility": "Accessibility", @@ -3066,6 +3067,10 @@ "time-format": { "description": "Default time format for shell UI without its own setting", "label": "Time Format" + }, + "window-switcher-mru": { + "description": "Order windows in the switcher by most recently used rather than workspace and screen layout", + "label": "Most Recently Used Order" } }, "system": { diff --git a/docs/user/configuration/shell.mdx b/docs/user/configuration/shell.mdx index 33a9de1a88..de8ef586c4 100644 --- a/docs/user/configuration/shell.mdx +++ b/docs/user/configuration/shell.mdx @@ -161,6 +161,9 @@ command = "swaylock -f" [shell.mpris] blacklist = [] # optional list of players to hide from media widgets/control-center +[shell.window_switcher] +mru = false # order windows by most recently used (Alt+Tab) instead of workspace layout + [shell.privacy] mic_filter_regex = "" # ignore matching microphone application names cam_filter_regex = "" # ignore matching camera process names diff --git a/example.toml b/example.toml index c55ab8d635..803dbc116b 100644 --- a/example.toml +++ b/example.toml @@ -104,6 +104,9 @@ prefix = "win" [shell.mpris] blacklist = [] # ignore MPRIS players by bus/identity/desktop entry token +[shell.window_switcher] +mru = false # order windows by most recently used (Alt+Tab) instead of workspace layout + # ── Wallpaper ───────────────────────────────────────────────────────────────── [wallpaper] diff --git a/src/config/config_types.h b/src/config/config_types.h index 21f1374c42..02c442382e 100644 --- a/src/config/config_types.h +++ b/src/config/config_types.h @@ -1061,6 +1061,12 @@ struct ShellConfig { bool operator==(const PrivacyConfig&) const = default; }; + struct WindowSwitcherConfig { + bool mru = false; + + bool operator==(const WindowSwitcherConfig&) const = default; + }; + float cornerRadiusScale = 1.0F; bool buttonBorders = true; bool inputBorders = true; @@ -1109,6 +1115,7 @@ struct ShellConfig { ShadowConfig shadow; PanelConfig panel; LauncherConfig launcher; + WindowSwitcherConfig windowSwitcher; KeyboardLayoutConfig keyboardLayout; ScreenCornersConfig screenCorners; MprisConfig mpris; diff --git a/src/config/schema/config_schema.cpp b/src/config/schema/config_schema.cpp index e077efbd04..fb274d80d5 100644 --- a/src/config/schema/config_schema.cpp +++ b/src/config/schema/config_schema.cpp @@ -1373,6 +1373,13 @@ namespace noctalia::config::schema { return s; } + const Schema& shellWindowSwitcherSchema() { + static const Schema s = { + field(&ShellConfig::WindowSwitcherConfig::mru, "mru"), + }; + return s; + } + const Schema& shellScreenCornersSchema() { static const Schema s = { field(&ShellConfig::ScreenCornersConfig::enabled, "enabled"), @@ -1562,6 +1569,7 @@ namespace noctalia::config::schema { subTable(&ShellConfig::panel, "panel", shellPanelSchema()), subTable(&ShellConfig::launcher, "launcher", shellLauncherSchema()), subTable(&ShellConfig::keyboardLayout, "keyboard_layout", shellKeyboardLayoutSchema()), + subTable(&ShellConfig::windowSwitcher, "window_switcher", shellWindowSwitcherSchema()), subTable(&ShellConfig::screenCorners, "screen_corners", shellScreenCornersSchema()), subTable(&ShellConfig::mpris, "mpris", shellMprisSchema()), subTable(&ShellConfig::screenshot, "screenshot", shellScreenshotSchema()), diff --git a/src/shell/settings/settings_registry.cpp b/src/shell/settings/settings_registry.cpp index e38e241756..0095ac14bc 100644 --- a/src/shell/settings/settings_registry.cpp +++ b/src/shell/settings/settings_registry.cpp @@ -1932,6 +1932,11 @@ namespace settings { e.visibleWhen = [](const Config& c) { return c.shell.screenshot.pipeToCommand; }; entries.push_back(std::move(e)); } + entries.push_back(makeEntry( + SettingsSection::Shell, "window-switcher", tr("settings.schema.shell.window-switcher-mru.label"), + tr("settings.schema.shell.window-switcher-mru.description"), {"shell", "window_switcher", "mru"}, + ToggleSetting{cfg.shell.windowSwitcher.mru}, "window switcher alt tab mru most recently used" + )); entries.push_back(makeEntry( SettingsSection::Osd, "osd", tr("settings.schema.shell.osd-enabled.label"), tr("settings.schema.shell.osd-enabled.description"), {"osd", "enabled"}, ToggleSetting{cfg.osd.enabled}, diff --git a/src/shell/switcher/window_switcher.cpp b/src/shell/switcher/window_switcher.cpp index e9acdff6e7..ff24516005 100644 --- a/src/shell/switcher/window_switcher.cpp +++ b/src/shell/switcher/window_switcher.cpp @@ -34,6 +34,8 @@ #include #include +#include +#include #include #include #include @@ -224,6 +226,14 @@ namespace { return {}; } + [[nodiscard]] std::string currentFocusedWindowKey(const CompositorPlatform& platform) { + const auto focusedId = platform.focusedCompositorWindowId(); + if (!focusedId.has_value()) { + return {}; + } + return canonicalWindowId(*focusedId); + } + [[nodiscard]] std::uintptr_t resolveCloseHandle( const CompositorPlatform& platform, std::string_view windowId, std::string_view appId, std::string_view title ) { @@ -274,6 +284,8 @@ namespace { std::int32_t sortX = 0; std::int32_t sortY = 0; std::uint64_t toplevelOrder = 0; + // Windows with no MRU rank sort after every ranked window. + std::size_t mruIndex = std::numeric_limits::max(); }; [[nodiscard]] WindowSwitcherEntry makeEntryFromAssignment( @@ -327,9 +339,29 @@ namespace { } } + // Identity keys of every window the switcher can list right now. Compositors reuse + // window ids (Hyprland reuses addresses), so MRU ranks must expire with the window. + [[nodiscard]] std::unordered_set liveWindowKeys(const CompositorPlatform& platform) { + std::unordered_set keys; + keys.reserve(32); + for (const auto& assignment : platform.workspaceWindowAssignments()) { + if (std::string key = canonicalWindowId(assignment.windowId); !key.empty()) { + keys.insert(std::move(key)); + } + } + + std::unordered_map liveToplevelById; + indexLiveToplevelsByWindowId(platform, liveToplevelById); + for (const auto& live : liveToplevelById) { + keys.insert(live.first); + } + return keys; + } + void buildWindowEntries( const CompositorPlatform& platform, IconResolver& iconResolver, int iconSize, - std::vector& out, const std::optional& focusedId + std::vector& out, const std::optional& focusedId, + const std::deque* mruKeys ) { std::unordered_map assignmentById; assignmentById.reserve(32); @@ -351,11 +383,23 @@ namespace { std::vector candidates; candidates.reserve(assignmentById.size() + liveToplevelById.size()); + // Empty while MRU ordering is off, which leaves every candidate at rank max. + std::unordered_map mruRanks; + if (mruKeys != nullptr) { + mruRanks.reserve(mruKeys->size()); + for (std::size_t i = 0; i < mruKeys->size(); ++i) { + mruRanks.try_emplace((*mruKeys)[i], i); + } + } + auto addCandidate = [&](WindowSwitcherCandidate candidate, const std::string& key) { if (key.empty() || seenKeys.contains(key)) { return; } seenKeys.insert(key); + if (const auto rank = mruRanks.find(key); rank != mruRanks.end()) { + candidate.mruIndex = rank->second; + } candidates.push_back(std::move(candidate)); }; @@ -392,6 +436,9 @@ namespace { } std::ranges::stable_sort(candidates, [](const WindowSwitcherCandidate& a, const WindowSwitcherCandidate& b) { + if (a.mruIndex != b.mruIndex) { + return a.mruIndex < b.mruIndex; + } if (a.workspaceKey != b.workspaceKey) { return a.workspaceKey < b.workspaceKey; } @@ -577,8 +624,36 @@ void WindowSwitcher::onOutputChange() { } } +bool WindowSwitcher::mruEnabled() const { return m_config != nullptr && m_config->config().shell.windowSwitcher.mru; } + +void WindowSwitcher::recordFocusedWindow() { + if (m_platform == nullptr || !mruEnabled()) { + return; + } + promoteMruKey(currentFocusedWindowKey(*m_platform)); +} + +void WindowSwitcher::promoteMruKey(const std::string& key) { + if (key.empty() || m_platform == nullptr) { + return; + } + + const std::unordered_set live = liveWindowKeys(*m_platform); + std::erase_if(m_mruKeys, [&](const std::string& existing) { return existing != key && !live.contains(existing); }); + + auto it = std::ranges::find(m_mruKeys, key); + if (it == m_mruKeys.end()) { + m_mruKeys.insert(m_mruKeys.begin(), key); + return; + } + if (it != m_mruKeys.begin()) { + std::rotate(m_mruKeys.begin(), it, it + 1); + } +} + void WindowSwitcher::onToplevelChange() { if (!m_active) { + recordFocusedWindow(); return; } const std::size_t previousCount = m_windows.size(); @@ -595,6 +670,9 @@ void WindowSwitcher::show(wl_output* output) { } const bool wasActive = m_active; + if (!wasActive) { + recordFocusedWindow(); + } refreshWindows(); m_output = output; @@ -642,7 +720,10 @@ void WindowSwitcher::refreshWindows() { IconResolver iconResolver; const int iconSize = 96; - buildWindowEntries(*m_platform, iconResolver, iconSize, m_windows, m_platform->focusedCompositorWindowId()); + buildWindowEntries( + *m_platform, iconResolver, iconSize, m_windows, m_platform->focusedCompositorWindowId(), + mruEnabled() ? &m_mruKeys : nullptr + ); if (selectedKey.has_value()) { for (std::size_t i = 0; i < m_windows.size(); ++i) { @@ -704,10 +785,13 @@ void WindowSwitcher::activateSelected() { if (m_platform == nullptr || m_windows.empty() || m_selectedIndex >= m_windows.size()) { return; } + const WindowSwitcherEntry entry = m_windows[m_selectedIndex]; + if (mruEnabled()) { + promoteMruKey(identityKeyForEntry(entry)); + } // Hyprland ignores zwlr_foreign_toplevel_handle_v1.activate while an exclusive // keyboard layer-shell surface is mapped (hyprwm/Hyprland#4829). Snapshot the // selection, tear the overlay down, then activate on the next loop tick. - const WindowSwitcherEntry entry = m_windows[m_selectedIndex]; CompositorPlatform* platform = m_platform; hide(); DeferredCall::callLater([platform, entry]() { activateWindowSwitcherEntry(*platform, entry); }); diff --git a/src/shell/switcher/window_switcher.h b/src/shell/switcher/window_switcher.h index 22d42e55ef..28659d5aa0 100644 --- a/src/shell/switcher/window_switcher.h +++ b/src/shell/switcher/window_switcher.h @@ -4,6 +4,7 @@ #include "wayland/wayland_seat.h" #include +#include #include #include #include @@ -55,6 +56,9 @@ class WindowSwitcher { void buildScene(Instance& instance, std::uint32_t width, std::uint32_t height); void positionGrid(Instance& instance, float screenW, float screenH); void syncGridSelection(); + [[nodiscard]] bool mruEnabled() const; + void recordFocusedWindow(); + void promoteMruKey(const std::string& key); WaylandConnection* m_wayland = nullptr; RenderContext* m_renderContext = nullptr; @@ -64,6 +68,7 @@ class WindowSwitcher { Instance* m_instance = nullptr; std::vector m_windows; + std::deque m_mruKeys; std::size_t m_selectedIndex = 0; std::size_t m_gridColumns = 5; wl_output* m_output = nullptr;