--- a/metadata/move.xml
+++ b/metadata/move.xml
@@ -6,29 +6,13 @@
Window Management
<_short>Move into a monitor zone
- <_long>Starts a move with Shift held on a combined output, allowing Grid to show monitor zones.
+ <_long>Starts a move with Shift held on a combined output, previewing monitor-sized destinations.
<super> <shift> BTN_LEFT
<_short>Activate
<_long>When the specified button is held down, you can drag windows to move them.
<super> BTN_LEFT
-
-
-
- <_short>Snap
- <_long>Snap a dragged window at screen edges and corners. Physical-scope windows use the monitor under the pointer, including internal monitor seams; virtual-scope windows use the combined desktop. Left/right edges select halves, corners select quarters, the top edge maximizes, and the bottom edge selects the bottom half. The preview shows the placement on release.
- true
-
-
- <_short>Snap threshold
- <_long>Sets the amount of pixels from the edge of the screen for which aero-snap gets triggered.
- 10
-
-
- <_short>Quarter snap threshold
- <_long>Sets the amount of pixels from the corner of the screen for which quarter aero-snap gets triggered. The mouse should still be within snap_threshold of at least one edge of the screen.
- 50
<_short>Snap off
@@ -45,25 +29,37 @@
<_long>Disallows independently moving dialogues.
false
-
+
+ <_short>Switch workspace after
+ <_long>Switch workspace when the mouse stays on the output edge for the
+ given duration in milliseconds, wrapping to the opposite side after the last workspace (-1 to disable).
+ -1
+ -1
+
+
+ <_short>Snapping
+ <_short>Snap<_long>Preview the held window after a short dwell at an edge or corner. Release to snap, or pull away to keep moving. Top and bottom edges select halves.true
+ <_short>Edge distance (px)10 0 16384
+ <_short>Corner reach (px)50 0 16384
+ <_short>Snap delay (ms)<_long>Stay in the same snap region for this long before previewing the held window. This is separate from the workspace-switch delay. Zero activates immediately.500 0 5000
+
+
+ <_short>Monitor zones
+ <_short>Shift fills a physical monitor<_long>Hold Shift during a drag to select the physical monitor's usable area instead of an edge or corner.true
+
+
+
<_short>Preview fill color
0.5 0.5 1 0.5
-
+
<_short>Preview border color
0.25 0.25 0.5 0.8
-
+
<_short>Preview border width
3
0
-
- <_short>Switch workspace after
- <_long>Switch workspace when the mouse stays on the output edge for the
- given duration in milliseconds (-1 to disable).
- -1
- -1
-
--- a/metadata/grid.xml
+++ b/metadata/grid.xml
@@ -2,7 +2,7 @@
<_short>Grid
- <_long>Position windows within their physical or VIRTUAL workarea. Repeating a position shortcut cycles through 100%, 80%, and 60% of that slot's normal width and height. Corners remain anchored; sides and center stay centered. A different position starts at 100%.
+ <_long>Position windows on a physical monitor or the VIRTUAL desktop. Repeating a position shortcut cycles through 100%, 80%, and 60% of its normal size.
Window Management
<_short>Toggle physical and VIRTUAL scope
@@ -20,7 +20,7 @@
<_short>Shift selects monitor zones
- <_long>On combined outputs, hold Shift while maximizing or dragging to fill a physical monitor zone. Dragging shows a preview.
+ <_long>On combined outputs, hold Shift while maximizing to fill a physical monitor zone. Drag snapping is configured in Move.
true
--- a/plugins/grid/grid.cpp
+++ b/plugins/grid/grid.cpp
@@ -15,7 +15,6 @@
#include "wayfire/plugin.hpp"
#include "wayfire/signal-definitions.hpp"
#include
-#include
#include "wayfire/plugins/grid.hpp"
#include "wayfire/plugins/crossfade.hpp"
#include
@@ -31,43 +30,6 @@
namespace
{
const std::string grid_view_id = "grid-view";
-
-class grid_preview_node_t : public wf::scene::translation_node_t
-{
- public:
- std::optional find_node_at(const wf::pointf_t&) override
- {
- return {};
- }
-};
-
-class grid_preview_t : public wf::preview_indication_t
-{
- std::shared_ptr overlay;
-
- public:
- grid_preview_t(wf::geometry_t target, wf::output_t *output) :
- wf::preview_indication_t(target, output, "move")
- {
- // The move helper renders the held window above all ordinary layers.
- // Keep its drop target visible above that window, without taking input.
- overlay = std::make_shared();
- overlay->set_offset(wf::origin(output->get_layout_geometry()));
- wf::scene::readd_front(overlay, view->get_root_node());
- wf::scene::add_front(wf::get_core().scene(), overlay);
- view->set_geometry(target);
- // Fade in at the actual destination; growing from the pointer makes
- // short visits to an edge look like incomplete or moving targets.
- set_target_geometry(target, 1);
- }
-
- ~grid_preview_t() override
- {
- view->damage();
- view->close();
- wf::scene::remove_child(overlay);
- }
-};
class grid_scope_toggle_data_t : public wf::custom_data_t
{
@@ -144,16 +106,6 @@
}
wf::option_wrapper_t zone_shift{"grid/zone_shift"};
wf::option_wrapper_t zone_gap{"grid/zone_gap"};
- wf::option_wrapper_t snap_threshold{"move/snap_threshold"};
- wf::option_wrapper_t quarter_snap_threshold{"move/quarter_snap_threshold"};
- struct
- {
- std::shared_ptr preview;
- wf::grid::slot_t slot_id = wf::grid::SLOT_NONE;
- std::string zone;
- wf::geometry_t geometry = {0, 0, 0, 0};
- } slot;
-
wf::plugin_activation_data_t grab_interface{
.name = "grid",
.capabilities = wf::CAPABILITY_MANAGE_DESKTOP,
@@ -246,69 +198,6 @@
data->zone = zone.name;
ensure_grid_view(view)->adjust_target_geometry(
adjust_for_workspace(get_view_wset(view), zone.geometry, workspace), wf::TILED_EDGES_ALL);
- }
-
- void clear_zone_preview()
- {
- slot.preview.reset();
- }
-
- bool handle_zone_move(wf::grid::grid_handle_move_signal *ev)
- {
- auto layout = ev->output->get_data();
- std::optional zone;
- if (layout && shift_zones_active() && (ev->operation != wf::grid::MOVE_OP_CLEAR_PREVIEW))
- {
- for (auto& candidate : layout->zones)
- {
- auto& g = candidate.geometry;
- if (ev->input.x >= g.x && ev->input.x < g.x + g.width &&
- ev->input.y >= g.y && ev->input.y < g.y + g.height)
- {
- zone = find_zone(ev->output, candidate.name);
- break;
- }
- }
- }
-
- if (!zone)
- {
- if (!slot.zone.empty())
- {
- clear_zone_preview();
- slot.zone.clear();
- slot.slot_id = wf::grid::SLOT_NONE;
- }
-
- return false;
- }
-
- if (ev->operation == wf::grid::MOVE_OP_DROP)
- {
- if (can_adjust_view(ev->view) && get_view_wset(ev->view))
- {
- if (wf::window_scope_policy(ev->view) == "physical")
- {
- wf::select_window_scope_monitor(ev->view, zone->name);
- }
- place_in_zone(ev->view, *zone, get_view_wset(ev->view)->get_current_workspace());
- }
-
- clear_zone_preview();
- slot.zone.clear();
- slot.slot_id = wf::grid::SLOT_NONE;
- return true;
- }
-
- if (slot.zone != zone->name)
- {
- clear_zone_preview();
- slot.slot_id = wf::grid::SLOT_NONE;
- slot.zone = zone->name;
- slot.preview = std::make_shared(zone->geometry, ev->output);
- }
-
- return true;
}
public:
@@ -433,113 +322,25 @@
});
}
- wf::get_core().connect(&grid_handle_move_signal_cb);
+ wf::get_core().connect(&on_snap_placement);
wf::get_core().output_layout->connect(&on_layout_changed);
}
- wf::signal::connection_t grid_handle_move_signal_cb =
- [=] (wf::grid::grid_handle_move_signal *ev)
- {
+ wf::signal::connection_t on_snap_placement =
+ [this] (wf::grid::snap_placement_signal *ev)
+ {
+ if (ev->carried_out || !can_adjust_view(ev->view) || !get_view_wset(ev->view)) return;
ev->carried_out = true;
- if (ev->operation == wf::grid::MOVE_OP_CLEAR_PREVIEW || !ev->view ||
- !can_adjust_view(ev->view) || ev->view->pending_fullscreen())
- {
- clear_zone_preview();
- slot.zone.clear();
- slot.slot_id = wf::grid::SLOT_NONE;
- return;
- }
-
- if (handle_zone_move(ev))
- {
- return;
- }
- auto drag_area = ev->output->workarea->get_workarea();
- auto policy = wf::window_scope_policy(ev->view);
- std::optional drag_zone;
- if (policy != "virtual")
- {
- if (auto layout = ev->output->get_data())
- {
- for (auto& zone : layout->zones)
- {
- // Half-open bounds give each seam to exactly one monitor.
- auto& g = zone.geometry;
- if (ev->input.x >= g.x && ev->input.x < g.x + g.width &&
- ev->input.y >= g.y && ev->input.y < g.y + g.height &&
- (policy == "physical" || zone.name == policy))
- {
- drag_zone = zone;
- break;
- }
- }
-
- // Empty space in an uneven layout is not a VIRTUAL snap target.
- if (!drag_zone)
- {
- clear_zone_preview();
- slot.slot_id = wf::grid::SLOT_NONE;
- return;
- }
-
- drag_area = wf::physical_zone_workarea(ev->output, *drag_zone);
- }
- }
- wf::grid::slot_t new_slot_id =
- wf::grid::calc_slot(drag_area, ev->input, snap_threshold, quarter_snap_threshold);
- auto slot_geometry = wf::grid::get_slot_dimensions(drag_area, new_slot_id);
-
- if ((ev->operation == wf::grid::MOVE_OP_DROP) && new_slot_id)
- {
- if (!get_view_wset(ev->view))
- {
- clear_zone_preview();
- slot.slot_id = wf::grid::SLOT_NONE;
- return;
- }
-
- // The pointer chooses the drop monitor, even when the grabbed
- // window's center is still on the other side of a seam.
- if (drag_zone && policy == "physical")
- {
- wf::select_window_scope_monitor(ev->view, drag_zone->name);
- }
- ev->view->toplevel()->pending().tiled_edges = wf::grid::get_tiled_edges_for_slot(new_slot_id);
- ev->view->erase_data();
- ev->view->get_data_safe()->slot = new_slot_id;
- ev->view->get_data_safe()->zone.clear();
- ensure_grid_view(ev->view)->adjust_target_geometry(
- adjust_for_workspace(get_view_wset(ev->view), slot_geometry,
- get_view_wset(ev->view)->get_current_workspace()),
- ev->view->toplevel()->pending().tiled_edges);
- new_slot_id = wf::grid::slot_t::SLOT_NONE;
- }
-
- /* No changes in the slot, just return */
- if (slot.slot_id == new_slot_id && (!new_slot_id ||
- (slot.preview && slot.preview->get_output() == ev->output && slot.geometry == slot_geometry)))
- {
- return;
- }
-
- // Remove the old target immediately, including its scene node. A
- // shrinking ghost should not trail the pointer or outlive the drag.
- clear_zone_preview();
-
- slot.slot_id = new_slot_id;
- slot.geometry = slot_geometry;
-
- /* Show a preview overlay */
- if (new_slot_id)
- {
- /* Unknown slot geometry, can't show a preview */
- if ((slot_geometry.width <= 0) || (slot_geometry.height <= 0))
- {
- return;
- }
-
- slot.preview = std::make_shared(slot_geometry, ev->output);
- }
+ ev->view->erase_data();
+ auto data = ev->view->get_data_safe();
+ data->slot = ev->slot;
+ data->zone = ev->monitor_zone;
+ // Snap Zones already animates the held window with Wobbly. Commit
+ // once, without starting Grid's separate keyboard-placement effect.
+ ev->view->erase_data();
+ wf::get_core().default_wm->update_last_windowed_geometry(ev->view);
+ ev->view->toplevel()->pending().tiled_edges = wf::grid::get_tiled_edges_for_slot(ev->slot);
+ ev->view->set_geometry(ev->geometry);
};
void handle_new_output(wf::output_t *output) override
@@ -550,20 +351,10 @@
output->connect(&on_tiled);
}
- void handle_output_removed(wf::output_t *output) override
- {
- // A group can disappear during an active drag.
- if (slot.preview && slot.preview->get_output() == output)
- {
- clear_zone_preview();
- slot.zone.clear();
- slot.slot_id = wf::grid::SLOT_NONE;
- }
- }
+ void handle_output_removed(wf::output_t*) override {}
void fini() override
{
- clear_zone_preview();
fini_output_tracking();
}
@@ -649,13 +440,7 @@
}
}
- if (slot.preview && !slot.zone.empty())
- {
- if (auto zone = find_zone(slot.preview->get_output(), slot.zone))
- {
- slot.preview->set_target_geometry(zone->geometry, 1);
- }
- }
+
}
wf::signal::connection_t on_layout_changed =
--- a/plugins/grid/wayfire/plugins/grid.hpp
+++ b/plugins/grid/wayfire/plugins/grid.hpp
@@ -13,6 +13,9 @@
MOVE_OP_CLEAR_PREVIEW = 0,
MOVE_OP_UPDATE_PREVIEW = 1,
MOVE_OP_DROP = 2,
+ // Remove temporary drag transforms before the shared helper ungrabs.
+ // Keep the armed destination until MOVE_OP_DROP commits the placement.
+ MOVE_OP_FINISH_PREVIEW = 3,
};
/**
@@ -30,6 +33,19 @@
/* input coordinates in output-local space */
wf::point_t input;
wayfire_toplevel_view view;
+};
+
+/** Optional Grid integration for an independently selected snap destination.
+ * Emitted on core by Move. Without Grid the caller applies the geometry.
+ * Grid records the selected slot for its normal keyboard and scope reflows.
+ */
+struct snap_placement_signal
+{
+ wayfire_toplevel_view view;
+ wf::geometry_t geometry;
+ int slot;
+ std::string monitor_zone;
+ bool carried_out = false;
};
/**
--- a/plugins/single_plugins/move.cpp
+++ b/plugins/single_plugins/move.cpp
@@ -22,11 +22,11 @@
#include
#include
-#include
#include
#include
#include
+#include "move-snap.hpp"
namespace
{
@@ -47,7 +47,6 @@
}
};
- wf::option_wrapper_t enable_snap{"move/enable_snap"};
wf::option_wrapper_t join_views{"move/join_views"};
wf::option_wrapper_t snap_threshold{"move/snap_threshold"};
wf::option_wrapper_t quarter_snap_threshold{"move/quarter_snap_threshold"};
@@ -56,12 +55,6 @@
wf::option_wrapper_t move_enable_snap_off{"move/enable_snap_off"};
wf::option_wrapper_t move_snap_off_threshold{"move/snap_off_threshold"};
-
- struct
- {
- std::shared_ptr preview;
- wf::grid::slot_t slot_id = wf::grid::SLOT_NONE;
- } slot;
wf::wl_timer workspace_switch_timer;
wf::grid::slot_t workspace_switch_slot_id = wf::grid::SLOT_NONE;
@@ -122,25 +115,13 @@
wf::move_drag::adjust_view_on_output(ev);
wf::update_window_scope_after_move(ev->main_view);
- if (enable_snap)
- {
- auto input = get_input_coords();
- wf::grid::grid_handle_move_signal grid_signal;
- grid_signal.output = output;
- grid_signal.input = input;
- grid_signal.operation = wf::grid::MOVE_OP_DROP;
- grid_signal.view = ev->main_view;
- wf::get_core().emit(&grid_signal);
-
- if (!grid_signal.carried_out && (slot.slot_id != wf::grid::SLOT_NONE))
- {
- wf::get_core().default_wm->tile_request(ev->main_view,
- wf::grid::get_tiled_edges_for_slot(slot.slot_id));
-
- /* Update slot, will hide the preview as well */
- update_slot(wf::grid::MOVE_OP_CLEAR_PREVIEW);
- }
- }
+ // Move owns delayed snapping and the provisional held-window preview.
+ wf::grid::grid_handle_move_signal snap_signal;
+ snap_signal.output = output;
+ snap_signal.input = get_input_coords();
+ snap_signal.operation = wf::grid::MOVE_OP_DROP;
+ snap_signal.view = ev->main_view;
+ wf::get_core().emit(&snap_signal);
wf::get_core().default_wm->set_view_grabbed(ev->main_view, false);
@@ -242,6 +223,7 @@
return;
}
+ update_slot(wf::grid::MOVE_OP_FINISH_PREVIEW);
drag_helper->handle_input_released();
return;
}
@@ -404,6 +386,7 @@
return;
}
+ update_slot(wf::grid::MOVE_OP_FINISH_PREVIEW);
drag_helper->handle_input_released();
}
@@ -452,24 +435,17 @@
return;
}
- wf::point_t cws = output->wset()->get_current_workspace();
- wf::point_t tws = {cws.x + dx, cws.y + dy};
- wf::dimensions_t ws_dim = output->wset()->get_workspace_grid_size();
- wf::geometry_t possible = {
- 0.0, 0.0, (double)ws_dim.width, (double)ws_dim.height
- };
-
- /* Outside of workspace grid */
- if (!(possible & tws))
- {
- workspace_switch_timer.disconnect();
-
- return;
- }
-
- workspace_switch_timer.set_timeout(workspace_switch_after, [this, tws] ()
- {
- output->wset()->request_workspace(tws);
+ workspace_switch_timer.set_timeout(workspace_switch_after, [this, dx, dy] ()
+ {
+ auto current = output->wset()->get_current_workspace();
+ auto size = output->wset()->get_workspace_grid_size();
+ if (size.width < 1 || size.height < 1) return;
+ // Continue at the opposite edge, including the last-to-first and
+ // first-to-last transitions of a horizontal workspace row.
+ wf::point_t target{
+ (current.x + dx + size.width) % size.width,
+ (current.y + dy + size.height) % size.height};
+ if (target != current) output->wset()->request_workspace(target);
});
}
@@ -488,45 +464,6 @@
grid_signal.view = drag_helper->view;
wf::get_core().emit(&grid_signal);
- if (grid_signal.carried_out)
- {
- update_workspace_switch_timeout(input, !operation);
- return;
- }
-
- /* if the grid plugin isn't loaded, we're handling the preview for maximize */
- wf::grid::slot_t old_slot_id = slot.slot_id;
- slot.slot_id = !operation ? wf::grid::SLOT_NONE :
- wf::grid::calc_slot(output, input, snap_threshold, quarter_snap_threshold);
- if (slot.slot_id != wf::grid::SLOT_CENTER)
- {
- slot.slot_id = wf::grid::SLOT_NONE;
- }
-
- if (old_slot_id == slot.slot_id)
- {
- return;
- }
-
- if (slot.preview)
- {
- slot.preview->set_target_geometry(wf::pointf_t{(double)input.x, (double)input.y}, 0,
- true);
- slot.preview = nullptr;
- }
-
- if (slot.slot_id)
- {
- wf::geometry_t slot_geometry = output->workarea->get_workarea();
- /* only show a preview with valid geometry */
- if ((slot_geometry.width > 0) || (slot_geometry.height > 0))
- {
- slot.preview = std::make_shared(
- wf::pointf_t{(double)input.x, (double)input.y}, output, "move");
- slot.preview->set_target_geometry(slot_geometry, 1);
- }
- }
-
update_workspace_switch_timeout(input, !operation);
}
@@ -556,7 +493,7 @@
bool is_snap_enabled()
{
- if (!enable_snap || !drag_helper->view || drag_helper->is_view_held_in_place())
+ if (!drag_helper->view || drag_helper->is_view_held_in_place())
{
return false;
}
@@ -596,6 +533,22 @@
output->rem_binding(&activate_zone_binding);
}
};
+
+class wayfire_move_plugin : public wf::per_output_plugin_t
+{
+ move_snap_t snapping;
+ public:
+ void init() override
+ {
+ snapping.init();
+ wf::per_output_plugin_t::init();
+ }
+ void fini() override
+ {
+ snapping.fini();
+ wf::per_output_plugin_t::fini();
+ }
+};
}
-DECLARE_WAYFIRE_PLUGIN(wf::per_output_plugin_t);
+DECLARE_WAYFIRE_PLUGIN(wayfire_move_plugin);
--- /dev/null
+++ b/plugins/single_plugins/move-snap.hpp
@@ -0,0 +1,293 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "move-snap-map.hpp"
+
+namespace
+{
+using Clock = std::chrono::steady_clock;
+constexpr const char *preview_name = "move-held-preview";
+
+// This changes only the rendered drag texture. The client keeps its original
+// geometry until release. Wobbly, above this node, supplies the existing spring
+// animation; there is no separate animation clock or destination overlay.
+class held_preview_t : public wf::scene::transformer_base_node_t
+{
+ class instance_t : public wf::scene::transformer_render_instance_t
+ {
+ public:
+ using transformer_render_instance_t::transformer_render_instance_t;
+ void transform_damage_region(wf::regionf_t& region) override
+ {
+ region |= self->target;
+ }
+ void render(const wf::scene::render_instruction_t& data) override
+ {
+ data.pass->add_texture(get_texture(data.target.scale), data.target,
+ self->target, data.damage);
+ }
+ };
+ public:
+ wf::geometry_t target;
+ explicit held_preview_t(wf::geometry_t target) : transformer_base_node_t(false), target(target) {}
+ wf::geometry_t get_bounding_box() override { return target; }
+ std::optional find_node_at(const wf::pointf_t&) override { return {}; }
+ std::string stringify() const override { return preview_name; }
+ void gen_render_instances(std::vector& instances,
+ wf::scene::damage_callback damage, wf::output_t *output) override
+ {
+ instances.push_back(std::make_unique(this, damage, output));
+ }
+};
+
+class move_snap_t : public wf::per_output_tracker_mixin_t<>
+{
+ wf::shared_data::ref_ptr_t repository;
+ wf::shared_data::ref_ptr_t drag;
+ move_snap_map_t map;
+ wf::option_wrapper_t edge{"move/snap_threshold"}, corner{"move/quarter_snap_threshold"},
+ delay{"move/snap_delay"};
+ wf::option_wrapper_t enabled{"move/enable_snap"}, shift_zones{"move/zone_shift"};
+ wf::wl_timer dwell, handoff_timeout;
+ struct destination_t
+ {
+ std::weak_ptr view;
+ wf::output_t *output = nullptr;
+ wf::geometry_t geometry{};
+ int slot = 0;
+ std::string monitor;
+ bool whole_monitor = false;
+ } destination;
+ Clock::time_point deadline;
+ bool ready = false, handing_off = false;
+ bool wobbly_grab_suspended = false;
+ wf::pointf_t grab_position{};
+ std::weak_ptr preview_view;
+ std::shared_ptr preview;
+
+ bool eligible(wayfire_toplevel_view view)
+ {
+ const auto actions = wf::VIEW_ALLOW_MOVE | wf::VIEW_ALLOW_RESIZE;
+ return view && view->is_mapped() && view->get_output() && !view->pending_fullscreen() &&
+ view->role != wf::VIEW_ROLE_DESKTOP_ENVIRONMENT &&
+ (view->get_allowed_actions() & actions) == actions;
+ }
+
+ destination_t select(wf::grid::grid_handle_move_signal *ev)
+ {
+ destination_t result;
+ if (!enabled || !eligible(ev->view) || !ev->output || !(ev->output->get_relative_geometry() & ev->input)) return result;
+ auto area = ev->output->workarea->get_workarea();
+ auto policy = wf::window_scope_policy(ev->view);
+ const bool whole = shift_zones && (wf::get_core().seat->get_keyboard_modifiers() & WLR_MODIFIER_SHIFT);
+ if (auto layout = ev->output->get_data();
+ layout && !layout->zones.empty() && (policy != "virtual" || whole))
+ {
+ bool found = false;
+ for (const auto& zone : layout->zones)
+ {
+ const auto& g = zone.geometry;
+ if (ev->input.x >= g.x && ev->input.x < g.x + g.width &&
+ ev->input.y >= g.y && ev->input.y < g.y + g.height &&
+ (whole || policy == "physical" || zone.name == policy))
+ {
+ result.monitor = zone.name;
+ area = wf::physical_zone_workarea(ev->output, zone);
+ found = true;
+ break;
+ }
+ }
+ if (!found) return result;
+ }
+ result.whole_monitor = whole && !result.monitor.empty();
+ result.slot = result.whole_monitor ? wf::grid::SLOT_CENTER : snap_slot(area, ev->input, edge, corner);
+ if (!result.slot) return {};
+ result.geometry = wf::grid::get_slot_dimensions(area, result.slot);
+ if (result.geometry.width <= 0 || result.geometry.height <= 0) return {};
+ result.view = ev->view->weak_from_this();
+ result.output = ev->output;
+ return result;
+ }
+
+ bool same(const destination_t& a, const destination_t& b)
+ {
+ return a.slot && a.slot == b.slot && a.view.lock() == b.view.lock() && a.output == b.output &&
+ a.geometry == b.geometry && a.monitor == b.monitor && a.whole_monitor == b.whole_monitor;
+ }
+
+ void remove_preview(bool restore_grab = true)
+ {
+ if (auto view = preview_view.lock())
+ {
+ view->get_transformed_node()->rem_transformer(preview_name);
+ if (restore_grab)
+ {
+ // Resume the visual grab before unpinning the corners. The
+ // actual Move grab has stayed active throughout the preview.
+ if (wobbly_grab_suspended && view->is_mapped() && drag->view.get() == view.get())
+ start_wobbly(view, grab_position.x, grab_position.y);
+ set_tiled_wobbly(view, false);
+ }
+ }
+ wobbly_grab_suspended = false;
+ preview.reset(); preview_view.reset();
+ handing_off = false; handoff_timeout.disconnect();
+ }
+
+ void clear()
+ {
+ dwell.disconnect(); remove_preview(); destination = {}; ready = false;
+ }
+
+ void show_preview(wayfire_toplevel_view view, wf::geometry_t geometry)
+ {
+ remove_preview();
+ // Move starts Wobbly first. Equal priority inserts this node directly
+ // below that existing transformer and above Move's drag transformer.
+ activate_wobbly(view);
+ preview = std::make_shared(geometry);
+ preview_view = view->weak_from_this();
+ view->get_transformed_node()->add_transformer(preview, wf::TRANSFORMER_HIGHLEVEL, preview_name);
+ set_tiled_wobbly(view, true);
+ if (drag->view.get() == view.get())
+ {
+ // Let the existing springs finish at the destination while the
+ // mouse remains held. Keeping Wobbly grabbed would bow the surface
+ // toward the pointer indefinitely instead of completing the resize.
+ end_wobbly(view);
+ wobbly_grab_suspended = true;
+ }
+ }
+
+ void arm()
+ {
+ auto view = destination.view.lock();
+ if (!enabled || !eligible(view) || drag->view.get() != view.get() || drag->is_view_held_in_place()) { clear(); return; }
+ ready = true;
+ show_preview(view, destination.geometry + wf::origin(destination.output->get_layout_geometry()));
+ }
+
+ void place(wayfire_toplevel_view view)
+ {
+ if (wf::window_scope_policy(view) == "physical" && !destination.monitor.empty())
+ wf::select_window_scope_monitor(view, destination.monitor);
+ // Hand the same rendered destination to the real surface while its
+ // resize transaction waits for the client. No flash of the old size.
+ show_preview(view, destination.geometry);
+ handing_off = true;
+ modify_wobbly(view, destination.geometry);
+ wf::grid::snap_placement_signal placement{
+ view, destination.geometry, destination.slot,
+ destination.whole_monitor ? destination.monitor : std::string{}, false};
+ wf::get_core().emit(&placement);
+ if (!placement.carried_out)
+ {
+ wf::get_core().default_wm->update_last_windowed_geometry(view);
+ view->toplevel()->pending().tiled_edges = wf::grid::get_tiled_edges_for_slot(destination.slot);
+ view->set_geometry(destination.geometry);
+ }
+ // A nonresponsive client must not leave a permanent visual transform.
+ handoff_timeout.set_timeout(1200, [this] { remove_preview(); });
+ }
+
+ wf::signal::connection_t on_move = [this] (auto *ev)
+ {
+ ev->carried_out = true;
+ if (ev->output)
+ {
+ auto origin = wf::origin(ev->output->get_layout_geometry());
+ grab_position = {double(ev->input.x + origin.x), double(ev->input.y + origin.y)};
+ }
+ if (ev->operation == wf::grid::MOVE_OP_CLEAR_PREVIEW) { clear(); return; }
+ auto next = select(ev);
+ if (ev->operation == wf::grid::MOVE_OP_FINISH_PREVIEW)
+ {
+ ready = same(destination, next) && Clock::now() >= deadline;
+ dwell.disconnect(); remove_preview(!ready);
+ return;
+ }
+ if (ev->operation == wf::grid::MOVE_OP_DROP)
+ {
+ const bool commit = ready && same(destination, next);
+ dwell.disconnect();
+ if (commit) place(ev->view); else remove_preview();
+ destination = {}; ready = false;
+ return;
+ }
+ if (!next.slot) { clear(); return; }
+ if (same(destination, next)) return;
+ clear(); destination = next;
+ const int ms = std::clamp(delay.value(), 0, 5000);
+ deadline = Clock::now() + std::chrono::milliseconds(ms);
+ if (!ms) arm(); else dwell.set_timeout(ms, [this] { arm(); });
+ };
+
+ // Without Grid, ordinary tiling reflows still preserve half/quarter sizes.
+ wf::signal::connection_t on_tile = [this] (auto *ev)
+ {
+ if (ev->carried_out || !eligible(ev->view) || !ev->edges) return;
+ int slot = wf::grid::get_slot_from_tiled_edges(ev->edges);
+ if (!slot) return;
+ auto area = wf::window_scope_workarea(ev->view);
+ auto g = wf::grid::get_slot_dimensions(area, slot);
+ auto output = ev->view->get_output();
+ auto delta = ev->workspace - output->wset()->get_current_workspace();
+ g.x += delta.x * output->get_screen_size().width;
+ g.y += delta.y * output->get_screen_size().height;
+ ev->carried_out = true;
+ ev->view->set_geometry(g);
+ };
+ wf::signal::connection_t on_geometry = [this] (auto *ev)
+ {
+ if (handing_off && preview_view.lock().get() == ev->view.get()) remove_preview();
+ };
+ wf::signal::connection_t on_unmap = [this] (auto *ev)
+ {
+ if (destination.view.lock().get() == ev->view.get() || preview_view.lock().get() == ev->view.get()) clear();
+ };
+ wf::signal::connection_t on_layout = [this] (auto) { clear(); };
+ wf::signal::connection_t on_workarea = [this] (auto) { clear(); };
+ wf::signal::connection_t on_config = [this] (auto) { clear(); };
+ public:
+ void handle_new_output(wf::output_t *output) override
+ {
+ output->connect(&on_tile); output->connect(&on_workarea);
+ }
+ void handle_output_removed(wf::output_t*) override { clear(); }
+ void init()
+ {
+ init_output_tracking(); map.init();
+ enabled.set_callback([this] { if (!enabled) clear(); });
+ wf::get_core().connect(&on_move);
+ wf::get_core().connect(&on_geometry);
+ wf::get_core().connect(&on_unmap);
+ wf::get_core().connect(&on_config);
+ wf::get_core().output_layout->connect(&on_layout);
+ repository->register_method("move/snap-status", [this] (wf::json_t)
+ {
+ auto reply = wf::ipc::json_ok();
+ reply["ready"] = ready; reply["preview"] = bool(preview); reply["handoff"] = handing_off;
+ reply["slot"] = destination.slot; reply["monitor"] = destination.monitor;
+ reply["target"] = wf::ipc::geometry_to_json(preview ? preview->target : destination.geometry);
+ reply["delay"] = delay.value(); reply["preview_style"] = "held-window-wobbly";
+ return reply;
+ });
+ }
+ void fini()
+ {
+ clear(); map.fini(); repository->unregister_method("move/snap-status"); fini_output_tracking();
+ on_move.disconnect(); on_geometry.disconnect(); on_unmap.disconnect();
+ on_config.disconnect(); on_layout.disconnect();
+ }
+};
+}
--- /dev/null
+++ b/plugins/single_plugins/move-snap-map.hpp
@@ -0,0 +1,224 @@
+#pragma once
+
+#include "move-snap-geometry.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+struct snap_region_t
+{
+ wf::geometry_t geometry;
+ int slot;
+};
+
+// Split only where calc_slot can change its answer. Both the map and desktop
+// overlay use this partition of the same workarea and physical pointer bounds
+// used by a real drag; panel reservations and gaps are included naturally.
+std::vector snap_regions(wf::geometry_t bounds, wf::geometry_t area, int edge, int corner)
+{
+ auto cuts = [] (double lo, double hi, double a, double b, int edge, int corner, bool horizontal)
+ {
+ std::vector values{lo, hi};
+ // Match calc_slot's integer pointer coordinates and asymmetric < / <= tests.
+ auto near = [horizontal] (double v) { return horizontal ? std::floor(v) + 1 : std::ceil(v); };
+ auto far = [horizontal] (double v) { return horizontal ? std::ceil(v) : std::floor(v) + 1; };
+ for (double v : {near(a + edge), far(b - edge), near(a + corner), far(b - corner)})
+ if (v > lo && v < hi) values.push_back(v);
+ std::sort(values.begin(), values.end());
+ values.erase(std::unique(values.begin(), values.end()), values.end());
+ return values;
+ };
+ auto xs = cuts(bounds.x, bounds.x + bounds.width, area.x, area.x + area.width, edge, corner, true);
+ auto ys = cuts(bounds.y, bounds.y + bounds.height, area.y, area.y + area.height, edge, corner, false);
+ std::vector regions;
+ for (size_t y = 1; y < ys.size(); ++y)
+ for (size_t x = 1; x < xs.size(); ++x)
+ {
+ // calc_slot accepts integer pointer coordinates, as does Move.
+ wf::point_t p{int(std::ceil(xs[x - 1])), int(std::ceil(ys[y - 1]))};
+ if (p.x >= xs[x] || p.y >= ys[y]) continue;
+ int slot = snap_slot(area, p, edge, corner);
+ if (!slot) continue;
+ wf::geometry_t g{xs[x - 1], ys[y - 1], xs[x] - xs[x - 1], ys[y] - ys[y - 1]};
+ // Merge adjacent cells to avoid displaying implementation seams.
+ if (!regions.empty() && regions.back().slot == slot && regions.back().geometry.y == g.y &&
+ regions.back().geometry.height == g.height &&
+ regions.back().geometry.x + regions.back().geometry.width == g.x)
+ regions.back().geometry.width += g.width;
+ else regions.push_back({g, slot});
+ }
+ for (size_t i = 0; i < regions.size(); ++i)
+ for (size_t j = i + 1; j < regions.size();)
+ {
+ auto& a = regions[i]; auto& b = regions[j];
+ if (a.slot == b.slot && a.geometry.x == b.geometry.x && a.geometry.width == b.geometry.width &&
+ a.geometry.y + a.geometry.height == b.geometry.y)
+ {
+ a.geometry.height += b.geometry.height;
+ regions.erase(regions.begin() + j);
+ } else ++j;
+ }
+ return regions;
+}
+
+class move_snap_map_node_t : public wf::scene::node_t
+{
+ class render_instance_t : public wf::scene::simple_render_instance_t
+ {
+ public:
+ using simple_render_instance_t::simple_render_instance_t;
+ void render(const wf::scene::render_instruction_t& data) override
+ {
+ for (auto& r : self->regions)
+ {
+ bool corner = r.slot == 1 || r.slot == 3 || r.slot == 7 || r.slot == 9;
+ wf::color_t color = corner ? wf::color_t{0.68, 0.48, 0.96, 0.42} : wf::color_t{0.26, 0.72, 0.90, 0.38};
+ data.pass->add_rect(color, data.target, r.geometry, data.damage);
+ }
+ }
+ };
+ std::vector regions;
+ wf::geometry_t bounds;
+ public:
+ move_snap_map_node_t(std::vector regions, wf::geometry_t bounds) :
+ node_t(false), regions(std::move(regions)), bounds(bounds) {}
+ std::optional find_node_at(const wf::pointf_t&) override { return {}; }
+ wf::geometry_t get_bounding_box() override { return bounds; }
+ std::string stringify() const override { return "move-snap-map"; }
+ void gen_render_instances(std::vector& instances,
+ wf::scene::damage_callback damage, wf::output_t *output) override
+ {
+ instances.push_back(std::make_unique(this, damage, output));
+ }
+};
+
+class move_snap_map_t
+{
+ wf::shared_data::ref_ptr_t repository;
+ wf::option_wrapper_t edge{"move/snap_threshold"}, corner{"move/quarter_snap_threshold"};
+ wf::option_wrapper_t delay{"move/snap_delay"};
+ wf::option_wrapper_t enabled{"move/enable_snap"};
+ wf::wl_timer lease;
+ std::shared_ptr overlay;
+ std::string owner, scope = "physical", displayed;
+ std::vector render_regions;
+ wf::geometry_t render_bounds;
+
+ wf::json_t snapshot(const std::string& selected_scope)
+ {
+ auto reply = wf::ipc::json_ok(); reply["version"] = 1;
+ reply["edge"] = edge.value(); reply["corner"] = corner.value(); reply["enabled"] = enabled.value(); reply["delay"] = delay.value();
+ reply["config_file"] = std::string(getenv("WAYFIRE_CONFIG_FILE") ?: "");
+ reply["scope"] = selected_scope; reply["monitors"] = wf::json_t::array();
+ render_regions.clear();
+ const std::vector names{"", "bl", "b", "br", "l", "c", "r", "tl", "t", "tr"};
+ bool first = true;
+ auto add = [&] (wf::output_t *output, std::string name, wf::geometry_t bounds, wf::geometry_t area)
+ {
+ auto origin = wf::origin(output->get_layout_geometry());
+ auto global_bounds = bounds + origin;
+ auto monitor = wf::json_t(); monitor["name"] = name;
+ monitor["geometry"] = wf::ipc::geometry_to_json(global_bounds);
+ monitor["workarea"] = wf::ipc::geometry_to_json(area + origin);
+ monitor["regions"] = wf::json_t::array();
+ if (first) { render_bounds = global_bounds; first = false; }
+ else
+ {
+ auto x = std::min(render_bounds.x, global_bounds.x), y = std::min(render_bounds.y, global_bounds.y);
+ render_bounds = {x, y, std::max(render_bounds.x + render_bounds.width, global_bounds.x + global_bounds.width) - x,
+ std::max(render_bounds.y + render_bounds.height, global_bounds.y + global_bounds.height) - y};
+ }
+ for (auto r : snap_regions(bounds, area, edge, corner))
+ {
+ r.geometry = r.geometry + origin;
+ wf::json_t region; region["geometry"] = wf::ipc::geometry_to_json(r.geometry);
+ region["slot"] = names[r.slot];
+ region["target"] = wf::ipc::geometry_to_json(wf::grid::get_slot_dimensions(area, r.slot) + origin);
+ monitor["regions"].append(region); render_regions.push_back(r);
+ }
+ reply["monitors"].append(monitor);
+ };
+ for (auto output : wf::get_core().output_layout->get_outputs())
+ {
+ if (std::string(output->handle->name).find("NOOP-") == 0) continue;
+ auto layout = output->get_data();
+ if (selected_scope == "physical" && layout && !layout->zones.empty())
+ for (auto& zone : layout->zones) add(output, zone.name, zone.geometry, wf::physical_zone_workarea(output, zone));
+ else add(output, output->handle->name, output->get_relative_geometry(), output->workarea->get_workarea());
+ }
+ return reply;
+ }
+
+ void hide()
+ {
+ if (overlay) wf::scene::remove_child(overlay);
+ overlay.reset(); displayed.clear(); owner.clear();
+ lease.disconnect();
+ }
+
+ void show(const wf::json_t& state)
+ {
+ auto content = state.serialize();
+ if (content == displayed) return;
+ if (overlay) wf::scene::remove_child(overlay);
+ overlay.reset(); displayed = content;
+ if (render_regions.empty()) return;
+ overlay = std::make_shared(render_regions, render_bounds);
+ wf::scene::add_front(wf::get_core().scene(), overlay);
+ }
+
+ wf::signal::connection_t layout_changed =
+ [this] (auto) { if (!owner.empty()) show(snapshot(scope)); };
+ wf::signal::connection_t config_changed =
+ [this] (auto) { if (!owner.empty()) show(snapshot(scope)); };
+ wf::signal::connection_t workarea_changed =
+ [this] (auto) { if (!owner.empty()) show(snapshot(scope)); };
+ public:
+ void init()
+ {
+ repository->register_method("move/snap-regions", [this] (wf::json_t data)
+ {
+ std::string selected = "physical";
+ if (data.has_member("scope"))
+ {
+ if (!data["scope"].is_string()) return wf::ipc::json_error("scope must be physical or virtual");
+ selected = data["scope"].as_string();
+ if (selected != "physical" && selected != "virtual") return wf::ipc::json_error("scope must be physical or virtual");
+ }
+ if (data.has_member("visible"))
+ {
+ if (!data["visible"].is_bool() || !data.has_member("owner") || !data["owner"].is_string() ||
+ data["owner"].as_string().empty() || data["owner"].as_string().size() > 128)
+ return wf::ipc::json_error("visible requires a boolean and an owner identifier");
+ auto requested = data["owner"].as_string();
+ if (data["visible"].as_bool())
+ {
+ if (!owner.empty() && owner != requested) return wf::ipc::json_error("Snap zones are already displayed by another settings window.");
+ owner = requested; scope = selected;
+ } else if (owner == requested) hide();
+ }
+ auto reply = snapshot(selected);
+ if (data.has_member("visible") && data["visible"].as_bool())
+ {
+ show(reply);
+ lease.set_timeout(2500, [this] { hide(); });
+ }
+ reply["visible"] = bool(overlay);
+ return reply;
+ });
+ wf::get_core().output_layout->connect(&layout_changed);
+ wf::get_core().connect(&config_changed);
+ wf::get_core().connect(&workarea_changed);
+ }
+ void fini() { hide(); repository->unregister_method("move/snap-regions"); }
+};
+}
--- /dev/null
+++ b/plugins/single_plugins/move-snap-geometry.hpp
@@ -0,0 +1,13 @@
+#pragma once
+#include
+
+namespace
+{
+// Preserve edge/corner precedence and threshold boundaries, but make the top
+// edge symmetric with the bottom instead of maximizing the window.
+inline wf::grid::slot_t snap_slot(wf::geometry_t area, wf::point_t point, int edge, int corner)
+{
+ auto slot = wf::grid::calc_slot(area, point, edge, corner);
+ return slot == wf::grid::SLOT_CENTER ? wf::grid::SLOT_TOP : slot;
+}
+}
--- /dev/null
+++ b/tests/move-snapping.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+"""Exercise the display API in a private compositor, never the active desktop."""
+import json, os, socket, struct, subprocess, tempfile, time
+from PIL import Image, ImageChops
+from pathlib import Path
+
+stage=Path(os.environ.get('WCM_TEST_STAGE','/var/tmp/wayfire-move-snap-20261001'))
+runtime=Path(tempfile.mkdtemp(prefix='scope-toggle-test-'))
+env=os.environ.copy()
+for key in ['WAYLAND_DISPLAY','DISPLAY','WAYFIRE_SOCKET','_WAYFIRE_SOCKET','WAYFIRE_CONFIG_FILE','WAYFIRE_DEFAULT_CONFIG_BACKEND']:
+ env.pop(key,None)
+env.update(GIO_USE_VFS='local',GTK_USE_PORTAL='0',XDG_RUNTIME_DIR=str(runtime),WLR_BACKENDS='headless',WLR_HEADLESS_OUTPUTS='4',WLR_RENDERER='gles2',
+ WLR_RENDER_DRM_DEVICE='/dev/dri/renderD128',WAYFIRE_PLUGIN_PATH=str(stage/'plugins'),WAYFIRE_PLUGIN_XML_PATH=str(stage/'metadata'))
+config=runtime/'wayfire.ini'
+config.write_text('''# Preserve this comment and unrelated settings.
+[core]
+plugins = ipc stipc ipc-rules output-control grid place move resize wm-actions wobbly follow-focus simple-tile cube vswitch
+xwayland = false
+stitch_enabled = true
+stitch_outputs = HEADLESS-1 HEADLESS-2 HEADLESS-3
+default_output = VIRTUAL-1
+window_scope = physical
+window_scope_rules = [{"app_id":"tile-wide*","scope":"virtual"},{"app_id":"scope-pinned","scope":"HEADLESS-3"}]
+[simple-tile]
+key_toggle = none
+tile_by_default = none
+float_on_top = false
+inner_gap_size = 10
+outer_horiz_gap_size = 5
+outer_vert_gap_size = 5
+[wm-actions]
+toggle_always_on_top = KEY_T
+[cube]
+rotate_left = KEY_LEFT
+rotate_right = KEY_RIGHT
+[autostart]
+autostart_wf_shell = false
+[grid]
+toggle_scope = KEY_ENTER
+type = none
+zone_gap = 20
+[output:HEADLESS-1]
+mode = 640x720@60
+position = 0,0
+[output:HEADLESS-2]
+zone_gap = 20
+mode = 640x720@60
+position = 640,0
+[output:HEADLESS-3]
+mode = 640x720@60
+position = 1280,0
+[output:HEADLESS-4]
+mode = 320x240@60
+position = 1920,0
+masked = true
+''')
+if os.environ.get('MOVE_TEST_UPGRADE'):
+ config.write_text(config.read_text().replace('grid place move',str(stage/'libgrid-old.so')+' place '+str(stage/'libmove-old.so')))
+if os.environ.get('GRID_TEST_UPGRADE'):
+ config.write_text(config.read_text().replace('output-control grid place', 'output-control '+str(stage/'libgrid-r19.so')+' place'))
+if os.environ.get('SCOPE_TEST_ANIMATION'):
+ config.write_text(config.read_text().replace('type = none', 'type = crossfade\nduration = 300ms circle'))
+
+log=(runtime/'wayfire.log').open('w')
+compositor_env=env.copy()
+if os.environ.get('WCM_TEST_ASAN'):
+ compositor_env.update(LD_PRELOAD=os.environ['WCM_TEST_ASAN'],ASAN_OPTIONS='detect_leaks=0:abort_on_error=1',UBSAN_OPTIONS='halt_on_error=1:print_stacktrace=1')
+process=subprocess.Popen([os.environ.get('WCM_TEST_COMPOSITOR',str(stage/'wayfire')),'-c',str(config)],env=compositor_env,stdout=log,stderr=log)
+def wait(fn,timeout=10):
+ deadline=time.monotonic()+timeout
+ while time.monotonic()=x and g['y']>=y and g['x']+g['width']<=x+w and g['y']+g['height']<=y+h,(vid,g,(x,y,w,h))
+def toggle_scope(vid, keyboard=False):
+ was=info(vid)['window-scope']
+ if keyboard:
+ focus(vid);keys('KEY_LEFTMETA','KEY_ENTER')
+ else:ipc('grid/toggle_scope',{'view-id':vid})
+ wait(lambda:(info(vid)['window-scope']=='virtual') != (was=='virtual'))
+ time.sleep(.4 if os.environ.get('SCOPE_TEST_ANIMATION') else .1)
+
+def same_geometry(vid, before):
+ expect(vid,**before)
+
+def rect(x,y,w,h):return dict(x=x,y=y,width=w,height=h)
+def slot_rect(area,slot):
+ x,y,w,h=(area[k] for k in ('x','y','width','height'))
+ if slot%3==1:w=int(w/2)
+ elif slot%3==0:x+=int(w/2);w=int(w/2)
+ if slot>=7:h=int(h/2)
+ elif slot<=3:y+=int(h/2);h=int(h/2)
+ return rect(x,y,w,h)
+def move(x,y):ipc('stipc/move_cursor',{'x':x,'y':y});time.sleep(.04)
+def button(mode):ipc('stipc/feed_button',{'combo':'S-BTN_LEFT','mode':mode})
+def prepare(vid,monitor=1):
+ ipc('window-rules/set-window-scope',{'id':vid,'scope':'physical'})
+ ipc('grid/restore',{'view-id':vid})
+ g=rect((monitor-1)*640+140,180,300,180)
+ ipc('window-rules/configure-view',{'id':vid,'geometry':g})
+ expect(vid,**g);time.sleep(.4 if os.environ.get("SCOPE_TEST_ANIMATION") else .05);focus(vid)
+def begin(vid,offset=150):
+ g=geometry(vid);move(g['x']+offset,g['y']+60);button('press')
+ move(g['x']+offset+30,g['y']+60)
+def preview(expected):
+ def found():
+ return any(v.get('mapped') and v.get('type')!='toplevel' and v.get('app-id')!='scope-bar' and v.get('geometry')==expected for v in ipc('window-rules/list-views'))
+ try:wait(found,3)
+ except Exception:
+ print('PREVIEW expected',expected,'views',ipc('window-rules/list-views'),flush=True);raise
+
+def end(vid,expected):
+ button('release');expect(vid,**expected);time.sleep(.25)
+import random
+
+def zones(**kw):return ipc('move/snap-regions',kw)
+def hit(g,x,y):return g['x']<=xcolor[0]+60 and color[2]>color[1]+60 and \
+ ImageChops.difference(interior,Image.new('RGB',interior.size,color)).getbbox() is None and \
+ all(abs(bbox[k]-target[k])<1 for k in target)
+ wait(settled,6)
+ assert status()['ready'] and status()['preview'] and not status()['handoff']
+def options(**kw):ipc('wayfire/set-config-options',{'move/'+k:v for k,v in kw.items()})
+def plugin(name,enabled):
+ current=ipc('wayfire/get-config-option',{'option':'core/plugins'})['value'].split()
+ wanted=[p for p in current if p!=name]
+ if enabled:wanted.append(name)
+ ipc('wayfire/set-config-options',{'core/plugins':' '.join(wanted)})
+ time.sleep(.15)
+def methods():return ipc('list-methods')['methods']
+def destination(m,slot):
+ monitor=next(x for x in zones()['monitors'] if x['name']==f'HEADLESS-{m}')
+ return next(r['target'] for r in monitor['regions'] if r['slot']==slot)
+def arm_at(vid,m,x,y):
+ prepare(vid,m);before=geometry(vid).copy();begin(vid);move((m-1)*640+x,y)
+ wait(lambda:status()['ready']);assert geometry(vid)==before
+ return before
+try:
+ env['WAYFIRE_SOCKET']=str(wait(lambda:next(runtime.glob('wayfire-*.socket'),None)))
+ env['WAYLAND_DISPLAY']=next(p.name for p in runtime.glob('wayland-*') if not p.name.endswith('.lock'))
+ print('Runtime:',runtime,flush=True);wait(lambda:names()==['VIRTUAL-1'])
+ if os.environ.get('MOVE_TEST_UPGRADE'):
+ wait(lambda:'grid/snap-zones' in methods())
+ warm,wid=spawn_at('warm-grid',1);prepare(wid);begin(wid);move(320,350);button('release');time.sleep(.2)
+ ipc('grid/slot_r',{'view-id':wid});time.sleep(.3);saved=geometry(wid)
+ current=ipc('wayfire/get-config-option',{'option':'core/plugins'})['value']
+ current=current.replace(str(stage/'libgrid-old.so'),'grid').replace(str(stage/'libmove-old.so'),'move')
+ ipc('wayfire/set-config-options',{'core/plugins':current})
+ wait(lambda:'move/snap-regions' in methods());assert geometry(wid)==saved
+ ipc('window-rules/close-view',{'id':wid});time.sleep(.2)
+ print('PASS old resident Grid/Move replacement preserves windows and shared drag state',flush=True)
+ wait(lambda:'move/snap-regions' in methods());assert 'grid/snap-zones' not in methods()
+ bar,_=spawn('zone-bar','bar');time.sleep(1.3)
+ for edge,corner in [(10,50),(0,0),(35,90),(90,15),(600,800)]:
+ options(snap_threshold=edge,quarter_snap_threshold=corner)
+ for scope in ['physical','virtual']:check_partition(zones(scope=scope))
+ print('PASS exact physical/VIRTUAL map partitions with top half, corners, gaps and panel reservations',flush=True)
+ options(snap_threshold=10,quarter_snap_threshold=50,snap_delay=500)
+ base=shot('hidden');state=zones(visible=True,owner='test');time.sleep(.15);shown=shot('shown')
+ for m in state['monitors']:
+ for r in m['regions']:
+ g=r['geometry'];p=(int(g['x']+g['width']/2),int(g['y']+g['height']/2))
+ assert base.getpixel(p)!=shown.getpixel(p)
+ ipc('move/snap-regions',{'visible':True,'owner':'other'},fail=True)
+ assert zones(visible=False,owner='other')['visible']
+ zones(visible=False,owner='test');time.sleep(.15)
+ assert shot('hidden-again').tobytes()==base.tobytes()
+ zones(visible=True,owner='test');time.sleep(2.8);assert not zones()['visible']
+ print('PASS map overlay ownership, removal and lease cleanup',flush=True)
+ app,vid=spawn_at('zone-drag',1);prepare(vid);before=geometry(vid).copy();begin(vid);move(320,1)
+ time.sleep(.10);assert not status()['ready'] and not status()['preview'];assert geometry(vid)==before
+ shot('before-delay');wait(lambda:status()['ready']);time.sleep(.8)
+ assert geometry(vid)==before and status()['target']==destination(1,'t')
+ settled_preview(vid,destination(1,'t'),'held-top-preview')
+ move(400,1);time.sleep(.3);settled_preview(vid,destination(1,'t'),'held-top-after-motion')
+ assert geometry(vid)==before
+ print('PASS preview fully settles while button remains down and stays settled during in-zone motion',flush=True)
+ move(360,350);wait(lambda:not status()['preview']);time.sleep(.6)
+ assert geometry(vid)==before
+ move(420,390)
+ # Wobbly rounds the damage box outward, adding up to one pixel to its size.
+ wait(lambda:all(abs(info(vid)['bbox'][k]-v)<=1 for k,v in rect(270,330,300,180).items()))
+ shot('pulled-away');button('release');time.sleep(.3)
+ assert geometry(vid)['width']==300 and geometry(vid)['height']==180
+ print('PASS dwell, held-window preview without client resize, pull-away restoration and free release',flush=True)
+ arm_at(vid,1,320,1);settled_preview(vid,destination(1,'t'),'before-zone-change')
+ move(638,350);assert not status()['ready'];wait(lambda:status()['ready'])
+ settled_preview(vid,destination(1,'r'),'after-zone-change');end(vid,destination(1,'r'))
+ print('PASS moving from a settled preview to another zone restarts dwell and settles again',flush=True)
+ for m in [1,2,3]:
+ for name,x,y in [('t',320,1),('b',320,718),('l',1,350),('r',638,350),('tl',1,1),('tr',638,1),('bl',1,718),('br',638,718)]:
+ before=arm_at(vid,m,x,y)
+ if m==1 or name=='t':
+ settled_preview(vid,destination(m,name),f'held-{m}-{name}')
+ assert geometry(vid)==before
+ end(vid,destination(m,name));wait(lambda:not status()['preview'])
+ assert info(vid)['geometry']==destination(m,name)
+ print(f'PASS all eight delayed destinations commit on physical monitor {m}',flush=True)
+ options(snap_delay=1000);prepare(vid);begin(vid);move(320,1);time.sleep(.1);button('release');time.sleep(.3)
+ assert geometry(vid)['width']==300 and geometry(vid)['height']==180 and not status()['preview']
+ print('PASS release before dwell does not snap',flush=True)
+ options(snap_delay=500);prepare(vid);begin(vid);move(1,350);time.sleep(.25);move(638,350);time.sleep(.2)
+ assert not status()['ready'];move(320,350);time.sleep(.6);assert not status()['preview'];button('release')
+ print('PASS changing zones restarts dwell; leaving cancels the pending preview',flush=True)
+ options(snap_delay=0);arm_at(vid,2,320,1);options(enable_snap=False)
+ assert not zones()['enabled'] and not status()['preview'];button('release');time.sleep(.4)
+ assert geometry(vid)['width']==300 and geometry(vid)['height']==180
+ prepare(vid);begin(vid);move(320,1);time.sleep(.7);button('release');time.sleep(.3)
+ assert geometry(vid)['width']==300 and geometry(vid)['height']==180
+ print('PASS Move Snap checkbox cancels a preview and disables edge snapping',flush=True)
+ ipc('grid/slot_r',{'view-id':vid});expect(vid,**destination(1,'r')) if 'move/snap-regions' in methods() else expect(vid,x=320,y=44,width=300,height=656)
+ ipc('grid/slot_r',{'view-id':vid});expect(vid,x=380,y=109.5,width=240,height=525)
+ print('PASS Grid keyboard placement and size cycling remain independent',flush=True)
+ prepare(vid);plugin('grid',False);options(enable_snap=True);options(snap_delay=100)
+ before=geometry(vid).copy();begin(vid);move(320,1);wait(lambda:status()['ready']);assert geometry(vid)==before
+ end(vid,destination(1,'t'));wait(lambda:not status()['preview'])
+ print('PASS Move snapping works with Grid disabled',flush=True)
+ plugin('grid',True)
+ prepare(vid,2);ipc('window-rules/set-window-scope',{'id':vid,'scope':'virtual'});begin(vid);move(960,1)
+ wait(lambda:status()['ready']);assert status()['target']['width']==1920
+ button('release');expect(vid,x=0,y=24,width=1920,height=348);wait(lambda:not status()['preview'])
+ print('PASS virtual-scope top destination is the upper half of the combined output',flush=True)
+ prepare(vid,2);begin(vid);keys('KEY_LEFTSHIFT')
+ ipc('stipc/feed_key',{'key':'KEY_LEFTSHIFT','state':True});move(960,350);wait(lambda:status()['ready'])
+ assert status()['slot']==5 and status()['target']==rect(660,44,600,656)
+ button('release');ipc('stipc/feed_key',{'key':'KEY_LEFTSHIFT','state':False});expect(vid,x=660,y=44,width=600,height=656)
+ wait(lambda:not status()['preview']);print('PASS explicit Shift monitor-fill stays available',flush=True)
+ plugin('wobbly',False);arm_at(vid,1,320,1);end(vid,destination(1,'t'));wait(lambda:not status()['preview'])
+ plugin('wobbly',True);print('PASS preview and placement also work when Wobbly is disabled',flush=True)
+ # Existing workspace dwell is independent of snap dwell and wraps a row.
+ options(enable_snap=False,workspace_switch_after=350)
+ ipc('wayfire/set-config-options',{'core/vwidth':6,'core/vheight':1})
+ def output_state():return next(o for o in ipc('window-rules/list-outputs') if o['name']=='VIRTUAL-1')
+ def workspace():return output_state()['workspace']['x']
+ prepare(vid,3)
+ ipc('vswitch/set-workspace',{'x':5,'y':0,'output-id':output_state()['id'],'view-id':vid})
+ wait(lambda:workspace()==5);time.sleep(.6)
+ prepare(vid,3);begin(vid);move(1918,350)
+ time.sleep(.1);assert workspace()==5
+ move(1600,350);time.sleep(.5);assert workspace()==5
+ move(1918,350);wait(lambda:workspace()==0);time.sleep(.7)
+ assert workspace()==0 and not status()['preview']
+ button('release');time.sleep(.3)
+ prepare(vid);begin(vid);move(1,350)
+ wait(lambda:workspace()==5);time.sleep(.7);button('release')
+ assert workspace()==5
+ print('PASS delayed workspace switching wraps 6 to 1 and 1 to 6; leaving edge cancels',flush=True)
+ options(enable_snap=True,snap_delay=100)
+ prepare(vid,3);begin(vid);move(1918,350)
+ wait(lambda:workspace()==0);time.sleep(.7)
+ settled_preview(vid,destination(3,'r'),'wrapped-preview-right')
+ end(vid,destination(3,'r'));wait(lambda:not status()['preview'])
+ prepare(vid);begin(vid);move(1,350)
+ wait(lambda:workspace()==5);time.sleep(.7)
+ settled_preview(vid,destination(1,'l'),'wrapped-preview-left')
+ end(vid,destination(1,'l'));wait(lambda:not status()['preview'])
+ print('PASS wrapping in both directions also preserves held previews and drop placement',flush=True)
+ options(enable_snap=False)
+ ipc('vswitch/set-workspace',{'x':0,'y':0,'output-id':output_state()['id'],'view-id':vid})
+ wait(lambda:workspace()==0);time.sleep(.6)
+ ipc('wayfire/set-config-options',{'core/vwidth':1,'core/vheight':1})
+ prepare(vid);begin(vid);move(1,350);time.sleep(.7)
+ assert workspace()==0;button('release')
+ options(enable_snap=True,workspace_switch_after=-1)
+ print('PASS one-workspace grid remains stable at an edge',flush=True)
+ prepare(vid);begin(vid);move(1,350);wait(lambda:status()['ready'])
+ plugin('move',False);assert 'move/snap-regions' not in methods()
+ button('release');time.sleep(.3)
+ assert geometry(vid)['width']==300 and geometry(vid)['height']==180
+ plugin('move',True);wait(lambda:'move/snap-regions' in methods())
+ print('PASS disabling Move during preview cancels safely and reloads cleanly',flush=True)
+ prepare(vid);begin(vid);move(1,350);wait(lambda:status()['ready'])
+ ipc('window-rules/close-view',{'id':vid});wait(lambda:not status()['preview']);button('release')
+ print('PASS closing a held window cancels preview and timer safely',flush=True)
+ assert not any(m.startswith('snap-zones/') for m in methods())
+ for data in [{'scope':'wrong'},{'visible':'true','owner':'x'},{'visible':True}]:ipc('move/snap-regions',data,fail=True)
+ print('ALL PASS',flush=True)
+finally:
+ for p,f in clients:
+ if p.poll() is None:p.terminate()
+ p.wait(timeout=3);f.close()
+ process.terminate();process.wait(timeout=5);log.close()