Skip to content

Commit a1ba27f

Browse files
fuddlesworthruvnet
andcommitted
fix: prevent overlapping zone multi-zone expansion cascade
When a layout has overlapping zones (e.g. a small zone fully inside a larger one), placing the cursor on the inner zone caused ALL zones to highlight. The root cause was detectMultiZone conflating "overlapping" (stacked at cursor point) with "adjacent" (near cursor edge), and expandZonesByIntersection using bounding-rect intersection that cascaded through overlapping zones. Changes: - detectMultiZone: separate overlapping zones (cursor inside) from edge-adjacent zones (cursor outside but near edge); multi-zone only triggers for edge-adjacent zones - expandZonesByIntersection: replace bounding-rect intersection with edge-adjacency flood-fill that skips zones spatially overlapping seeds - areZonesAdjacent: consolidate onto shared sharesEdge() helper - handleZoneSpanModifier: replace duplicated smallest-area loop with layout->zoneAtPoint() which already implements the same heuristic - Add overlappingZones field to ZoneDetectionResult for future use - Wire edge tolerance to adjacentThreshold setting instead of hardcoded value, so user-configured gap tolerance applies consistently Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 03f6672 commit a1ba27f

File tree

3 files changed

+117
-92
lines changed

3 files changed

+117
-92
lines changed

src/core/interfaces.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ struct PLASMAZONES_EXPORT ZoneDetectionResult
383383
{
384384
Zone* primaryZone = nullptr; // Main zone to snap to
385385
QVector<Zone*> adjacentZones; // Adjacent zones for multi-zone snap
386+
QVector<Zone*> overlappingZones; // All zones containing cursor point (overlap info)
386387
QRectF snapGeometry; // Combined geometry for snapping
387388
qreal distance = -1; // Distance to zone edge
388389
bool isMultiZone = false; // Whether snapping to multiple zones

src/core/zonedetector.cpp

Lines changed: 113 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <QSet>
88
#include <algorithm>
99
#include <cmath>
10+
#include <limits>
1011

1112
namespace PlasmaZones {
1213

@@ -84,53 +85,101 @@ ZoneDetectionResult ZoneDetector::detectZone(const QPointF& cursorPos) const
8485

8586
namespace {
8687

87-
// Shared raycasting algorithm: expand seed zones to include all zones that intersect
88-
// the bounding rectangle. Same logic used by detectMultiZone and paint-to-snap.
89-
QVector<Zone*> expandZonesByIntersection(Layout* layout, const QVector<Zone*>& seedZones)
88+
// Check if two rects share an edge (left↔right or top↔bottom) within tolerance,
89+
// with perpendicular overlap > 0. Used by both expandZonesByIntersection and areZonesAdjacent.
90+
// minOverlapFraction: minimum perpendicular overlap as fraction of the smaller dimension (0.0–1.0).
91+
bool sharesEdge(const QRectF& r1, const QRectF& r2, qreal tolerance, qreal minOverlapFraction = 0.0)
92+
{
93+
// Left-Right adjacency (r1.right ≈ r2.left or r2.right ≈ r1.left)
94+
if (qAbs(r1.right() - r2.left()) <= tolerance || qAbs(r2.right() - r1.left()) <= tolerance) {
95+
qreal overlap = qMin(r1.bottom(), r2.bottom()) - qMax(r1.top(), r2.top());
96+
if (overlap > 0 && overlap >= qMin(r1.height(), r2.height()) * minOverlapFraction) {
97+
return true;
98+
}
99+
}
100+
// Top-Bottom adjacency (r1.bottom ≈ r2.top or r2.bottom ≈ r1.top)
101+
if (qAbs(r1.bottom() - r2.top()) <= tolerance || qAbs(r2.bottom() - r1.top()) <= tolerance) {
102+
qreal overlap = qMin(r1.right(), r2.right()) - qMax(r1.left(), r2.left());
103+
if (overlap > 0 && overlap >= qMin(r1.width(), r2.width()) * minOverlapFraction) {
104+
return true;
105+
}
106+
}
107+
return false;
108+
}
109+
110+
// Check if two rects spatially overlap (intersection is thick on both axes).
111+
// Distinguishes true area-overlap from edge-touching.
112+
bool spatiallyOverlaps(const QRectF& r1, const QRectF& r2, qreal tolerance)
113+
{
114+
QRectF intersection = r1.intersected(r2);
115+
return intersection.width() > tolerance && intersection.height() > tolerance;
116+
}
117+
118+
// Expand seed zones by edge-adjacency flood-fill. A zone is added only if it shares
119+
// an edge with an already-selected zone AND does not spatially overlap any seed zone.
120+
// This preserves gap-filling for non-overlapping tiling layouts while preventing
121+
// cascade through stacked/overlapping zones.
122+
QVector<Zone*> expandZonesByIntersection(Layout* layout, const QVector<Zone*>& seedZones, qreal edgeTolerance)
90123
{
91124
if (!layout || seedZones.isEmpty()) {
92125
return seedZones;
93126
}
94127

95128
const auto& allZones = layout->zones();
96129

97-
// Build initial bounding rect and selected set from seed zones (skip nulls)
98-
QRectF boundingRect;
130+
// Build selected set and seed set from seed zones (skip nulls)
99131
QSet<Zone*> selectedZones;
132+
QSet<Zone*> seedSet;
100133

101134
for (auto* zone : seedZones) {
102135
if (!zone) {
103136
continue;
104137
}
105-
if (boundingRect.isEmpty()) {
106-
boundingRect = zone->geometry();
107-
} else {
108-
boundingRect = boundingRect.united(zone->geometry());
109-
}
110138
selectedZones.insert(zone);
139+
seedSet.insert(zone);
111140
}
112141

113142
if (selectedZones.isEmpty()) {
114143
return QVector<Zone*>();
115144
}
116145

117-
// Iteratively expand to include all zones that intersect the bounding rect
146+
// Flood-fill: add zones that share an edge with already-selected zones
147+
// but do NOT spatially overlap any seed zone
118148
bool foundNew = true;
119149
int iterations = 0;
120150
const int maxIterations = 100;
121151

122152
while (foundNew && iterations < maxIterations) {
123153
foundNew = false;
124154
iterations++;
125-
QRectF currentRect = boundingRect;
126155

127-
for (auto* zone : allZones) {
128-
if (!zone || selectedZones.contains(zone)) {
156+
for (auto* candidate : allZones) {
157+
if (!candidate || selectedZones.contains(candidate)) {
129158
continue;
130159
}
131-
if (zone->geometry().intersects(currentRect)) {
132-
selectedZones.insert(zone);
133-
boundingRect = boundingRect.united(zone->geometry());
160+
161+
// Skip candidates that spatially overlap any seed zone
162+
bool overlapsASeed = false;
163+
for (auto* seed : seedSet) {
164+
if (spatiallyOverlaps(candidate->geometry(), seed->geometry(), edgeTolerance)) {
165+
overlapsASeed = true;
166+
break;
167+
}
168+
}
169+
if (overlapsASeed) {
170+
continue;
171+
}
172+
173+
// Check if candidate shares an edge with any already-selected zone
174+
bool shouldAdd = false;
175+
for (auto* selected : selectedZones) {
176+
if (sharesEdge(candidate->geometry(), selected->geometry(), edgeTolerance)) {
177+
shouldAdd = true;
178+
break;
179+
}
180+
}
181+
if (shouldAdd) {
182+
selectedZones.insert(candidate);
134183
foundNew = true;
135184
}
136185
}
@@ -160,50 +209,75 @@ ZoneDetectionResult ZoneDetector::detectMultiZone(const QPointF& cursorPos) cons
160209
return detectZone(cursorPos);
161210
}
162211

163-
// Find all zones near the cursor (within threshold or containing cursor)
164-
QVector<Zone*> nearbyZones;
165212
const auto& allZones = m_layout->zones();
213+
const qreal adjacentThreshold = m_settings->adjacentThreshold();
214+
215+
// Separate overlapping zones (cursor inside) from edge-adjacent zones
216+
// (cursor outside but within threshold). Only edge-adjacent zones trigger multi-zone.
217+
QVector<Zone*> overlappingZones;
218+
QVector<Zone*> edgeAdjacentZones;
166219

167220
for (auto* zone : allZones) {
168221
if (!zone) {
169222
continue;
170223
}
171-
qreal distance = zone->distanceToPoint(cursorPos);
172-
if (distance <= m_settings->adjacentThreshold() || zone->containsPoint(cursorPos)) {
173-
nearbyZones.append(zone);
224+
if (zone->containsPoint(cursorPos)) {
225+
overlappingZones.append(zone);
226+
} else {
227+
qreal distance = zone->distanceToPoint(cursorPos);
228+
if (distance <= adjacentThreshold) {
229+
edgeAdjacentZones.append(zone);
230+
}
174231
}
175232
}
176233

177-
// If we found 2+ nearby zones, use raycast algorithm
178-
if (nearbyZones.size() >= 2) {
179-
Zone* primaryZone = nullptr;
180-
qreal minDistance = std::numeric_limits<qreal>::max();
181-
for (auto* zone : nearbyZones) {
182-
qreal distance = zone->distanceToPoint(cursorPos);
183-
if (distance < minDistance) {
184-
minDistance = distance;
185-
primaryZone = zone;
186-
}
234+
// Get primary zone via smallest-area heuristic (handles overlap correctly)
235+
Zone* primaryZone = zoneAtPoint(cursorPos);
236+
237+
// Store overlap info for callers
238+
result.overlappingZones = overlappingZones;
239+
240+
// Multi-zone ONLY if there are edge-adjacent zones (cursor near a boundary between zones)
241+
if (!edgeAdjacentZones.isEmpty()) {
242+
// Combine primary (if any) + edge-adjacent zones as seeds for expansion
243+
QVector<Zone*> seedZones = edgeAdjacentZones;
244+
if (primaryZone && !seedZones.contains(primaryZone)) {
245+
seedZones.prepend(primaryZone);
187246
}
188247

189-
QVector<Zone*> zonesInRect = expandZonesByIntersection(m_layout, nearbyZones);
248+
QVector<Zone*> expanded = expandZonesByIntersection(m_layout, seedZones, adjacentThreshold);
249+
250+
if (expanded.size() > 1) {
251+
if (!primaryZone) {
252+
// No containing zone — pick closest edge-adjacent as primary
253+
qreal minDistance = std::numeric_limits<qreal>::max();
254+
for (auto* zone : edgeAdjacentZones) {
255+
qreal distance = zone->distanceToPoint(cursorPos);
256+
if (distance < minDistance) {
257+
minDistance = distance;
258+
primaryZone = zone;
259+
}
260+
}
261+
}
190262

191-
if (zonesInRect.size() > 1 && primaryZone) {
192263
result.primaryZone = primaryZone;
193-
result.adjacentZones = zonesInRect;
264+
result.adjacentZones = expanded;
194265
result.isMultiZone = true;
195-
result.snapGeometry = combineZoneGeometries(zonesInRect);
266+
result.snapGeometry = combineZoneGeometries(expanded);
196267
result.distance = 0;
197268
return result;
198269
}
199270
}
200271

201-
return detectZone(cursorPos);
272+
// Fall through to single-zone detection, preserving overlap info
273+
ZoneDetectionResult singleResult = detectZone(cursorPos);
274+
singleResult.overlappingZones = overlappingZones;
275+
return singleResult;
202276
}
203277

204278
QVector<Zone*> ZoneDetector::expandPaintedZonesToRect(const QVector<Zone*>& seedZones) const
205279
{
206-
return expandZonesByIntersection(m_layout, seedZones);
280+
return expandZonesByIntersection(m_layout, seedZones, m_settings->adjacentThreshold());
207281
}
208282

209283
Zone* ZoneDetector::zoneAtPoint(const QPointF& point) const
@@ -266,37 +340,7 @@ bool ZoneDetector::areZonesAdjacent(Zone* zone1, Zone* zone2) const
266340
return false;
267341
}
268342

269-
const QRectF& r1 = zone1->geometry();
270-
const QRectF& r2 = zone2->geometry();
271-
272-
// Check if zones share an edge (within threshold)
273-
// Use a stricter threshold for adjacency (adjacentThreshold is for cursor proximity, not zone adjacency)
274-
// Zones are adjacent if they share an edge within 5 pixels (much stricter than cursor proximity)
275-
qreal adjacencyTolerance = 5.0;
276-
277-
// Left-Right adjacency (vertical edge between zones)
278-
if (qAbs(r1.right() - r2.left()) <= adjacencyTolerance || qAbs(r2.right() - r1.left()) <= adjacencyTolerance) {
279-
// Check vertical overlap - zones must overlap significantly, not just touch
280-
qreal overlap = qMin(r1.bottom(), r2.bottom()) - qMax(r1.top(), r2.top());
281-
qreal minHeight = qMin(r1.height(), r2.height());
282-
// Require at least 10% overlap to consider zones adjacent
283-
if (overlap > 0 && overlap >= minHeight * 0.1) {
284-
return true;
285-
}
286-
}
287-
288-
// Top-Bottom adjacency (horizontal edge between zones)
289-
if (qAbs(r1.bottom() - r2.top()) <= adjacencyTolerance || qAbs(r2.bottom() - r1.top()) <= adjacencyTolerance) {
290-
// Check horizontal overlap - zones must overlap significantly, not just touch
291-
qreal overlap = qMin(r1.right(), r2.right()) - qMax(r1.left(), r2.left());
292-
qreal minWidth = qMin(r1.width(), r2.width());
293-
// Require at least 10% overlap to consider zones adjacent
294-
if (overlap > 0 && overlap >= minWidth * 0.1) {
295-
return true;
296-
}
297-
}
298-
299-
return false;
343+
return sharesEdge(zone1->geometry(), zone2->geometry(), m_settings->adjacentThreshold(), 0.1);
300344
}
301345

302346
qreal ZoneDetector::distanceToZoneEdge(const QPointF& point, Zone* zone) const

src/dbus/windowdragadaptor.cpp

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
#include <QKeySequence>
88
#include <QScreen>
99
#include <cmath>
10-
#include <limits>
1110
#include <KGlobalAccel>
1211
#include <KLocalizedString>
1312
#include "windowtrackingadaptor.h"
@@ -307,28 +306,9 @@ void WindowDragAdaptor::handleZoneSpanModifier(int x, int y)
307306
m_currentMultiZoneGeometry = QRect();
308307
}
309308

310-
// Convert cursor position to relative coordinates within the layout's geometry
311-
QRectF refGeom = GeometryUtils::effectiveScreenGeometry(layout, screen);
312-
if (refGeom.width() <= 0 || refGeom.height() <= 0) {
313-
return;
314-
}
315-
316-
qreal relX = static_cast<qreal>(x - refGeom.x()) / refGeom.width();
317-
qreal relY = static_cast<qreal>(y - refGeom.y()) / refGeom.height();
318-
319-
// Find zone at cursor position — prefer smallest overlapping zone (FancyZones area-covered heuristic)
320-
Zone* foundZone = nullptr;
321-
qreal bestArea = std::numeric_limits<qreal>::max();
322-
for (auto* zone : layout->zones()) {
323-
QRectF normGeom = zone->normalizedGeometry(refGeom);
324-
if (normGeom.contains(QPointF(relX, relY))) {
325-
qreal area = normGeom.width() * normGeom.height();
326-
if (area < bestArea) {
327-
bestArea = area;
328-
foundZone = zone;
329-
}
330-
}
331-
}
309+
// Find zone at cursor position using layout's smallest-area heuristic
310+
// (zone geometry already recalculated to absolute coords by prepareHandlerContext)
311+
Zone* foundZone = layout->zoneAtPoint(QPointF(x, y));
332312

333313
// Accumulate painted zones (never remove during a paint drag)
334314
if (foundZone) {

0 commit comments

Comments
 (0)