Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
build/
__pycache__/
1 change: 1 addition & 0 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Located in `main.cpp`, registered via `HyprlandAPI::addConfigValueV2`:
- `plugin:hyprview:window_name_bg_opacity`
- `plugin:hyprview:window_text_color`
- `plugin:hyprview:gesture_distance`
- `plugin:hyprview:fullscreen_on_select` (default 0)

### Framebuffer Management
- Individual framebuffers per window stored in `SWindowImage::fb`
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ https://github.com/user-attachments/assets/c0553bfe-6357-48e5-a4d0-50068096d800

## Features

* **Fullscreen Windows:** Include fullscreen windows and restore their original fullscreen modes and workspaces when dismissing the overview.
* **Optional Fullscreen Selection:** Enable `fullscreen_on_select` to make an explicitly selected window fullscreen.
* **Workspace Overview:** See all your open windows on the current workspace at a glance.
* **Multi-Workspace Modes:** View windows from the current workspace, all workspaces on the monitor, or include special (scratchpad) workspaces.
* **Workspace Indicator:** Each window tile shows its workspace ID (displayed as "wsid:N") in a configurable position with customizable size and styling. The indicator color automatically matches the window's border color (active or inactive) for easy navigation across multiple workspaces.
Expand Down Expand Up @@ -77,6 +79,28 @@ so a downward callback can close it. Avoid assigning competing workspace gesture
to the same fingers/direction. Legacy `hyprview-gesture` remains available in
`hyprland.conf`.

### Fullscreen selection

By default, selecting a preview retains its previous fullscreen state. The
existing sticky `on` mode also retains its behavior of focusing a preview without
closing. To make a left-click or explicit selection close the overview and make
the chosen window fullscreen, opt in:

```lua
-- Apply after the plugin is loaded (or in the config reload following its load).
hl.config({ plugin = { hyprview = { fullscreen_on_select = 1 } } })
```

For legacy configuration:

```ini
plugin:hyprview:fullscreen_on_select = 1
```

The default is `0`. A downward gesture or `off` without a selection restores the
original window states regardless of this setting. With the option enabled,
clicking a preview also exits sticky `on` mode. Empty background clicks are ignored.

### Keybinds

You can bind the overview to a key. The dispatcher accepts optional arguments to control the behavior.
Expand Down
85 changes: 64 additions & 21 deletions src/hyprview.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#include <linux/input-event-codes.h>
#include "hyprview.hpp"
#include <algorithm>
#include <any>
Expand Down Expand Up @@ -71,6 +72,12 @@ CHyprView::~CHyprView() {
window->moveToWorkspace(image.originalWorkspace);
}
}
for (const auto &image : images) {
auto window = image.pWindow.lock();
if (window && window->m_isMapped && image.originalFullscreen.internal != Fullscreen::FSMODE_NONE)
Fullscreen::controller()->setFullscreenMode(window, image.originalFullscreen.internal,
image.originalFullscreen.client);
}
}

// Always cleanup resources in destructor if they haven't been cleaned yet
Expand All @@ -80,7 +87,9 @@ CHyprView::~CHyprView() {
images.clear();
if (bgFramebuffer)
bgFramebuffer->release();
g_pPointerManager->resetCursorImage();
// resetCursorImage() clears the buffer without invalidating the renderer's
// cached shape, so later requests for that same shape can leave it invisible.
g_pHyprRenderer->setCursorFromName("left_ptr", true);
}
}

Expand All @@ -91,8 +100,17 @@ void CHyprView::setupWindowImages(std::vector<PHLWINDOW> &windowsToRender) {

// Save original workspaces BEFORE moving
std::unordered_map<PHLWINDOW, PHLWORKSPACE> originalWorkspaces;
std::unordered_map<PHLWINDOW, Fullscreen::SFullscreenMode> originalFullscreen;
for (auto &window : windowsToRender) {
originalWorkspaces[window] = window->m_workspace;
originalFullscreen[window] = Fullscreen::controller()->getFullscreenModes(window);
}

// The overview owns rendering temporarily. Remove covering fullscreen state
// before combining windows from different workspaces, keeping client state.
for (auto &window : windowsToRender) {
if (originalFullscreen[window].internal != Fullscreen::FSMODE_NONE)
Fullscreen::controller()->setFullscreenMode(window, Fullscreen::FSMODE_NONE);
}

// Move windows to active workspace so they have valid surfaces for rendering
Expand All @@ -115,6 +133,7 @@ void CHyprView::setupWindowImages(std::vector<PHLWINDOW> &windowsToRender) {
image.originalPos = window->positionAnimation()->value();
image.originalSize = window->sizeAnimation()->value();
image.originalWorkspace = originalWorkspaces[window];
image.originalFullscreen = originalFullscreen[window];

const auto RENDERSIZE =
(window->sizeAnimation()->value() * pMonitor->m_scale).floor();
Expand Down Expand Up @@ -348,10 +367,6 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
if (!w->m_isMapped || w->isHidden())
continue;

// Skip fullscreen windows to prevent problems and crashes
if (Fullscreen::controller()->isFullscreen(w))
continue;

if (!shouldIncludeWindow(w))
continue;

Expand Down Expand Up @@ -451,7 +466,7 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,

g_pHyprRenderer->m_bBlockSurfaceFeedback = false;

g_pCursorManager->setCursorFromName("left_ptr");
g_pHyprRenderer->setCursorFromName("left_ptr", true);

lastMousePosLocal =
g_pInputManager->getMouseCoordsInternal() - pMonitor->m_position;
Expand All @@ -463,7 +478,7 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
// Check if mouse is actually on this monitor BEFORE cancelling
Vector2D globalMousePos = g_pInputManager->getMouseCoordsInternal();
Vector2D monitorPos = pMonitor->m_position;
Vector2D fullMonitorSize = pMonitor->m_pixelSize;
Vector2D fullMonitorSize = pMonitor->m_pixelSize / pMonitor->m_scale;

bool mouseOnThisMonitor =
(globalMousePos.x >= monitorPos.x &&
Expand Down Expand Up @@ -493,7 +508,7 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
// Check if mouse is on this monitor BEFORE cancelling
Vector2D globalMousePos = g_pInputManager->getMouseCoordsInternal();
Vector2D monitorPos = pMonitor->m_position;
Vector2D fullMonitorSize = pMonitor->m_pixelSize;
Vector2D fullMonitorSize = pMonitor->m_pixelSize / pMonitor->m_scale;

bool mouseOnThisMonitor =
(globalMousePos.x >= monitorPos.x &&
Expand All @@ -505,8 +520,10 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
return; // Mouse is on a different monitor - don't cancel event
}

static const CConfigValue<Config::INTEGER> PFULLSCREENONSELECT(
"plugin:hyprview:fullscreen_on_select");
// If explicitly turned on, project click to real window
if (stickyOn) {
if (stickyOn && !*PFULLSCREENONSELECT) {
info.cancelled = true;

Vector2D localMousePos = globalMousePos - monitorPos;
Expand All @@ -520,8 +537,8 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,

// Calculate mouse position relative to tile
const CBox &tileBox = images[tileIndex].box;
Vector2D mousePosInTile = {localMousePos.x - tileBox.x,
localMousePos.y - tileBox.y};
Vector2D mousePosInTile = {localMousePos.x * pMonitor->m_scale - tileBox.x,
localMousePos.y * pMonitor->m_scale - tileBox.y};

// Calculate scale factor from tile to real window
Vector2D realWindowSize = window->sizeAnimation()->value();
Expand All @@ -544,17 +561,24 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
return;
}

// Normal mode: cancel click, select window, and close ALL overviews except
// forced ones
info.cancelled = true;
selectHoveredWindow();
lastMousePosLocal = globalMousePos - monitorPos;
const int tileIndex = getWindowIndexFromMousePos(lastMousePosLocal);
if (tileIndex < 0 || tileIndex >= (int)images.size())
return;
auto selectedWindow = images[tileIndex].pWindow.lock();
if (!selectedWindow || !selectedWindow->m_isMapped)
return;

// Close all overview instances except those with stickyOn=true
currentHoveredIndex = tileIndex;
selectHoveredWindow();
// Restore other monitors first, then focus this selection.
for (auto &[monitor, instance] : g_pHyprViewInstances) {
if (instance && !instance->stickyOn) {
if (instance && instance.get() != this &&
(!instance->stickyOn || *PFULLSCREENONSELECT))
instance->close();
}
}
close();
};

auto onMouseAxis = [this](SCallbackInfo &info) {
Expand All @@ -564,7 +588,7 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
// Check if mouse is on this monitor
Vector2D globalMousePos = g_pInputManager->getMouseCoordsInternal();
Vector2D monitorPos = pMonitor->m_position;
Vector2D fullMonitorSize = pMonitor->m_pixelSize;
Vector2D fullMonitorSize = pMonitor->m_pixelSize / pMonitor->m_scale;

bool mouseOnThisMonitor =
(globalMousePos.x >= monitorPos.x &&
Expand Down Expand Up @@ -601,7 +625,10 @@ CHyprView::CHyprView(PHLMONITOR pMonitor_, PHLWORKSPACE startedOn_, bool swipe_,
auto& EV = Event::bus()->m_events;
mouseMoveHook = EV.input.mouse.move.listen([onCursorMove](const Vector2D&, SCallbackInfo& info) { onCursorMove(info); });
touchMoveHook = EV.input.touch.motion.listen([onCursorMove](const ITouch::SMotionEvent&, SCallbackInfo& info) { onCursorMove(info); });
mouseButtonHook = EV.input.mouse.button.listen([onCursorSelect](const IPointer::SButtonEvent&, SCallbackInfo& info) { onCursorSelect(info); });
mouseButtonHook = EV.input.mouse.button.listen([onCursorSelect](const IPointer::SButtonEvent& event, SCallbackInfo& info) {
if (event.button == BTN_LEFT && event.state == WL_POINTER_BUTTON_STATE_PRESSED)
onCursorSelect(info);
});
mouseAxisHook = EV.input.mouse.axis.listen([onMouseAxis](const IPointer::SAxisEvent&, SCallbackInfo& info) { onMouseAxis(info); });
touchDownHook = EV.input.touch.down.listen([onCursorSelect](const ITouch::SDownEvent&, SCallbackInfo& info) { onCursorSelect(info); });

Expand Down Expand Up @@ -768,6 +795,14 @@ void CHyprView::close() {
}
}

// Restore fullscreen only after all windows are back on their workspaces.
for (const auto &image : images) {
auto window = image.pWindow.lock();
if (window && window->m_isMapped && image.originalFullscreen.internal != Fullscreen::FSMODE_NONE)
Fullscreen::controller()->setFullscreenMode(window, image.originalFullscreen.internal,
image.originalFullscreen.client);
}

// STEP 2: Start closing animationi - animate scale back to 0
Debug::log(LOG, "[hyprview] close(): Start closing animation");
*scale = 0.0f;
Expand All @@ -776,6 +811,11 @@ void CHyprView::close() {
if (userExplicitlySelected && selectedWindow) {
Desktop::focusState()->fullWindowFocus(selectedWindow, Desktop::FOCUS_REASON_KEYBIND);
Config::Actions::alterZOrder("top");
static const CConfigValue<Config::INTEGER> PFULLSCREENONSELECT(
"plugin:hyprview:fullscreen_on_select");
if (*PFULLSCREENONSELECT)
Fullscreen::controller()->setFullscreenMode(selectedWindow, Fullscreen::FSMODE_FULLSCREEN,
Fullscreen::FSMODE_FULLSCREEN);
}
}

Expand All @@ -792,7 +832,9 @@ void CHyprView::onPreRender() {
images.clear();
if (bgFramebuffer)
bgFramebuffer->release();
g_pPointerManager->resetCursorImage();
// resetCursorImage() clears the buffer without invalidating the renderer's
// cached shape, so later requests for that same shape can leave it invisible.
g_pHyprRenderer->setCursorFromName("left_ptr", true);
}
}

Expand Down Expand Up @@ -1213,7 +1255,8 @@ void CHyprView::onSwipeEnd() {
m_isSwiping = false;
}

int CHyprView::getWindowIndexFromMousePos(const Vector2D &mousePos) {
int CHyprView::getWindowIndexFromMousePos(const Vector2D &logicalMousePos) {
const Vector2D mousePos = logicalMousePos * pMonitor->m_scale;
if (images.empty())
return -1;

Expand Down
1 change: 1 addition & 0 deletions src/hyprview.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class CHyprView {
struct SWindowImage {
SP<Render::IFramebuffer> fb;
PHLWINDOWREF pWindow;
Fullscreen::SFullscreenMode originalFullscreen;
CBox box;
Vector2D originalPos;
Vector2D originalSize;
Expand Down
4 changes: 4 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,10 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
throw std::runtime_error("[hyprview] Cannot register plugin:hyprview:window_name_bg_opacity");
if (!HyprlandAPI::addConfigValueV2(PHANDLE, makeShared<Config::Values::CIntValue>("plugin:hyprview:window_text_color", "Hyprview option", 0xFFFFFFFF)))
throw std::runtime_error("[hyprview] Cannot register plugin:hyprview:window_text_color");
if (!HyprlandAPI::addConfigValueV2(PHANDLE,
makeShared<Config::Values::CIntValue>("plugin:hyprview:fullscreen_on_select",
"Fullscreen an explicitly selected window", 0)))
throw std::runtime_error("[hyprview] Cannot register plugin:hyprview:fullscreen_on_select");
HyprlandAPI::reloadConfig();

return {"hyprview", "Window overview with multiple placement algorithms",
Expand Down
22 changes: 22 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Python 3, Hyprland/hyprctl, foot, and grim, plus a running Wayland compositor.
make -C src
python3 tests/nested.py build/hyprview.so --scale 1
python3 tests/nested.py build/hyprview.so --scale 2
python3 tests/nested.py build/hyprview.so --scale 1 --fullscreen --selection default
python3 tests/nested.py build/hyprview.so --scale 2 --fullscreen --selection fullscreen
```

Use `--parent-display wayland-N` if the terminal's `WAYLAND_DISPLAY` is stale.
Expand All @@ -19,3 +21,23 @@ Checks cover two windows on separate workspaces, three open/close cycles, exact
workspace/fullscreen-state restoration, config reload, and unloading while open.
This exercises Lua callbacks directly; physical touchpad gestures still require
a manual check. It does not cover older Hyprland versions or rotated outputs.

`--fullscreen` adds a fullscreen window and checks that the overview temporarily
clears its internal fullscreen mode, then restores both internal and client modes.
`--selection` additionally requires a C compiler, pkg-config, wayland-client
development files, and wayland-scanner. It builds a virtual pointer helper and
checks empty-background clicks, selection across workspaces, the default sticky
behavior, and optional fullscreen selection (including an already-fullscreen
selection). The helper connects only to the test compositor's socket.

`virtual-pointer.xml` comes from swaywm/wlr-protocols, at
`unstable/wlr-virtual-pointer-unstable-v1.xml`; its upstream license notice is
retained in the file.

Use `--check-cursor` to verify that a cursor image remains after dismissal,
selection, and unloading. This builds a small read-only observer plugin against
the installed Hyprland headers and loads it only into the nested compositor.
It requires a C++ compiler and `pkg-config --cflags hyprland` to resolve matching
headers (set `PKG_CONFIG_PATH` if using hyprpm's header installation). The check
fails on the previous cleanup code, which clears the cursor buffer while leaving
the renderer's cached cursor shape intact.
19 changes: 19 additions & 0 deletions tests/cursor-probe.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#include <hyprland/src/plugins/PluginAPI.hpp>
#include <hyprland/src/pointer/PointerManager.hpp>
#include <lua.hpp>
#include <stdexcept>

// Test-only observer. Load exclusively in the nested test compositor.
APICALL EXPORT std::string PLUGIN_API_VERSION() { return HYPRLAND_API_VERSION; }

APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
if (!HyprlandAPI::addLuaFunction(handle, "hyprview_test", "has_cursor", [](lua_State *L) -> int {
const auto &cursor = Pointer::mgr()->currentCursorImage();
lua_pushboolean(L, cursor.pBuffer || cursor.surface);
return 1;
}))
throw std::runtime_error("Cannot register cursor test observer");
return {"hyprview-cursor-test", "Observe the cursor image in nested tests", "Hyprview tests", "1"};
}

APICALL EXPORT void PLUGIN_EXIT() {}
Loading