layout: allow constraining floating windows to be entirely or partially on-screen (#15492)

* floating: support fitting windows only partially on-screen

* floating: add config for whether existing floating windows are kept on-screen

* floating: apply "floating windows on-screen" config when moving into layout

* floating: add config for whether new floating windows are forced on-screen

* tests: (new_)?float_force_onscreen behavior
This commit is contained in:
Lucas Ritzdorf
2026-07-29 15:08:53 +02:00
committed by GitHub
parent a95b81806c
commit b3aa455ceb
4 changed files with 140 additions and 24 deletions
+68
View File
@@ -1943,3 +1943,71 @@ TEST_CASE(sendFsWindowToAnotherWorkspace) {
ASSERT_CONTAINS(str, "floating: 1");
}
}
TEST_CASE(floatingForceOnscreen) {
// Test: move a floating window around
NLog::log("{}Testing force-onscreen for existing floating windows", Colors::GREEN);
Tests::spawnKitty();
OK(getFromSocket("/dispatch hl.dsp.window.float({ action = 'on' })"));
OK(getFromSocket("/dispatch hl.dsp.window.resize({ x = 100, y = 100, relative = false })"));
// No constraints
OK(getFromSocket("/eval hl.config({ misc = { float_force_onscreen = 0 }})"));
OK(getFromSocket("/dispatch hl.dsp.window.move({ x = -110, y = 0, relative = false })"));
ASSERT_CONTAINS(getFromSocket("/activewindow"), "at: -110,0");
// Partially onscreen
OK(getFromSocket("/eval hl.config({ misc = { float_force_onscreen = 1 }})"));
OK(getFromSocket("/dispatch hl.dsp.window.move({ x = -110, y = 0, relative = false })"));
ASSERT_CONTAINS(getFromSocket("/activewindow"), "at: -50,0");
// Fully onscreen
OK(getFromSocket("/eval hl.config({ misc = { float_force_onscreen = 2 }})"));
OK(getFromSocket("/dispatch hl.dsp.window.move({ x = -10, y = 0, relative = false })"));
ASSERT_CONTAINS(getFromSocket("/activewindow"), "at: 2,2");
OK(getFromSocket("/dispatch hl.dsp.window.kill()"));
// Test: spawn new floating windows offscreen
// NOTE: These new-window tests all run with float_force_onscreen = 2.
// That's intended, and it shouldn't limit where new windows can spawn.
NLog::log("{}Testing force-onscreen for new floating windows", Colors::GREEN);
// Helper macro, because spawnKitty() doesn't support executing with rules
#define WAIT_FOR_WINDOW(N) \
do { \
std::this_thread::sleep_for(std::chrono::milliseconds(10)); \
int counter = 0; \
while (Tests::windowCount() == N) { \
counter++; \
std::this_thread::sleep_for(std::chrono::milliseconds(100)); \
if (counter > 50) \
break; \
} \
} while (0)
// No constraints
OK(getFromSocket("/eval hl.config({ misc = { new_float_force_onscreen = 0 }})"));
OK(getFromSocket("/dispatch hl.dsp.exec_cmd('kitty', { float = true, size = {100, 100}, move = {-110, 0} })"));
WAIT_FOR_WINDOW(0);
ASSERT_CONTAINS(getFromSocket("/activewindow"), "at: -110,0");
OK(getFromSocket("/dispatch hl.dsp.window.kill()"));
// Partially onscreen
OK(getFromSocket("/eval hl.config({ misc = { new_float_force_onscreen = 1 }})"));
OK(getFromSocket("/dispatch hl.dsp.exec_cmd('kitty', { float = true, size = {100, 100}, move = {-110, 0} })"));
WAIT_FOR_WINDOW(0);
ASSERT_CONTAINS(getFromSocket("/activewindow"), "at: -50,0");
OK(getFromSocket("/dispatch hl.dsp.window.kill()"));
// Fully onscreen
OK(getFromSocket("/eval hl.config({ misc = { new_float_force_onscreen = 2 }})"));
OK(getFromSocket("/dispatch hl.dsp.exec_cmd('kitty', { float = true, size = {100, 100}, move = {-10, 0} })"));
WAIT_FOR_WINDOW(0);
// NOTE: As we spawn with rules, apparently border_size isn't known yet, so
// we get placed at (0,0) rather than (2,2)
ASSERT_CONTAINS(getFromSocket("/activewindow"), "at: 0,0");
OK(getFromSocket("/dispatch hl.dsp.window.kill()"));
#undef WAIT_FOR_WINDOW
}
+2
View File
@@ -517,6 +517,8 @@ std::vector<SP<IValue>> Values::getConfigValues() {
MS<Bool>("misc:screencopy_force_8b", "forces 8 bit screencopy", true),
MS<Bool>("misc:disable_scale_notification", "disables notification popup when a monitor fails to set a suitable scale", false),
MS<Bool>("misc:size_limits_tiled", "whether to apply minsize and maxsize rules to tiled windows", false),
MS<Int>("misc:new_float_force_onscreen", "whether new floating windows must be placed fully/partially on-screen", 2),
MS<Int>("misc:float_force_onscreen", "whether existing floating windows must remain fully/partially on-screen", 0),
/*
* binds:
@@ -5,6 +5,7 @@
#include "../../../target/WindowTarget.hpp"
#include "../../../space/Space.hpp"
#include "../../../../config/ConfigValue.hpp"
#include "../../../../Compositor.hpp"
#include "../../../../desktop/state/WindowState.hpp"
#include "../../../../output/Monitor.hpp"
@@ -90,16 +91,20 @@ void CDefaultFloatingAlgorithm::newTarget(SP<ITarget> target) {
if (!posOverridden && (!DESIRED_GEOM || !DESIRED_GEOM->pos))
windowGeometry = CBox{WORK_AREA.middle() - windowGeometry.size() / 2.F, windowGeometry.size()};
if (posOverridden // pos is overridden by a rule
|| (DESIRED_GEOM && DESIRED_GEOM->pos && target->window() && target->window()->m_isX11) // X11 window with a geom
|| WORK_AREA.containsPoint(windowGeometry.middle())) // geometry within work area
target->setPositionGlobal(windowGeometry);
else {
if (!posOverridden // pos is overridden by a rule
&& !(DESIRED_GEOM && DESIRED_GEOM->pos && target->window() && target->window()->m_isX11) // X11 window with a geom
) {
const auto POS = WORK_AREA.middle() - windowGeometry.size() / 2.f;
windowGeometry.x = POS.x;
windowGeometry.y = POS.y;
}
target->setPositionGlobal(windowGeometry);
static auto PFORCEONSCREEN = CConfigValue<Config::INTEGER>("misc:new_float_force_onscreen");
switch (*PFORCEONSCREEN) {
default:
case 0: target->setPositionGlobal(windowGeometry); break;
case 1: target->setPositionGlobal(fitBoxInWorkArea(windowGeometry, target, false)); break;
case 2: target->setPositionGlobal(fitBoxInWorkArea(windowGeometry, target, true)); break;
}
// TODO: not very OOP, is it?
@@ -148,6 +153,8 @@ void CDefaultFloatingAlgorithm::movedTarget(SP<ITarget> target, std::optional<Ve
LAST_SIZE = DESIRED ? DESIRED->size : DEFAULT_SIZE;
}
CBox wantPos;
if (target->wasTiling()) {
// Avoid floating toggles that don't change size, they aren't easily visible to the user
if (std::abs(LAST_SIZE.x - CURRENT_SIZE.x) < 5 && std::abs(LAST_SIZE.y - CURRENT_SIZE.y) < 5)
@@ -156,8 +163,8 @@ void CDefaultFloatingAlgorithm::movedTarget(SP<ITarget> target, std::optional<Ve
// calculate new position
const auto OLD_CENTER = target->position().middle();
// put around the current center, fit in workArea
target->setPositionGlobal(fitBoxInWorkArea(CBox{OLD_CENTER - LAST_SIZE / 2.F, LAST_SIZE}, target));
// put around the current center
wantPos = CBox{OLD_CENTER - LAST_SIZE / 2.F, LAST_SIZE};
} else {
// calculate new position
@@ -166,26 +173,43 @@ void CDefaultFloatingAlgorithm::movedTarget(SP<ITarget> target, std::optional<Ve
const auto MON_FROM_OLD = State::monitorState()->query().vec(OLD_POS).run();
const auto NEW_POS = MON_FROM_OLD ? OLD_POS - MON_FROM_OLD->m_position + THIS_MON_POS : OLD_POS;
// put around the current center, fit in workArea
target->setPositionGlobal(fitBoxInWorkArea(CBox{NEW_POS, LAST_SIZE}, target));
wantPos = CBox{NEW_POS, LAST_SIZE};
}
setPositionGlobal(target, wantPos);
updateTarget(target);
}
CBox CDefaultFloatingAlgorithm::fitBoxInWorkArea(const CBox& box, SP<ITarget> t) {
CBox CDefaultFloatingAlgorithm::fitBoxInWorkArea(const CBox& box, SP<ITarget> t, bool fully) {
const auto WORK_AREA = m_parent->space()->workArea(true);
const auto EXTENTS = t->window() ? t->window()->getWindowExtentsUnified(Desktop::View::RESERVED_EXTENTS | Desktop::View::INPUT_EXTENTS) : SBoxExtents{};
CBox targetBox = box.copy().addExtents(EXTENTS);
targetBox.x = std::max(targetBox.x, WORK_AREA.x);
targetBox.y = std::max(targetBox.y, WORK_AREA.y);
if (fully) {
targetBox.x = std::max(targetBox.x, WORK_AREA.x);
targetBox.y = std::max(targetBox.y, WORK_AREA.y);
if (targetBox.x + targetBox.w > WORK_AREA.x + WORK_AREA.w)
targetBox.x = WORK_AREA.x + WORK_AREA.w - targetBox.w;
if (targetBox.x + targetBox.w > WORK_AREA.x + WORK_AREA.w)
targetBox.x = WORK_AREA.x + WORK_AREA.w - targetBox.w;
if (targetBox.y + targetBox.h > WORK_AREA.y + WORK_AREA.h)
targetBox.y = WORK_AREA.y + WORK_AREA.h - targetBox.h;
if (targetBox.y + targetBox.h > WORK_AREA.y + WORK_AREA.h)
targetBox.y = WORK_AREA.y + WORK_AREA.h - targetBox.h;
} else {
// If we'd move offscreen, place centerpoint on nearest edge instead
if (targetBox.intersection(WORK_AREA).empty()) {
targetBox.x = std::max(targetBox.x, WORK_AREA.x - (targetBox.w / 2.F));
targetBox.y = std::max(targetBox.y, WORK_AREA.y - (targetBox.h / 2.F));
if (targetBox.x > WORK_AREA.x + WORK_AREA.w)
targetBox.x = WORK_AREA.x + WORK_AREA.w - (targetBox.w / 2.F);
if (targetBox.y > WORK_AREA.y + WORK_AREA.h)
targetBox.y = WORK_AREA.y + WORK_AREA.h - (targetBox.h / 2.F);
}
}
return targetBox.addExtents(SBoxExtents{.topLeft = -EXTENTS.topLeft, .bottomRight = -EXTENTS.bottomRight});
}
@@ -200,7 +224,7 @@ void CDefaultFloatingAlgorithm::resizeTarget(const Vector2D& Δ, SP<ITarget> tar
pos.w += Δ.x;
pos.h += Δ.y;
pos.translate(-Δ / 2.F);
target->setPositionGlobal(pos);
setPositionGlobal(target, pos);
if (g_layoutManager->dragController()->target() == target)
target->warpPositionSize();
@@ -211,7 +235,7 @@ void CDefaultFloatingAlgorithm::resizeTarget(const Vector2D& Δ, SP<ITarget> tar
void CDefaultFloatingAlgorithm::moveTarget(const Vector2D& Δ, SP<ITarget> target) {
auto pos = target->position();
pos.translate(Δ);
target->setPositionGlobal(pos);
setPositionGlobal(target, pos);
if (g_layoutManager->dragController()->target() == target)
target->warpPositionSize();
@@ -221,8 +245,8 @@ void CDefaultFloatingAlgorithm::moveTarget(const Vector2D& Δ, SP<ITarget> targe
void CDefaultFloatingAlgorithm::swapTargets(SP<ITarget> a, SP<ITarget> b) {
auto posABackup = a->position();
a->setPositionGlobal(b->position());
b->setPositionGlobal(posABackup);
setPositionGlobal(a, b->position());
setPositionGlobal(b, posABackup);
updateTarget(a);
updateTarget(b);
@@ -242,7 +266,7 @@ void CDefaultFloatingAlgorithm::moveTargetInDirection(SP<ITarget> t, Math::eDire
default: Log::logger->log(Log::ERR, "Invalid direction in CDefaultFloatingAlgorithm::moveTargetInDirection"); break;
}
t->setPositionGlobal(pos);
setPositionGlobal(t, pos);
updateTarget(t);
}
@@ -257,7 +281,7 @@ void CDefaultFloatingAlgorithm::recenter(SP<ITarget> t) {
}
void CDefaultFloatingAlgorithm::setTargetGeom(const CBox& geom, SP<ITarget> target) {
target->setPositionGlobal(geom);
setPositionGlobal(target, geom);
updateTarget(target);
}
@@ -265,3 +289,23 @@ void CDefaultFloatingAlgorithm::setTargetGeom(const CBox& geom, SP<ITarget> targ
void CDefaultFloatingAlgorithm::updateTarget(SP<ITarget> t) {
m_datas[t] = {.lastBox = t->position()};
}
CBox CDefaultFloatingAlgorithm::setPositionGlobal(SP<ITarget> t, const CBox& box) {
CBox adjustedBox;
static auto PFORCEONSCREEN = CConfigValue<Config::INTEGER>("misc:float_force_onscreen");
switch (*PFORCEONSCREEN) {
// No no limits, we'll reach for the sky
default:
case 0: adjustedBox = box.copy(); break;
// Must be partially visible
case 1: adjustedBox = fitBoxInWorkArea(box, t, false); break;
// Must be fully in-bounds
case 2: adjustedBox = fitBoxInWorkArea(box, t, true); break;
}
t->setPositionGlobal(adjustedBox);
return adjustedBox;
}
@@ -27,10 +27,12 @@ namespace Layout::Floating {
virtual void recenter(SP<ITarget> t);
private:
CBox fitBoxInWorkArea(const CBox& box, SP<ITarget> t);
CBox fitBoxInWorkArea(const CBox& box, SP<ITarget> t, bool fully = true);
void updateTarget(SP<ITarget>);
CBox setPositionGlobal(SP<ITarget> t, const CBox& box);
struct SWindowData {
CBox lastBox;
};