--- a/src/displays.cpp +++ b/src/displays.cpp @@ -1,4 +1,5 @@ #include "displays.hpp" +#include "ipc.hpp" #include #include #include @@ -39,52 +40,7 @@ wf::json_t DisplaysPage::request(const std::string& method, wf::json_t data) { - auto path = getenv("WAYFIRE_SOCKET"); - if (!path || !*path) throw std::runtime_error("Open WCM from the running Wayfire session (WAYFIRE_SOCKET is missing)."); - sockaddr_un addr{}; addr.sun_family = AF_UNIX; - if (strlen(path) >= sizeof addr.sun_path) throw std::runtime_error("Wayfire socket path is too long"); - strcpy(addr.sun_path, path); - int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); - if (fd < 0) throw std::runtime_error("Cannot open Wayfire socket"); - try - { - timeval timeout{3, 0}; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout); - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout); - if (connect(fd, reinterpret_cast(&addr), sizeof addr)) throw std::runtime_error("Cannot connect to Wayfire"); - ucred peer{}; socklen_t size = sizeof peer; - if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peer, &size) || peer.uid != getuid()) throw std::runtime_error("Wayfire socket belongs to another user"); - wf::json_t message; message["method"] = method; message["data"] = data; - auto bytes = message.serialize(); uint32_t length = htole32(bytes.size()); - auto write_all = [&] (const char *p, size_t n) - { - while (n) - { - auto r = send(fd, p, n, MSG_NOSIGNAL); - if (r < 0 && errno == EINTR) continue; - if (r <= 0) throw std::runtime_error("Wayfire request failed"); - p += r; n -= r; - } - }; - auto read_all = [&] (char *p, size_t n) - { - while (n) - { - auto r = recv(fd, p, n, 0); - if (r < 0 && errno == EINTR) continue; - if (r <= 0) throw std::runtime_error("Wayfire did not respond; an unconfirmed display preview will revert automatically"); - p += r; n -= r; - } - }; - write_all(reinterpret_cast(&length), sizeof length); write_all(bytes.data(), bytes.size()); - read_all(reinterpret_cast(&length), sizeof length); length = le32toh(length); - if (length > 4 * 1024 * 1024) throw std::runtime_error("Invalid Wayfire response size"); - bytes.resize(length); read_all(bytes.data(), length); - wf::json_t reply; - if (wf::json_t::parse_string(bytes, reply)) throw std::runtime_error("Invalid Wayfire response"); - if (reply.has_member("error")) throw std::runtime_error(reply["error"].as_string()); - close(fd); return reply; - } catch (...) { close(fd); throw; } + return wcm_ipc_request(method, data); } DisplaysPage::DisplaysPage(const std::string& config, std::function on_saved, @@ -313,7 +269,7 @@ std::istringstream input(get("core/stitch_outputs")); std::string name; while (input >> name) if (heads.count(name)) order.push_back(name); combined.set_active(get("core/stitch_enabled") == "true"); - gap.set_value(std::stoi(get("grid/zone_gap"))); shift.set_active(get("grid/zone_shift") == "true"); + gap.set_value(std::stoi(get("grid/zone_gap"))); shift.set_active(get("move/zone_shift") == "true"); zone_binding.set_text(get("grid/zone_maximize")); drag_binding.set_text(get("move/activate_zone")); update_defaults(); if (!heads.count(selected)) selected = heads.empty() ? "" : heads.begin()->first; @@ -374,7 +330,7 @@ set("grid/use_virtual", virtual_binding.get_text()); set("grid/use_physical", physical_binding.get_text()); set("grid/reset_scope", reset_binding.get_text()); set("grid/next_monitor", next_binding.get_text()); set("grid/zone_gap", std::to_string(gap.get_value_as_int())); - set("grid/zone_shift", shift.get_active() ? "true" : "false"); + set("move/zone_shift", shift.get_active() ? "true" : "false"); set("grid/zone_maximize", zone_binding.get_text()); set("move/activate_zone", drag_binding.get_text()); if (heads.count(selected)) { --- a/src/wcm.cpp +++ b/src/wcm.cpp @@ -1105,13 +1105,19 @@ } } -PluginPage::PluginPage(Plugin *plugin) +PluginPage::PluginPage(Plugin *plugin, const std::string& config, std::function save_snap) { std::string gettext_domain_name = "wf-plugin-" + plugin->name; set_scrollable(); + if (plugin->name == "move" && plugin->type == PLUGIN_TYPE_WAYFIRE) + { + move_snap = std::make_unique(config, save_snap); + append_page(*move_snap, "Snapping"); + } for (auto *group : plugin->option_groups) { - if ((group->type != OPTION_TYPE_GROUP) || group->hidden) + if ((group->type != OPTION_TYPE_GROUP) || group->hidden || + (plugin->name == "move" && group->name == "Snapping")) { continue; } @@ -1318,26 +1324,14 @@ std::string::size_type find_plugin(Plugin *p, const std::string & plugins) { - std::string::size_type pos = 0; - while (true) - { - pos = plugins.find(p->name, pos); - - if (pos == std::string::npos) - { - break; - } - - if (((pos == 0) || (plugins[pos - 1] == ' ')) && - ((pos + p->name.length() == plugins.length()) || - (plugins[pos + p->name.length()] == ' '))) - { - return pos; - } - - pos += p->name.length(); - } - + for (size_t pos = plugins.find_first_not_of(" \t\n"); pos != std::string::npos;) + { + auto end = plugins.find_first_of(" \t\n", pos); + auto token = plugins.substr(pos, end == std::string::npos ? end : end - pos); + if (token == p->name || (token.find('/') != std::string::npos && + std::filesystem::path(token).filename() == "lib" + p->name + ".so")) return pos; + pos = end == std::string::npos ? end : plugins.find_first_not_of(" \t\n", end); + } return std::string::npos; } @@ -1529,10 +1523,10 @@ while (pos != std::string::npos) { - enabled_plugins.erase( - pos - ( /* remove space before */ pos != 0 ? 1 : 0), - plugin->name.length() + - (enabled_plugins.length() != plugin->name.length() ? 1 : 0)); + auto end = enabled_plugins.find_first_of(" \t\n", pos); + auto token = enabled_plugins.substr(pos, end == std::string::npos ? end : end - pos); + if (token.find('/') != std::string::npos) plugin_paths[plugin->name] = token; + enabled_plugins.erase(pos, end == std::string::npos ? end : end - pos + 1); pos = find_plugin(plugin, enabled_plugins); } } else @@ -1542,7 +1536,8 @@ return; } - enabled_plugins.append((enabled_plugins.empty() ? "" : " ") + plugin->name); + enabled_plugins.append((enabled_plugins.empty() ? "" : " ") + + (plugin_paths.count(plugin->name) ? plugin_paths[plugin->name] : plugin->name)); } wf_opt->set_value_str(enabled_plugins); @@ -1640,7 +1635,7 @@ { Plugin *launch_plugin = find_plugin_by_name(plugins, start_plugin); std::cout << "Opening Plugin: " << start_plugin << std::endl; - this->open_page(launch_plugin); + Glib::signal_idle().connect_once([this, launch_plugin] { open_page(launch_plugin); }); } } @@ -1671,6 +1666,7 @@ std::string gettext_domain_name = "wf-plugin-" + plugin->name; plugin_enabled_box.set_visible( !plugin->is_core_plugin() && plugin->type != PLUGIN_TYPE_WF_SHELL); + current_plugin = nullptr; plugin_enabled_check.set_active(plugin->enabled); plugin_name_label.set_markup( "" + @@ -1678,7 +1674,19 @@ plugin_description_label.set_markup( "" + std::string(dgettext(gettext_domain_name.c_str(), plugin->tooltip.c_str())) + ""); - plugin_page = std::make_unique(plugin); + plugin_page = std::make_unique(plugin, wf_config_file, [this] (bool enabled, int edge, int corner, int delay) + { + auto snap = find_plugin_by_name(plugins, "move"); + if (!snap) throw std::runtime_error("Move snapping settings are unavailable."); + // Merge the current file before saving, preserving edits by other controls. + wf::config::load_configuration_options_from_file(wf_config_mgr, wf_config_file); + auto section = get_config_section(snap); + section->get_option("enable_snap")->set_value_str(enabled ? "true" : "false"); + section->get_option("snap_threshold")->set_value_str(std::to_string(edge)); + section->get_option("quarter_snap_threshold")->set_value_str(std::to_string(corner)); + section->get_option("snap_delay")->set_value_str(std::to_string(delay)); + if (!save_config(snap)) throw std::runtime_error("Could not save Move snapping settings."); + }); main_stack.add(*plugin_page); plugin_page->show_all(); main_stack.set_visible_child(*plugin_page); @@ -1700,7 +1708,7 @@ displays_page = std::make_unique(wf_config_file, [this] { wf::config::load_configuration_options_from_file(wf_config_mgr, wf_config_file); }, [this] { Glib::signal_idle().connect_once([this] { open_page(find_plugin_by_name(plugins, "panel")); }); }, - [this] { Glib::signal_idle().connect_once([this] { open_page(find_plugin_by_name(plugins, "grid")); }); }); + [this] { Glib::signal_idle().connect_once([this] { open_page(find_plugin_by_name(plugins, "move")); }); }); displays_scroll.add(*displays_page); displays_scroll.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); main_stack.add(displays_scroll); --- a/src/wcm.hpp +++ b/src/wcm.hpp @@ -41,6 +41,7 @@ #include "metadata.hpp" #include "displays.hpp" +#include "move-snap.hpp" struct animate_option { @@ -299,9 +300,10 @@ class PluginPage : public Gtk::Notebook { std::vector groups; - - public: - PluginPage(Plugin *plugin); + std::unique_ptr move_snap; + + public: + PluginPage(Plugin *plugin, const std::string& config, std::function save_snap); }; class WCM @@ -321,6 +323,7 @@ std::string wf_shell_config_file; std::string start_plugin; std::vector plugins; + std::map plugin_paths; Plugin *current_plugin = nullptr; --- a/src/meson.build +++ b/src/meson.build @@ -9,7 +9,7 @@ layer_shell = dependency('gtk-layer-shell-0') dep_list = [xml, gtkmm, wf_config, wf_protos, evdev, xkbregistry, libintl, libfmt, wf_json, layer_shell] -sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'displays.cpp', 'vendor/wf-json.cpp') +sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'displays.cpp', 'move-snap.cpp', 'vendor/wf-json.cpp') executable(meson.project_name(), sources, install : true, --- /dev/null +++ b/src/ipc.hpp @@ -0,0 +1,59 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +inline wf::json_t wcm_ipc_request(const std::string& method, wf::json_t data = {}) +{ + auto path = getenv("WAYFIRE_SOCKET"); + if (!path || !*path) throw std::runtime_error("Open WCM from the running Wayfire session (WAYFIRE_SOCKET is missing)."); + sockaddr_un addr{}; addr.sun_family = AF_UNIX; + if (strlen(path) >= sizeof addr.sun_path) throw std::runtime_error("Wayfire socket path is too long"); + strcpy(addr.sun_path, path); + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) throw std::runtime_error("Cannot open Wayfire socket"); + try + { + timeval timeout{3, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout); + if (connect(fd, reinterpret_cast(&addr), sizeof addr)) throw std::runtime_error("Cannot connect to Wayfire"); + ucred peer{}; socklen_t size = sizeof peer; + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peer, &size) || peer.uid != getuid()) throw std::runtime_error("Wayfire socket belongs to another user"); + wf::json_t message; message["method"] = method; message["data"] = data; + auto bytes = message.serialize(); uint32_t length = htole32(bytes.size()); + auto write_all = [&] (const char *p, size_t n) + { + while (n) + { + auto r = send(fd, p, n, MSG_NOSIGNAL); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) throw std::runtime_error("Wayfire request failed"); + p += r; n -= r; + } + }; + auto read_all = [&] (char *p, size_t n) + { + while (n) + { + auto r = recv(fd, p, n, 0); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) throw std::runtime_error("Wayfire did not respond"); + p += r; n -= r; + } + }; + write_all(reinterpret_cast(&length), sizeof length); write_all(bytes.data(), bytes.size()); + read_all(reinterpret_cast(&length), sizeof length); length = le32toh(length); + if (length > 4 * 1024 * 1024) throw std::runtime_error("Invalid Wayfire response size"); + bytes.resize(length); read_all(bytes.data(), length); + wf::json_t reply; + if (wf::json_t::parse_string(bytes, reply)) throw std::runtime_error("Invalid Wayfire response"); + if (reply.has_member("error")) throw std::runtime_error(reply["error"].as_string()); + close(fd); return reply; + } catch (...) { close(fd); throw; } +} --- /dev/null +++ b/src/move-snap.hpp @@ -0,0 +1,32 @@ +#pragma once +#include +#include + +class MoveSnapPage : public Gtk::Box +{ + Gtk::Label intro, legend, hint, status; + Gtk::Box toolbar{Gtk::ORIENTATION_HORIZONTAL, 10}; + Gtk::ComboBoxText scope, monitor; + Gtk::CheckButton enabled{"Snap"}; + Gtk::CheckButton overlay{"Show snap zones on desktop"}; + Gtk::DrawingArea diagram; + Gtk::Grid controls; + Gtk::SpinButton edge, corner, delay; + Gtk::Button refresh{"Refresh"}; + wf::json_t state; + std::vector monitor_names; + std::string config_file, owner, hovered; + std::function save; + sigc::connection poll, delayed_save; + bool loading = false, available = false; + double zoom = 1, dx = 0, dy = 0; + wf::json_t target; + void update(); + void stop_overlay(); + void changed(); + bool draw(const Cairo::RefPtr& cr); + bool motion(GdkEventMotion *event); + public: + MoveSnapPage(const std::string& config, std::function save); + ~MoveSnapPage() override; +}; --- /dev/null +++ b/src/move-snap.cpp @@ -0,0 +1,194 @@ +#include "move-snap.hpp" +#include "ipc.hpp" +#include +#include +#include +#include + +namespace +{ +struct Rect { double x, y, w, h; }; +Rect rect(const wf::json_t& j) +{ + return {j["x"].as_double(), j["y"].as_double(), j["width"].as_double(), j["height"].as_double()}; +} +void path(const Cairo::RefPtr& cr, Rect g) { cr->rectangle(g.x, g.y, g.w, g.h); } +bool contains(Rect g, double x, double y) { return x >= g.x && y >= g.y && x < g.x + g.w && y < g.y + g.h; } +std::string slot_name(std::string slot) +{ + static const std::map names{{"bl","Bottom-left quarter"},{"b","Bottom half"}, + {"br","Bottom-right quarter"},{"l","Left half"},{"c","Fill usable area"},{"r","Right half"}, + {"tl","Top-left quarter"},{"t","Top half"},{"tr","Top-right quarter"}}; + auto it = names.find(slot); return it == names.end() ? slot : it->second; +} +bool is_corner(const std::string& slot) { return slot.size() == 2; } +} + +MoveSnapPage::MoveSnapPage(const std::string& config, std::function save) : + Gtk::Box(Gtk::ORIENTATION_VERTICAL, 14), config_file(config), save(save) +{ + set_border_width(20); + auto uuid = g_uuid_string_random(); owner = uuid; g_free(uuid); + intro.set_text("Hold a dragged window in an edge or corner zone to preview it at the destination. Release to snap; pull away to restore its size. Hover over this map to inspect each destination."); + intro.set_line_wrap(); intro.set_max_width_chars(72); intro.set_xalign(0); pack_start(intro, false, false); + pack_start(enabled, false, false); + enabled.signal_toggled().connect(sigc::mem_fun(*this, &MoveSnapPage::changed)); + scope.append("physical", "Physical monitors"); scope.append("virtual", "VIRTUAL desktop"); scope.set_active_id("physical"); + scope.get_accessible()->set_name("Snap zone scope"); + scope.set_tooltip_text("Preview physical or VIRTUAL placement without changing any window's scope."); + monitor.get_accessible()->set_name("Snap zone monitor"); + toolbar.pack_start(scope, false, false); toolbar.pack_start(monitor, true, true); toolbar.pack_start(refresh, false, false); + pack_start(toolbar, false, false); + diagram.set_size_request(620, 270); diagram.set_hexpand(); diagram.set_vexpand(); + diagram.get_accessible()->set_name("Snap activation regions"); + diagram.add_events(Gdk::POINTER_MOTION_MASK | Gdk::LEAVE_NOTIFY_MASK); + diagram.signal_draw().connect(sigc::mem_fun(*this, &MoveSnapPage::draw)); + diagram.signal_motion_notify_event().connect(sigc::mem_fun(*this, &MoveSnapPage::motion)); + diagram.signal_leave_notify_event().connect([this] (GdkEventCrossing*) { target = {}; hovered.clear(); hint.set_text("Hover near an edge or corner to see its destination."); diagram.queue_draw(); return true; }); + pack_start(diagram, true, true); + legend.set_markup("● Edge regions ● Corner regions"); + legend.set_xalign(0); pack_start(legend, false, false); + hint.set_text("Hover near an edge or corner to see its destination."); hint.set_xalign(0); hint.set_line_wrap(); hint.set_max_width_chars(72); + pack_start(hint, false, false); + controls.set_row_spacing(10); controls.set_column_spacing(18); + auto field = [&] (int row, const std::string& text, Gtk::SpinButton& spin) + { + auto label = Gtk::manage(new Gtk::Label(text)); label->set_xalign(0); controls.attach(*label,0,row,1,1); + spin.set_range(0,16384); spin.set_increments(1,10); spin.set_numeric(); spin.get_accessible()->set_name(text); + controls.attach(spin,1,row,1,1); + }; + field(0,"Edge distance (px)",edge); field(1,"Corner reach (px)",corner); + field(2,"Snap delay (ms)",delay); delay.set_range(0,5000); + delay.set_tooltip_text("Time spent in one zone before the held-window preview activates. Releasing early leaves the window unsnapped."); + edge.set_tooltip_text("Distance from a usable edge that activates snapping. Panel reservations and monitor gaps also belong to the edge region."); + corner.set_tooltip_text("Distance along an edge that selects a corner. The pointer must still be within the edge distance of at least one edge."); + pack_start(controls, false, false); pack_start(overlay, false, false); + status.set_xalign(0); status.set_line_wrap(); status.set_max_width_chars(72); pack_start(status, false, false); + edge.signal_value_changed().connect(sigc::mem_fun(*this, &MoveSnapPage::changed)); + corner.signal_value_changed().connect(sigc::mem_fun(*this, &MoveSnapPage::changed)); + delay.signal_value_changed().connect(sigc::mem_fun(*this, &MoveSnapPage::changed)); + scope.signal_changed().connect([this] { if (!loading) { hovered.clear(); target = {}; update(); } }); + monitor.signal_changed().connect([this] { if (loading) return; hovered.clear(); target = {}; diagram.queue_draw(); }); + overlay.signal_toggled().connect([this] { if (!loading) update(); }); + refresh.signal_clicked().connect([this] { update(); }); + signal_map().connect([this] { update(); }); + signal_unmap().connect([this] { stop_overlay(); }); + poll = Glib::signal_timeout().connect([this] { if (get_mapped() && !delayed_save.connected()) update(); return true; }, 1000); +} + +MoveSnapPage::~MoveSnapPage() +{ + delayed_save.disconnect(); poll.disconnect(); stop_overlay(); +} + +void MoveSnapPage::stop_overlay() +{ + if (!overlay.get_active()) return; + loading = true; overlay.set_active(false); loading = false; + try { wf::json_t request; request["owner"] = owner; request["visible"] = false; wcm_ipc_request("move/snap-regions",request); } catch (...) {} +} + +void MoveSnapPage::update() +{ + try + { + wf::json_t request; request["scope"] = std::string(scope.get_active_id()); + request["owner"] = owner; request["visible"] = overlay.get_active(); + auto reply = wcm_ipc_request("move/snap-regions",request); + if (reply["version"].as_int() != 1) throw std::runtime_error("This compositor's snap-zone interface is not supported."); + if (std::filesystem::weakly_canonical(reply["config_file"].as_string()) != std::filesystem::weakly_canonical(config_file)) + throw std::runtime_error("Open WCM with the running session's configuration to edit snap zones."); + loading = true; + if (!delayed_save.connected()) { enabled.set_active(reply["enabled"].as_bool()); edge.set_value(reply["edge"].as_int()); corner.set_value(reply["corner"].as_int()); delay.set_value(reply["delay"].as_int()); } + std::vector names; auto monitors = reply["monitors"]; + for (size_t i = 0; i < monitors.size(); ++i) names.push_back(monitors[i]["name"].as_string()); + if (names != monitor_names) + { + auto selection = monitor.get_active_id(); monitor.remove_all(); monitor.append("", "All monitors"); + for (auto& name : names) monitor.append(name,name); + if (!monitor.set_active_id(selection)) monitor.set_active(0); + monitor_names = names; target = {}; hovered.clear(); + } + loading = false; available = true; state = reply; + enabled.set_sensitive(true); edge.set_sensitive(true); corner.set_sensitive(true); delay.set_sensitive(true); overlay.set_sensitive(true); + status.set_text(reply["enabled"].as_bool() ? "Changes save immediately. Regions include panel space and monitor gaps; select a monitor for a closer view." : + "Enable Snap to activate drag snapping."); + diagram.queue_draw(); + } catch (const std::exception& e) + { + loading = false; available = false; stop_overlay(); + enabled.set_sensitive(false); edge.set_sensitive(false); corner.set_sensitive(false); delay.set_sensitive(false); overlay.set_sensitive(false); + status.set_text(std::string(e.what()) + " Enable Move and IPC in the running Wayfire session."); diagram.queue_draw(); + } +} + +void MoveSnapPage::changed() +{ + if (loading || !available) return; + delayed_save.disconnect(); + delayed_save = Glib::signal_timeout().connect([this] + { + try { save(enabled.get_active(),edge.get_value_as_int(),corner.get_value_as_int(),delay.get_value_as_int()); } + catch (const std::exception& e) { status.set_text(e.what()); } + return false; + }, 180); +} + +bool MoveSnapPage::draw(const Cairo::RefPtr& cr) +{ + auto width = diagram.get_allocated_width(), height = diagram.get_allocated_height(); + cr->set_source_rgb(.10,.12,.16); cr->paint(); + if (!available) return true; + auto monitors = state["monitors"]; std::vector shown; + Rect bounds{}; bool first = true; + for (size_t i = 0; i < monitors.size(); ++i) + { + auto m = monitors[i]; if (!monitor.get_active_id().empty() && m["name"].as_string() != monitor.get_active_id()) continue; + auto g = rect(m["geometry"]); shown.push_back(m); + if (first) { bounds = g; first = false; } + else { double x=std::min(bounds.x,g.x), y=std::min(bounds.y,g.y); bounds={x,y,std::max(bounds.x+bounds.w,g.x+g.w)-x,std::max(bounds.y+bounds.h,g.y+g.h)-y}; } + } + if (first || bounds.w <= 0 || bounds.h <= 0) return true; + zoom=std::min((width-48.)/bounds.w,(height-64.)/bounds.h); + dx=(width-bounds.w*zoom)/2-bounds.x*zoom; dy=(height-bounds.h*zoom)/2-bounds.y*zoom+10; + cr->save(); cr->translate(dx,dy); cr->scale(zoom,zoom); + for (auto& m : shown) + { + auto g=rect(m["geometry"]); cr->set_source_rgb(.16,.19,.25); path(cr,g); cr->fill(); + auto regions=m["regions"]; + for (size_t i=0;iset_source_rgba(c?.68:.26,c?.48:.72,c?.96:.90,.85); path(cr,rect(r["geometry"])); cr->fill(); + } + cr->set_source_rgb(.55,.62,.72); cr->set_line_width(1/zoom); path(cr,g); cr->stroke(); + auto label=diagram.create_pango_layout(m["name"].as_string()); + cr->save(); cr->translate(g.x,g.y-24/zoom); cr->scale(1/zoom,1/zoom); cr->set_source_rgb(.9,.92,.96); label->show_in_cairo_context(cr); cr->restore(); + } + if (target.has_member("width")) + { + path(cr,rect(target)); + cr->set_source_rgba(.8,.93,1,.95); cr->set_line_width(2/zoom); cr->stroke(); + } + cr->restore(); return true; +} + +bool MoveSnapPage::motion(GdkEventMotion *event) +{ + if (!available || zoom <= 0) return false; + double x=(event->x-dx)/zoom, y=(event->y-dy)/zoom; + auto monitors=state["monitors"]; target={}; hovered.clear(); + for (size_t i=0;i KEY_ENTER\n[command]\nbinding_terminal = KEY_T\ncommand_terminal = alacritty\n[output:HEADLESS-1]\nmode = 1400x1000@60\nposition = 0,0\n[output:HEADLESS-2]\nmode = 1400x1000@60\nposition = 1400,0\n[output:HEADLESS-3]\nmode = 1400x1000@60\nposition = 2800,0\n') +config = compositor_config +original = config.read_text() +shell_config = runtime/'shell.ini' +shell_config.write_text('') +compositor = subprocess.Popen([str(stage/'wayfire'), '-c', str(compositor_config)], env=env, stdout=(runtime/'wayfire.log').open('w'), stderr=subprocess.STDOUT) +ui = None + +def wait(fn, timeout=15): + until = time.monotonic()+timeout + while time.monotonic() < until: + assert compositor.poll() is None, (runtime/'wayfire.log').read_text() + if ui is not None: + assert ui.poll() is None, (runtime/'wcm.log').read_text() + result = fn() + if result: + return result + time.sleep(.1) + raise TimeoutError(f'Timeout; logs in {runtime}') + +def nodes(): + stack = [Atspi.get_desktop(0)] + while stack: + node = stack.pop() + if node is None: + continue + yield node + stack.extend(node.get_child_at_index(i) for i in range(node.get_child_count())) + +def find(name=None, role=None): + return next((n for n in nodes() if (name is None or n.get_name() == name) and (role is None or n.get_role() == role)), None) + +def showing(node): + return node is not None and node.get_state_set().contains(Atspi.StateType.SHOWING) + +def click(name, role=Atspi.Role.PUSH_BUTTON): + node = wait(lambda: find(name, role)) + assert node.get_action_iface().do_action(0), name + time.sleep(.15) + return node + +def select_tab(node): + parent=node.get_parent(); selection=parent.get_selection_iface() + assert selection is not None + assert selection.select_child(node.get_index_in_parent()) + time.sleep(.15) + +def choose(combo_name,item_name): + combo=wait(lambda:find(combo_name,Atspi.Role.COMBO_BOX));assert combo.get_action_iface().do_action(0) + item=wait(lambda:find(item_name,Atspi.Role.MENU_ITEM));assert item.get_action_iface().do_action(0);time.sleep(.2) + +def read_config(): + # The existing WCM writer rewrites in place; wait for a complete document. + for attempt in range(15): + parsed = configparser.ConfigParser(interpolation=None) + try:parsed.read(config);return parsed + except configparser.Error: + if attempt==14:raise + time.sleep(.02) + +def flags(): + return set(read_config()['core']['plugins'].split()) + +def ipc(method,data=None): + with socket.socket(socket.AF_UNIX) as sock: + sock.settimeout(5);sock.connect(env['WAYFIRE_SOCKET']) + body=json.dumps({'method':method,'data':data or {}}).encode();sock.sendall(struct.pack(' KEY_ENTER' + print('PASS overlay heartbeat and threshold changes preserve unrelated settings',flush=True) + tabs=[n for n in nodes() if n.get_role()==Atspi.Role.PAGE_TAB and showing(n) and n.get_name()!='Snapping'] + assert tabs + select_tab(tabs[0]);wait(lambda:not zone_state()['visible']) + select_tab(find('Snapping',Atspi.Role.PAGE_TAB));wait(lambda:showing(find('Show snap zones on desktop',Atspi.Role.CHECK_BOX))) + click('Show snap zones on desktop',Atspi.Role.CHECK_BOX);wait(lambda:zone_state()['visible']) + click('Back');wait(lambda:not zone_state()['visible']) + click('Move');wait(lambda:showing(find('Show snap zones on desktop',Atspi.Role.CHECK_BOX))) + assert not zone_state()['visible'];print('PASS leaving tab or page removes overlay',flush=True) + click('Snap',Atspi.Role.CHECK_BOX) + wait(lambda:not zone_state()['enabled']) + assert not read_config().getboolean('move','enable_snap') + assert str(stage/'plugins/libmove.so') in flags() + click('Snap',Atspi.Role.CHECK_BOX) + wait(lambda:zone_state()['enabled']) + assert read_config().getboolean('move','enable_snap') + print('PASS Snap checkbox toggles snapping while Move remains enabled',flush=True) + plugin_check=next(n for n in nodes() if n.get_role()==Atspi.Role.CHECK_BOX and showing(n) and n.get_name() not in {'Show snap zones on desktop','Snap'}) + plugin_check.get_action_iface().do_action(0) + wait(lambda:str(stage/'plugins/libmove.so') not in flags()) + wait(lambda:'move/snap-regions' not in ipc('list-methods')['methods']) + plugin_check.get_action_iface().do_action(0) + wait(lambda:str(stage/'plugins/libmove.so') in flags()) + wait(lambda:'move/snap-regions' in ipc('list-methods')['methods']) + time.sleep(1.2) + wait(lambda:find('Show snap zones on desktop',Atspi.Role.CHECK_BOX).get_state_set().contains(Atspi.StateType.SENSITIVE)) + assert 'move' not in flags() + print('PASS standard checkbox disables/re-enables Move snapping without disabling Grid',flush=True) + assert 'grid' in flags() + assert not find('Snap Zones',Atspi.Role.PUSH_BUTTON) + assert not read_config().has_section('snap-zones') + click('Back');click('Grid') + assert not showing(find('Snap activation regions')) + click('Back');click('Move') + print('PASS Grid page no longer contains Move snapping',flush=True) + # A crashed UI cannot strand the compositor overlay. + click('Show snap zones on desktop',Atspi.Role.CHECK_BOX);wait(lambda:zone_state()['visible']) + ui.kill();ui.wait(timeout=3);ui=None + wait(lambda:not zone_state()['visible'],5) + print('PASS killed WCM overlay expires automatically',flush=True) + print('ALL PASS',flush=True) +finally: + if ui is not None and ui.poll() is None:ui.terminate();ui.wait(timeout=5) + compositor.terminate();compositor.wait(timeout=10)