Keep the stitched VIRTUAL-1 while its members are unplugged (apply after r72-review-followups). Unplugging a member of core/stitch_outputs (for example a wall-switch power off, which is a real DRM disconnect) used to break the group up: VIRTUAL-1 was destroyed, windows moved to NOOP-1 or to members re-enabled as standalone outputs, and back again on return. move_view_to_output() scales positions but only clamps sizes, so windows came back small, top-left, sometimes on another workspace. - output-layout: an unplugged member is remembered (name, make/model/serial, configured state, slice). VIRTUAL-1 keeps its size and windows; the other members keep mirroring their slices; the missing slice is not scanned out. NOOP-1 is not created and no views are transferred, also when every member is gone. Members are absent from the output lists clients see. - While no member is connected VIRTUAL-1 runs at 1 Hz (frame callbacks keep coming, slowly); the configured refresh returns with the first member. - A remembered member that returns with the same monitor (make/model/serial where known) and the same configured state is re-attached to its slice without a rebuild. It starts powered on, like any new connector. - A member that returns with another mode or monitor, or any change of core/stitch_enabled or core/stitch_outputs, rebuilds the group as before. A member that never returns keeps its slice (and VIRTUAL-1 its size) indefinitely. - core: move_view_to_output() clamped the kept workspace to the grid size instead of the last column/row. No installed header or class layout changes; plugin ABI is unchanged. diff --git a/src/core/core.cpp b/src/core/core.cpp index db70f67..7eb7452 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -741,8 +741,8 @@ void wf::move_view_to_output(wayfire_toplevel_view v, wf::output_t *new_output, { auto new_grid_size = new_wset->get_workspace_grid_size(); new_ws = { - std::min(old_ws.x, new_grid_size.width), - std::min(old_ws.y, new_grid_size.height) + std::min(old_ws.x, new_grid_size.width - 1), + std::min(old_ws.y, new_grid_size.height - 1) }; } diff --git a/src/core/output-layout.cpp b/src/core/output-layout.cpp index 23c0406..a211b6e 100644 --- a/src/core/output-layout.cpp +++ b/src/core/output-layout.cpp @@ -1418,6 +1418,22 @@ class output_layout_t::impl * virtual output with the noop backend. */ std::unique_ptr noop_output; wlr_output *stitch_output = nullptr; + + /* Members of the stitched group which were unplugged. VIRTUAL-1 keeps + * their slice (and so its size and every window position) until they + * return with the same monitor and mode, or until the group changes in + * the config. A member which never returns keeps its slice indefinitely. */ + struct missing_stitch_member_t + { + std::string name, make, model, serial; + output_state_t state; + wlr_box slice; + }; + std::vector missing_members; + /** core/stitch_outputs when the first missing member was recorded. */ + std::string missing_members_group; + /** VIRTUAL-1 refresh (mHz) while none of its members is connected. */ + static constexpr int STITCH_IDLE_REFRESH = 1000; wf::option_wrapper_t stitch_enabled{"core/stitch_enabled"}; wf::option_wrapper_t stitch_outputs{"core/stitch_outputs"}; @@ -1478,6 +1494,7 @@ class output_layout_t::impl } } + missing_members.clear(); auto handle = stitch_output; outputs.at(handle)->destroy_wayfire_output(); wlr_output_layout_remove(output_layout, handle); @@ -1825,15 +1842,186 @@ class output_layout_t::impl lo->output->render->schedule_redraw(); }); + if (reattach_stitch_member(lo)) + { + return; + } + reconfigure_from_config(); } + missing_stitch_member_t *find_missing_member(const std::string& name) + { + for (auto& member : missing_members) + { + if (member.name == name) + { + return &member; + } + } + + return nullptr; + } + + /** + * A stitch member was unplugged: keep VIRTUAL-1 as it is, stop scanning + * this member's slice and remember the member, instead of breaking up the + * group (which moved every window to NOOP-1/standalone outputs and back). + */ + bool detach_stitch_member(output_layout_output_t& lo) + { + if (!stitch_output || !stitch_enabled || !lo.stitch_source_box || !lo.stitch_restore_state || + !outputs.at(stitch_output)->output) + { + return false; + } + + if (missing_members.empty()) + { + missing_members_group = stitch_outputs; + } + + missing_stitch_member_t member; + member.name = nonull(lo.handle->name); + member.make = nonull(lo.handle->make); + member.model = nonull(lo.handle->model); + member.serial = nonull(lo.handle->serial); + member.state = *lo.stitch_restore_state; + member.slice = *lo.stitch_source_box; + missing_members.push_back(member); + LOGI("Stitch member ", member.name, " disconnected; keeping VIRTUAL-1 and its slice"); + + output_state_t off; + off.source = OUTPUT_IMAGE_SOURCE_NONE; + lo.apply_state(off); + lo.stitch_source_box.reset(); + lo.stitch_restore_state.reset(); + + update_stitch_idle_refresh(); + idle_update_configuration.run_once([=] () + { + send_wlr_configuration(); + }); + return true; + } + + static bool same_or_unknown(const std::string& a, const char *b) + { + return a.empty() || !b || !*b || (a == b); + } + + /** A remembered member came back: mirror its slice again if nothing changed. */ + bool reattach_stitch_member(output_layout_output_t *lo) + { + if (!stitch_output || missing_members.empty()) + { + return false; + } + + auto it = std::find_if(missing_members.begin(), missing_members.end(), + [&] (auto& m) { return m.name == nonull(lo->handle->name); }); + if (it == missing_members.end()) + { + return false; + } + + auto member = *it; + missing_members.erase(it); + + auto configured = lo->load_configured_state(); + auto& zones = outputs.at(stitch_output)->stitch_zones; + bool slice_kept = std::any_of(zones.begin(), zones.end(), [&] (auto& zone) + { + return (zone.name == member.name) && (zone.geometry == wf::geometry_t{(double)member.slice.x, + (double)member.slice.y, (double)member.slice.width, (double)member.slice.height}); + }); + if (!stitch_enabled || (stitch_outputs.value() != missing_members_group) || !slice_kept || + !same_or_unknown(member.make, lo->handle->make) || !same_or_unknown(member.model, + lo->handle->model) || !same_or_unknown(member.serial, lo->handle->serial) || + !(configured == member.state)) + { + LOGI("Stitch member ", member.name, " returned with a different monitor or mode; rebuilding"); + return false; + } + + LOGI("Stitch member ", member.name, " reconnected; re-attaching its slice of VIRTUAL-1"); + lo->stitch_restore_state = configured; + lo->stitch_source_box = member.slice; + // Power requests are not carried over: like any new connector, the + // member starts powered on (digitalrain-power drops its lease when + // the old wlr_output is destroyed). + + // Back to full rate before the member starts mirroring. + update_stitch_idle_refresh(); + auto state = configured; + state.source = OUTPUT_IMAGE_SOURCE_MIRROR; + state.mirror_from = "VIRTUAL-1"; + lo->apply_state(state); + if (applied_stitch) + { + applied_stitch->requested[lo->handle] = configured; + applied_stitch->applied[lo->handle] = lo->current_state; + } + + if (auto wo = outputs.at(stitch_output)->output.get()) + { + wo->render->damage_whole(); + } + + idle_update_configuration.run_once([=] () + { + send_wlr_configuration(); + }); + return true; + } + + /** + * Nothing scans VIRTUAL-1 out while all its members are unplugged: lower + * its refresh so that animations and clients do not render at full rate + * into nothing. Frame callbacks continue at 1 Hz, like a slow monitor. + */ + void update_stitch_idle_refresh() + { + if (!stitch_output || !outputs.count(stitch_output) || !outputs.at(stitch_output)->output) + { + return; + } + + auto& virt = outputs.at(stitch_output); + bool connected = std::any_of(outputs.begin(), outputs.end(), [] (auto& entry) + { + return entry.second->stitch_source_box.has_value(); + }); + int refresh = connected ? virt->current_state.mode.refresh : STITCH_IDLE_REFRESH; + if ((refresh <= 0) || (stitch_output->refresh == refresh)) + { + return; + } + + wlr_output_state state; + wlr_output_state_init(&state); + wlr_output_state_set_custom_mode(&state, stitch_output->width, stitch_output->height, refresh); + if (wlr_output_commit_state(stitch_output, &state)) + { + LOGI("VIRTUAL-1 refresh ", refresh / 1000.0, " Hz (", connected ? "members connected" : + "no member connected", ")"); + } else + { + LOGE("Failed to change VIRTUAL-1 refresh to ", refresh / 1000.0, " Hz"); + } + + wlr_output_state_finish(&state); + } + void remove_output(wlr_output *to_remove) { auto active_outputs = get_outputs(); LOGI("remove output: ", to_remove->name); - if (stitch_output && !is_shutting_down()) + if (stitch_output && !is_shutting_down() && detach_stitch_member(*outputs.at(to_remove))) + { + // VIRTUAL-1 stays; the member's slice waits for it to return. + } else if (stitch_output && !is_shutting_down()) { // A disappearing member invalidates the group. Restore surviving // connectors before releasing the combined workspace/output. @@ -1878,6 +2066,8 @@ class output_layout_t::impl std::istringstream names(stitch_outputs.value()); std::vector members; + std::vector member_zones; + std::optional first_position; std::unordered_set seen; std::string name; int width = 0, height = 0, refresh = 0; @@ -1889,13 +2079,15 @@ class output_layout_t::impl return entry.first != stitch_output && name == entry.first->name; }); - if ((it == config.end()) || !seen.insert(name).second) + // An unplugged member keeps its slice while VIRTUAL-1 exists. + auto missing = (it == config.end() && stitch_output) ? find_missing_member(name) : nullptr; + if (((it == config.end()) && !missing) || !seen.insert(name).second) { LOGW("Cannot stitch: missing or duplicate output ", name); return {}; } - auto& state = it->second; + auto& state = missing ? missing->state : it->second; if ((state.source != OUTPUT_IMAGE_SOURCE_SELF) || (state.scale != 1.0) || (state.transform != WL_OUTPUT_TRANSFORM_NORMAL) || state.vrr || state.hdr || (state.depth != 8) || (state.mode.width <= 0) || (state.mode.height <= 0) || @@ -1920,10 +2112,19 @@ class output_layout_t::impl refresh = refresh ? std::min(refresh, state.mode.refresh) : state.mode.refresh; zones.push_back({name, {(double)width, 0.0, (double)state.mode.width, (double)height}}); width += state.mode.width; - members.push_back(it->first); + if (!first_position) + { + first_position = state.position; + } + + if (!missing) + { + members.push_back(it->first); + member_zones.push_back(zones.size() - 1); + } } - if (members.size() < 2) + if (zones.size() < 2) { LOGW("Cannot stitch: select at least two outputs in core/stitch_outputs."); return {}; @@ -1984,7 +2185,7 @@ class output_layout_t::impl combined.mode.height = height; combined.mode.refresh = refresh; combined.uses_custom_mode = true; - auto position = config.at(members.front()).position; + auto position = *first_position; combined.position = position.is_automatic_position() ? output_config::position_t{0, 0} : position; config[stitch_output] = combined; outputs.at(stitch_output)->stitch_zones = zones; @@ -1994,7 +2195,7 @@ class output_layout_t::impl auto& lo = outputs.at(members[i]); auto& state = config.at(members[i]); lo->stitch_restore_state = state; - const auto& box = zones[i].geometry; + const auto& box = zones[member_zones[i]].geometry; lo->stitch_source_box = wlr_box{(int)box.x, (int)box.y, (int)box.width, (int)box.height}; state.source = OUTPUT_IMAGE_SOURCE_MIRROR; state.mirror_from = "VIRTUAL-1"; @@ -2024,6 +2225,13 @@ class output_layout_t::impl scoped_views.emplace_back(top, ws); } } + if (!missing_members.empty() && (!stitch_enabled || (stitch_outputs.value() != missing_members_group))) + { + // The group itself changed: unplugged members no longer hold slices. + LOGI("Stitch group changed; forgetting ", missing_members.size(), " unplugged member(s)"); + missing_members.clear(); + } + // Load desired configuration from config file output_configuration_t configuration; for (auto& [output, layout_output] : this->outputs) @@ -2374,6 +2582,8 @@ class output_layout_t::impl }); } + // Re-applying VIRTUAL-1 restores its configured refresh. + update_stitch_idle_refresh(); idle_update_configuration.run_once([=] () { send_wlr_configuration();