diff --git a/metadata/core.xml b/metadata/core.xml index 6bb03af..ffda987 100644 --- a/metadata/core.xml +++ b/metadata/core.xml @@ -4,6 +4,16 @@ <_short>Core <_long>General options. General + + <_short>Combine outputs + <_long>Experimental: combine the selected outputs into one desktop output. Requires equal heights, scale 1, normal transform, SDR and matching fixed refresh rates. + false + + + <_short>Combined output members + <_long>Space-separated connector names, in left-to-right order. The combined output is named STITCH-1. + + <_short>Plugins <_long>Loads the specified plugins, space-separated list. diff --git a/metadata/grid.xml b/metadata/grid.xml index 0d90486..a03849a 100644 --- a/metadata/grid.xml +++ b/metadata/grid.xml @@ -4,6 +4,16 @@ <_short>Grid <_long>A plugin to position the windows in certain regions of the output. Window Management + + <_short>Maximize within a monitor zone + <_long>On a combined output, fill the physical monitor zone containing most of the active window. Repeat to restore. + <super> <shift> KEY_UP + + + <_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. + true + <_short>Duration <_long>Sets the duration of the animation in milliseconds. diff --git a/metadata/move.xml b/metadata/move.xml index 8f0990b..cc6c9da 100644 --- a/metadata/move.xml +++ b/metadata/move.xml @@ -4,6 +4,11 @@ <_short>Move <_long>A plugin to move windows around by dragging them from any part (not just the title bar). 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. + <super> <shift> BTN_LEFT + <_short>Activate <_long>When the specified button is held down, you can drag windows to move them. diff --git a/plugins/grid/grid.cpp b/plugins/grid/grid.cpp index 57c42de..5e30063 100644 --- a/plugins/grid/grid.cpp +++ b/plugins/grid/grid.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include #include #include @@ -25,7 +27,8 @@ const std::string grid_view_id = "grid-view"; class wf_grid_slot_data : public wf::custom_data_t { public: - int slot; + int slot = 0; + std::string zone; }; nonstd::observer_ptr ensure_grid_view(wayfire_toplevel_view view) @@ -56,12 +59,15 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke std::vector slots = {"unused", "bl", "b", "br", "l", "c", "r", "tl", "t", "tr"}; wf::ipc_activator_t bindings[10]; wf::ipc_activator_t restore{"grid/restore"}; + wf::ipc_activator_t zone_maximize{"grid/zone_maximize"}; + wf::option_wrapper_t zone_shift{"grid/zone_shift"}; 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; } slot; wf::plugin_activation_data_t grab_interface{ @@ -95,11 +101,170 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke return nullptr; } + std::optional find_zone(wf::output_t *output, + const std::string& name) + { + auto layout = output->get_data(); + if (layout) + { + for (auto zone : layout->zones) + { + if (zone.name == name) + { + zone.geometry = wf::geometry_intersection(zone.geometry, output->workarea->get_workarea()); + if ((zone.geometry.width > 0) && (zone.geometry.height > 0)) + { + return zone; + } + } + } + } + + return {}; + } + + std::optional zone_for_window(wayfire_toplevel_view view) + { + auto output = view->get_output(); + auto layout = output ? output->get_data() : nullptr; + std::optional best; + double area = -1; + if (layout) + { + auto geometry = view->get_pending_geometry(); + auto size = output->get_screen_size(); + // Choose in the view's workspace, including off-screen workspaces. + geometry.x -= std::floor((geometry.x + geometry.width / 2) / size.width) * size.width; + geometry.y -= std::floor((geometry.y + geometry.height / 2) / size.height) * size.height; + for (auto& zone : layout->zones) + { + auto overlap = wf::geometry_intersection(geometry, zone.geometry); + double candidate = std::max(0.0, overlap.width) * std::max(0.0, overlap.height); + if (candidate > area) + { + best = find_zone(output, zone.name); + area = candidate; + } + } + } + + return best; + } + + bool shift_zones_active() + { + return zone_shift && (wf::get_core().seat->get_keyboard_modifiers() & WLR_MODIFIER_SHIFT); + } + + void place_in_zone(wayfire_toplevel_view view, const wf::output_stitch_zone_t& zone, + wf::point_t workspace) + { + auto data = view->get_data_safe(); + data->slot = wf::grid::SLOT_CENTER; + 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() + { + if (slot.preview) + { + // Dropping the helper alone leaves its compositor view mapped. + slot.preview->view->damage(); + slot.preview->view->get_root_node()->set_enabled(false); + slot.preview->view->close(); + 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)) + { + 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( + wf::pointf_t{(double)ev->input.x, (double)ev->input.y}, ev->output, "move"); + slot.preview->set_target_geometry(zone->geometry, 1); + } + + return true; + } + public: void init() override { init_output_tracking(); restore.set_handler(handle_restore); + zone_maximize.set_handler([=] (wf::output_t *wo, wayfire_view view) + { + auto toplevel = toplevel_cast(view); + if (!toplevel || !can_adjust_view(toplevel) || !get_view_wset(toplevel) || + toplevel->pending_fullscreen() || !wo->can_activate_plugin(&grab_interface)) + { + return false; + } + + auto zone = zone_for_window(toplevel); + if (!zone) + { + return false; + } + + auto old = toplevel->get_data(); + if (old && !old->zone.empty() && toplevel->pending_tiled_edges()) + { + wf::get_core().default_wm->tile_request(toplevel, 0); + } else + { + place_in_zone(toplevel, *zone, get_view_wset(toplevel)->get_current_workspace()); + } + + return true; + }); for (int i = 1; i < 10; i++) { bindings[i].load_from_xml_option("grid/slot_" + slots[i]); @@ -121,12 +286,17 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke } wf::get_core().connect(&grid_handle_move_signal_cb); + 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) { ev->carried_out = true; + if (handle_zone_move(ev)) + { + return; + } wf::grid::slot_t new_slot_id = ev->operation == wf::grid::MOVE_OP_CLEAR_PREVIEW ? wf::grid::slot_t::SLOT_NONE : wf::grid::calc_slot(ev->output, ev->input, snap_threshold, quarter_snap_threshold); @@ -136,6 +306,7 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke ev->view->toplevel()->pending().tiled_edges = wf::grid::get_tiled_edges_for_slot(new_slot_id); auto desired_size = wf::grid::get_slot_dimensions(ev->view->get_output(), new_slot_id); 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), desired_size, get_view_wset(ev->view)->get_current_workspace()), @@ -187,11 +358,18 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke void handle_output_removed(wf::output_t *output) override { - // no-op + // 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 fini() override { + clear_zone_preview(); fini_output_tracking(); } @@ -210,11 +388,50 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke } view->get_data_safe()->slot = slot; + view->get_data_safe()->zone.clear(); auto slot_geometry = wf::grid::get_slot_dimensions(view->get_output(), slot) + delta; ensure_grid_view(view)->adjust_target_geometry( slot_geometry, wf::grid::get_tiled_edges_for_slot(slot)); } + bool reflow_zone(wayfire_toplevel_view view) + { + auto data = view->get_data(); + if (!data || data->zone.empty() || !view->pending_tiled_edges() || + view->pending_fullscreen() || !get_view_wset(view)) + { + return false; + } + + auto output = view->get_output(); + if (auto zone = find_zone(output, data->zone)) + { + auto g = view->get_pending_geometry(); + auto size = output->get_screen_size(); + auto ws = get_view_wset(view)->get_current_workspace(); + ws.x += std::floor((g.x + g.width / 2) / size.width); + ws.y += std::floor((g.y + g.height / 2) / size.height); + place_in_zone(view, *zone, ws); + return true; + } + + data->zone.clear(); + return false; + } + + wf::signal::connection_t on_layout_changed = + [this] (auto) + { + // Member order can change without changing the combined workarea. + for (auto output : wf::get_core().output_layout->get_outputs()) + { + for (auto view : output->wset()->get_views(wf::WSET_MAPPED_ONLY)) + { + reflow_zone(view); + } + } + }; + wf::signal::connection_t on_workarea_changed = [=] (wf::workarea_changed_signal *ev) { @@ -222,6 +439,11 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke { auto data = view->get_data_safe(); + if (view->pending_fullscreen() || reflow_zone(view)) + { + continue; + } + /* Detect if the view was maximized outside of the grid plugin */ auto wm = view->get_pending_geometry(); if (view->pending_tiled_edges() && (wm.width == ev->old_workarea.width) && @@ -267,6 +489,15 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke } data->carried_out = true; + if ((data->edges == wf::TILED_EDGES_ALL) && shift_zones_active()) + { + if (auto zone = zone_for_window(data->view)) + { + place_in_zone(data->view, *zone, data->workspace); + return; + } + } + uint32_t slot = wf::grid::get_slot_from_tiled_edges(data->edges); if (slot > 0) { @@ -274,6 +505,7 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke } data->view->get_data_safe()->slot = slot; + data->view->get_data_safe()->zone.clear(); ensure_grid_view(data->view)->adjust_target_geometry( adjust_for_workspace(get_view_wset(data->view), data->desired_size, data->workspace), wf::grid::get_tiled_edges_for_slot(slot)); @@ -299,6 +531,11 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke { geom = wf::grid::get_slot_dimensions(data->view->get_output(), slot); edges = wf::grid::get_tiled_edges_for_slot(slot); + if (auto zone = find_zone(data->view->get_output(), + data->view->get_data()->zone)) + { + geom = zone->geometry; + } } } diff --git a/plugins/ipc-rules/ipc-rules-common.hpp b/plugins/ipc-rules/ipc-rules-common.hpp index 724e17d..7190570 100644 --- a/plugins/ipc-rules/ipc-rules-common.hpp +++ b/plugins/ipc-rules/ipc-rules-common.hpp @@ -3,6 +3,7 @@ #include "wayfire/plugins/ipc/ipc-helpers.hpp" #include "wayfire/plugins/ipc/ipc-method-repository.hpp" #include +#include #include #include #include "config.h" @@ -30,6 +31,18 @@ static inline wf::json_t output_to_json(wf::output_t *o) response["workspace"]["y"] = o->wset()->get_current_workspace().y; response["workspace"]["grid_width"] = o->wset()->get_workspace_grid_size().width; response["workspace"]["grid_height"] = o->wset()->get_workspace_grid_size().height; + if (auto layout = o->get_data()) + { + response["members"] = wf::json_t::array(); + for (auto& zone : layout->zones) + { + wf::json_t member; + member["name"] = zone.name; + member["geometry"] = wf::ipc::geometry_to_json(zone.geometry); + response["members"].append(member); + } + } + return response; } diff --git a/plugins/ipc-rules/ipc-rules.cpp b/plugins/ipc-rules/ipc-rules.cpp index 0ac26ca..dfc7c30 100644 --- a/plugins/ipc-rules/ipc-rules.cpp +++ b/plugins/ipc-rules/ipc-rules.cpp @@ -27,6 +27,13 @@ class ipc_rules_t : public wf::plugin_interface_t, { method_repository->register_method("window-rules/list-views", list_views); method_repository->register_method("window-rules/list-outputs", list_outputs); + method_repository->register_method("window-rules/set-output-power", [] (wf::json_t data) + { + auto name = wf::ipc::json_get_string(data, "output_name"); + auto enabled = wf::ipc::json_get_bool(data, "enabled"); + return wf::get_core().output_layout->set_output_power(name, enabled) ? + wf::ipc::json_ok() : wf::ipc::json_error("output name not found or output is disabled"); + }); method_repository->register_method("window-rules/list-wsets", list_wsets); method_repository->register_method("window-rules/view-info", get_view_info); method_repository->register_method("window-rules/output-info", get_output_info); @@ -49,6 +56,7 @@ class ipc_rules_t : public wf::plugin_interface_t, { method_repository->unregister_method("window-rules/list-views"); method_repository->unregister_method("window-rules/list-outputs"); + method_repository->unregister_method("window-rules/set-output-power"); method_repository->unregister_method("window-rules/list-wsets"); method_repository->unregister_method("window-rules/view-info"); method_repository->unregister_method("window-rules/output-info"); @@ -267,12 +275,61 @@ class ipc_rules_t : public wf::plugin_interface_t, { auto view = wf::ipc::json_find_view_or_throw(data); auto output_id = wf::ipc::json_get_optional_uint64(data, "output_id"); + auto output_name = wf::ipc::json_get_optional_string(data, "output_name"); + std::optional target_region; + wf::output_t *named_output = nullptr; + if (output_name) + { + if (output_id) + { + return wf::ipc::json_error("specify output_name or output_id, not both"); + } + + for (auto output : wf::get_core().output_layout->get_outputs()) + { + if (output->to_string() == *output_name) + { + named_output = output; + target_region = output->workarea->get_workarea(); + break; + } + + if (auto group = output->get_data()) + { + for (auto& member : group->zones) + { + if (member.name == *output_name) + { + named_output = output; + target_region = wf::geometry_intersection(member.geometry, + output->workarea->get_workarea()); + break; + } + } + } + } + + if (!named_output) + { + return wf::ipc::json_error("output name or member not found"); + } + } if (data.has_member("geometry") && !data["geometry"].is_object()) { return wf::ipc::json_error("invalid geometry"); } + std::optional requested_geometry; + if (data.has_member("geometry")) + { + requested_geometry = wf::ipc::geometry_from_json(data["geometry"]); + if (!requested_geometry) + { + return wf::ipc::json_error("invalid geometry"); + } + } + auto sticky = wf::ipc::json_get_optional_bool(data, "sticky"); auto tiled_edges = wf::ipc::json_get_optional_uint64(data, "tiled-edges"); if (tiled_edges.has_value() && (tiled_edges.value() > wf::TILED_EDGES_ALL)) @@ -281,6 +338,13 @@ class ipc_rules_t : public wf::plugin_interface_t, } auto fullscreen = wf::ipc::json_get_optional_bool(data, "fullscreen"); + if (target_region) + { + // Named placement addresses an area of the logical output. Keep + // true fullscreen an explicit, output-wide operation. + tiled_edges = tiled_edges.value_or(0); + fullscreen = fullscreen.value_or(false); + } auto toplevel = wf::toplevel_cast(view); if (!toplevel) @@ -299,15 +363,21 @@ class ipc_rules_t : public wf::plugin_interface_t, wf::move_view_to_output(toplevel, wo, !data.has_member("geometry")); } - if (data.has_member("geometry")) + if (named_output) + { + wf::move_view_to_output(toplevel, named_output, false); + toplevel->toplevel()->pending().geometry = *target_region; + } + + if (requested_geometry) { - auto geometry = wf::ipc::geometry_from_json(data["geometry"]); - if (!geometry) + if (target_region) { - return wf::ipc::json_error("invalid geometry"); + requested_geometry->x += target_region->x; + requested_geometry->y += target_region->y; } - toplevel->toplevel()->pending().geometry = *geometry; + toplevel->toplevel()->pending().geometry = *requested_geometry; } if (tiled_edges.has_value()) @@ -321,7 +391,7 @@ class ipc_rules_t : public wf::plugin_interface_t, } // Schedule one transaction with all the state changes. - if (data.has_member("geometry") || tiled_edges.has_value() || fullscreen.has_value()) + if (data.has_member("geometry") || target_region || tiled_edges.has_value() || fullscreen.has_value()) { wf::get_core().tx_manager->schedule_object(toplevel->toplevel()); } diff --git a/plugins/single_plugins/move.cpp b/plugins/single_plugins/move.cpp index 486cb19..0a20e2b 100644 --- a/plugins/single_plugins/move.cpp +++ b/plugins/single_plugins/move.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,16 @@ class wayfire_move : public wf::per_output_plugin_instance_t, public wf::pointer_interaction_t, public wf::touch_interaction_t { wf::button_callback activate_binding; + wf::button_callback activate_zone_binding; + wf::option_wrapper_t activate_zone_button{"move/activate_zone"}; + wf::signal::connection_t> zone_modifiers = + [this] (auto) + { + if (input_grab && input_grab->is_grabbed() && output->has_data()) + { + update_slot(wf::grid::MOVE_OP_UPDATE_PREVIEW); + } + }; wf::option_wrapper_t enable_snap{"move/enable_snap"}; wf::option_wrapper_t join_views{"move/join_views"}; @@ -174,6 +185,7 @@ class wayfire_move : public wf::per_output_plugin_instance_t, void init() override { wf::get_core().connect(&on_raw_pointer_button); + wf::get_core().connect(&zone_modifiers); wf::get_core().connect(&on_raw_touch_down); input_grab = std::make_unique("move", output, nullptr, this, this); @@ -192,6 +204,16 @@ class wayfire_move : public wf::per_output_plugin_instance_t, }; output->add_button(activate_button, &activate_binding); + activate_zone_binding = [this] (auto event) + { + if (output->has_data()) + { + return activate_binding(event); + } + + return false; + }; + output->add_button(activate_zone_button, &activate_zone_binding); using namespace std::placeholders; @@ -564,6 +586,7 @@ class wayfire_move : public wf::per_output_plugin_instance_t, } output->rem_binding(&activate_binding); + output->rem_binding(&activate_zone_binding); } }; diff --git a/plugins/window-rules/view-action-interface.cpp b/plugins/window-rules/view-action-interface.cpp index ba9d6a2..d2c914d 100644 --- a/plugins/window-rules/view-action-interface.cpp +++ b/plugins/window-rules/view-action-interface.cpp @@ -8,6 +8,7 @@ #include "wayfire/util/log.hpp" #include "wayfire/view-transform.hpp" #include "wayfire/output-layout.hpp" +#include "wayfire/output-stitch.hpp" #include "../wm-actions/wm-actions-signals.hpp" #include #include @@ -530,6 +531,48 @@ void view_action_interface_t::_start_on_output(std::string name) // First try normal connector name (DP-1, HDMI-A-1, etc.) auto output = layout->find_output(name); + // Physical member names remain useful in existing start_on_output rules. + if (!output && !_view->parent) + { + for (auto combined : layout->get_outputs()) + { + auto group = combined->get_data(); + if (!group) + { + continue; + } + + for (auto& member : group->zones) + { + if (member.name != name) + { + continue; + } + + if (_view->get_output() != combined) + { + move_view_to_output(_view, combined, true); + } + + // True fullscreen still belongs to the complete logical output. + if (!_view->pending_fullscreen()) + { + auto area = wf::geometry_intersection(member.geometry, + combined->workarea->get_workarea()); + auto geometry = _view->get_pending_geometry(); + geometry.width = std::min(geometry.width, area.width); + geometry.height = std::min(geometry.height, area.height); + geometry.x = area.x + (area.width - geometry.width) / 2; + geometry.y = area.y + (area.height - geometry.height) / 2; + _view->toplevel()->pending().tiled_edges = 0; + _view->set_geometry(geometry); + } + + return; + } + } + } + // If not found, try make + model if (!output) { diff --git a/src/api/wayfire/output-layout.hpp b/src/api/wayfire/output-layout.hpp index 9169eb2..ddc11cd 100644 --- a/src/api/wayfire/output-layout.hpp +++ b/src/api/wayfire/output-layout.hpp @@ -235,6 +235,9 @@ class output_layout_t : public wf::signal::provider_t bool apply_configuration(const output_configuration_t& configuration, bool test_only = false); + /** Set DPMS for a logical output or an individual member of a combined output. */ + bool set_output_power(const std::string& name, bool enabled); + class impl; std::unique_ptr pimpl; }; diff --git a/src/api/wayfire/output-stitch.hpp b/src/api/wayfire/output-stitch.hpp new file mode 100644 index 0000000..ec1eae3 --- /dev/null +++ b/src/api/wayfire/output-stitch.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include +#include + +namespace wf +{ +/** A placement zone in the combined output's local logical coordinates. */ +struct output_stitch_zone_t +{ + std::string name; + wf::geometry_t geometry; +}; + +/** Present only on a combined output. Members are not separate desktop outputs. */ +struct output_stitch_layout_t : public wf::custom_data_t +{ + std::vector zones; +}; +} diff --git a/src/core/output-layout.cpp b/src/core/output-layout.cpp index aa0f9ae..6d88d6a 100644 --- a/src/core/output-layout.cpp +++ b/src/core/output-layout.cpp @@ -1,6 +1,7 @@ #include "wayfire/output.hpp" #include "wayfire/core.hpp" #include "wayfire/output-layout.hpp" +#include "wayfire/output-stitch.hpp" #include "wayfire/view.hpp" #include "wayfire/workspace-set.hpp" #include "wayfire/render-manager.hpp" @@ -14,6 +15,7 @@ #include #include #include +#include #include #include @@ -484,6 +486,11 @@ struct output_layout_output_t std::map> formats_for_depth; int current_bit_depth = 0; std::optional current_hdr_enabled; + // A source rectangle turns a mirror into one physical slice of STITCH-1. + std::optional stitch_source_box; + std::optional stitch_restore_state; + std::vector stitch_zones; + bool stitch_power_off = false; std::unique_ptr output; wl_listener_wrapper on_destroy, on_commit, on_request_state; @@ -775,6 +782,11 @@ struct output_layout_output_t { if (this->output) { + if (!stitch_zones.empty()) + { + output->get_data_safe()->zones = stitch_zones; + } + this->output->set_effective_size(effective_size); return; @@ -782,6 +794,11 @@ struct output_layout_output_t this->output = std::make_unique(handle, effective_size); + if (!stitch_zones.empty()) + { + output->get_data_safe()->zones = stitch_zones; + } + auto wo = output.get(); priv_render_manager_start_rendering(wo->render.get()); @@ -1026,16 +1043,17 @@ struct output_layout_output_t wl_listener_wrapper on_mirrored_frame; wl_listener_wrapper on_frame; wlr_output *locked_cursors_on = NULL; + bool source_pending = false; /** Render the output using texture as source */ - void render_output(wlr_texture *texture) + bool render_output(wlr_texture *texture) { // TODO: use render-manager's functions, apply gamma, use our normal pass functions. struct wlr_render_pass *pass = wlr_output_begin_render_pass( handle, &pending_state.pending, NULL); if (pass == NULL) { - return; + return false; } // Render other output as a fullscreen texture. @@ -1046,6 +1064,12 @@ struct output_layout_output_t opts.filter_mode = WLR_SCALE_FILTER_BILINEAR; opts.clip = NULL; opts.src_box = {0, 0, 0, 0}; + if (stitch_source_box) + { + const auto& box = *stitch_source_box; + opts.src_box = {(double)box.x, (double)box.y, (double)box.width, (double)box.height}; + opts.filter_mode = WLR_SCALE_FILTER_NEAREST; + } opts.dst_box = {0, 0, (int)handle->width, (int)handle->height}; opts.transform = WL_OUTPUT_TRANSFORM_NORMAL; opts.wait_timeline = source_wait_point.timeline; @@ -1055,10 +1079,10 @@ struct output_layout_output_t if (!wlr_render_pass_submit(pass)) { pending_state.reset(); - return; + return false; } - pending_state.commit(handle); + return pending_state.commit(handle); } /* Load output contents and render them */ @@ -1067,6 +1091,11 @@ struct output_layout_output_t void handle_frame() { + if (!source_pending) + { + return; + } + auto wo = get_core().output_layout->find_output( current_state.mirror_from); if (!wo) @@ -1090,7 +1119,11 @@ struct output_layout_output_t return; } - render_output(texture); + if (render_output(texture)) + { + source_pending = false; + } + wlr_texture_destroy(texture); } @@ -1105,6 +1138,12 @@ struct output_layout_output_t void setup_mirror() { + if (stitch_source_box && stitch_power_off) + { + set_enabled(false); + return; + } + /* Check if we can mirror */ auto wo = get_core().output_layout->find_output( current_state.mirror_from); @@ -1157,6 +1196,7 @@ struct output_layout_output_t source_back_buffer = ev->state->buffer; wlr_buffer_lock(ev->state->buffer); + source_pending = true; source_wait_point = { ev->state->wait_timeline, ev->state->wait_point, @@ -1188,6 +1228,7 @@ struct output_layout_output_t } source_wait_point = {}; + source_pending = false; on_mirrored_frame.disconnect(); on_frame.disconnect(); @@ -1261,7 +1302,7 @@ struct output_layout_output_t * up again, in case the output to mirror from changed */ teardown_mirror(); - if (state.source == OUTPUT_IMAGE_SOURCE_NONE) + if (state.source == OUTPUT_IMAGE_SOURCE_NONE || (stitch_source_box && stitch_power_off)) { /* output is OFF */ destroy_wayfire_output(); @@ -1276,7 +1317,7 @@ struct output_layout_output_t apply_depth(state.depth); apply_hdr(state.hdr); - if (state.source & OUTPUT_IMAGE_SOURCE_SELF) + if ((state.source & OUTPUT_IMAGE_SOURCE_SELF) || stitch_source_box) { if (handle->transform != state.transform) { @@ -1289,7 +1330,10 @@ struct output_layout_output_t } pending_state.commit(handle); + } + if (state.source & OUTPUT_IMAGE_SOURCE_SELF) + { ensure_wayfire_output(get_effective_size()); emit_configuration_changed(changed_fields); output->render->damage_whole(); @@ -1325,6 +1369,42 @@ class output_layout_t::impl * temporarily no output is available. For those cases, we create a * virtual output with the noop backend. */ std::unique_ptr noop_output; + wlr_output *stitch_output = nullptr; + wf::option_wrapper_t stitch_enabled{"core/stitch_enabled"}; + wf::option_wrapper_t stitch_outputs{"core/stitch_outputs"}; + + void release_mirror_buffers() + { + // A source may be removed in the same transaction. Release its cursor + // locks and buffers before destroying any of the source outputs. + for (auto& [handle, lo] : outputs) + { + lo->teardown_mirror(); + } + } + + void destroy_stitch_output() + { + if (!stitch_output) + { + return; + } + + for (auto& [output, lo] : outputs) + { + if (lo->locked_cursors_on == stitch_output) + { + lo->teardown_mirror(); + } + } + + auto handle = stitch_output; + outputs.at(handle)->destroy_wayfire_output(); + wlr_output_layout_remove(output_layout, handle); + outputs.erase(handle); + stitch_output = nullptr; + wlr_output_destroy(handle); + } wf::signal::connection_t on_config_reload = [=] (wf::reload_config_signal *ev) { @@ -1369,7 +1449,11 @@ class output_layout_t::impl // We destroy the noop output when the renderer is destroyed. // This is needed because the noop output uses the same wlr_egl // as the real outputs. - on_backend_destroy.set_callback([=] (auto) { deinit_noop(); }); + on_backend_destroy.set_callback([=] (auto) + { + destroy_stitch_output(); + deinit_noop(); + }); on_backend_destroy.connect(&wf::get_core().renderer->events.destroy); output_layout = wlr_output_layout_create(get_core().display); @@ -1404,6 +1488,8 @@ class output_layout_t::impl void fini() { // Destroy outputs first + destroy_stitch_output(); + release_mirror_buffers(); this->outputs.clear(); noop_output.reset(); @@ -1482,12 +1568,53 @@ class output_layout_t::impl auto configuration = output_configuration_from_wlr_configuration(wlr_configuration); + if (stitch_output) + { + // A management client sends all heads, including unchanged hidden + // members. Keep their internal mirror/DPMS states while allowing + // unrelated outputs to be configured normally. + for (auto& [handle, state] : configuration) + { + auto& lo = outputs.at(handle); + if ((handle != stitch_output) && !lo->stitch_source_box) + { + continue; + } + + wlr_box box; + wlr_output_layout_get_box(output_layout, handle, &box); + bool enabled = state.source != OUTPUT_IMAGE_SOURCE_NONE; + bool unchanged = enabled == bool(handle->enabled); + if (enabled) + { + unchanged &= state.mode.width == handle->width && state.mode.height == handle->height && + state.mode.refresh == handle->refresh && state.scale == handle->scale && + state.transform == handle->transform && state.vrr == (handle->adaptive_sync_status == WLR_OUTPUT_ADAPTIVE_SYNC_ENABLED) && + state.position == output_config::position_t{box.x, box.y}; + } + + if (!unchanged) + { + LOGW("Disable core/stitch_enabled before changing group/member display modes. ", + "Use window-rules/set-output-power for individual or group power."); + wlr_output_configuration_v1_send_failed(wlr_configuration); + wlr_output_configuration_v1_destroy(wlr_configuration); + return; + } + + state = lo->current_state; + } + } + if (apply_configuration(configuration, test_only)) { // Notify outputs that they have external configuration for (auto& [wo, _] : configuration) { - this->outputs[wo]->is_externally_managed = true; + if (!test_only && (wo != stitch_output) && !outputs[wo]->stitch_source_box) + { + this->outputs[wo]->is_externally_managed = true; + } } wlr_output_configuration_v1_send_succeeded(wlr_configuration); @@ -1622,10 +1749,18 @@ class output_layout_t::impl auto active_outputs = get_outputs(); LOGI("remove output: ", to_remove->name); - /* Unset mode, plus destroy the wayfire output */ - auto configuration = get_current_configuration(); - configuration[to_remove].source = OUTPUT_IMAGE_SOURCE_NONE; - apply_configuration(configuration); + if (stitch_output && !is_shutting_down()) + { + // A disappearing member invalidates the group. Restore surviving + // connectors before releasing the combined workspace/output. + reconfigure_from_config(to_remove); + } else + { + /* Unset mode, plus destroy the wayfire output */ + auto configuration = get_current_configuration(); + configuration[to_remove].source = OUTPUT_IMAGE_SOURCE_NONE; + apply_configuration(configuration); + } outputs.erase(to_remove); @@ -1645,23 +1780,198 @@ class output_layout_t::impl return configuration; } - /** Load config from file, test and apply */ - void reconfigure_from_config() + std::vector prepare_stitch(output_configuration_t& config) + { + if (!stitch_enabled) + { + return {}; + } + + std::istringstream names(stitch_outputs.value()); + std::vector members; + std::unordered_set seen; + std::string name; + int width = 0, height = 0, refresh = 0; + std::vector zones; + while (names >> name) + { + auto it = std::find_if(config.begin(), config.end(), [&] (auto& entry) + { + return entry.first != stitch_output && name == entry.first->name; + }); + + if ((it == config.end()) || !seen.insert(name).second) + { + LOGW("Cannot stitch: missing or duplicate output ", name); + return {}; + } + + auto& 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) || + (state.mode.refresh <= 0) || (height && height != state.mode.height) || + (refresh && std::abs(refresh - state.mode.refresh) > 500)) + { + LOGW("Cannot stitch ", name, ": require enabled SDR/8-bit outputs, scale 1, normal ", + "transform, equal heights and matching fixed refresh rates."); + return {}; + } + + // This also bounds integer arithmetic before adding widths. Check + // actual buffer allocation below before changing physical outputs. + int limit = wf::option_wrapper_t("workarounds/max_buffer_size"); + if ((limit <= 0) || (state.mode.width > limit - width) || (state.mode.height > limit)) + { + LOGW("Cannot stitch: combined size exceeds workarounds/max_buffer_size."); + return {}; + } + + height = state.mode.height; + 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 (members.size() < 2) + { + LOGW("Cannot stitch: select at least two outputs in core/stitch_outputs."); + return {}; + } + + if (!stitch_output) + { + for (auto& [handle, state] : config) + { + if (std::string(handle->name) == "STITCH-1") + { + LOGE("Cannot stitch: STITCH-1 is already in use."); + return {}; + } + } + + stitch_output = wlr_headless_add_output(noop_backend, width, height); + if (!stitch_output) + { + LOGE("Cannot create combined output."); + return {}; + } + + wlr_output_set_name(stitch_output, "STITCH-1"); + wlr_output_set_description(stitch_output, "Combined desktop output"); + if (!wlr_output_init_render(stitch_output, get_core().allocator, get_core().renderer)) + { + wlr_output_destroy(stitch_output); + stitch_output = nullptr; + LOGE("Cannot initialize combined output renderer."); + return {}; + } + + outputs[stitch_output] = std::make_unique(stitch_output); + } + + wlr_output_state_setter_t candidate; + wlr_output_state_set_enabled(&candidate.pending, true); + wlr_output_state_set_custom_mode(&candidate.pending, width, height, refresh); + if (!wlr_output_configure_primary_swapchain(stitch_output, &candidate.pending, + &stitch_output->swapchain)) + { + LOGE("Cannot allocate combined output swapchain."); + return {}; + } + + auto buffer = wlr_swapchain_acquire(stitch_output->swapchain); + if (!buffer) + { + LOGE("Cannot allocate combined output buffer."); + return {}; + } + + wlr_buffer_unlock(buffer); + output_state_t combined; + combined.source = OUTPUT_IMAGE_SOURCE_SELF; + combined.mode.width = width; + combined.mode.height = height; + combined.mode.refresh = refresh; + combined.uses_custom_mode = true; + auto position = config.at(members.front()).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; + + for (size_t i = 0; i < members.size(); ++i) + { + auto& lo = outputs.at(members[i]); + auto& state = config.at(members[i]); + lo->stitch_restore_state = state; + const auto& box = 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 = "STITCH-1"; + } + + return zones; + } + + /** Load config from file, test and apply. Exclude a connector being removed. */ + void reconfigure_from_config(wlr_output *removing = nullptr) { // Load desired configuration from config file output_configuration_t configuration; for (auto& [output, layout_output] : this->outputs) { - configuration[output] = layout_output->load_configured_state(); + if (output == stitch_output) + { + continue; + } + + configuration[output] = layout_output->stitch_restore_state && layout_output->should_ignore_config_state() ? + *layout_output->stitch_restore_state : layout_output->load_configured_state(); + if (output == removing) + { + configuration[output].source = OUTPUT_IMAGE_SOURCE_NONE; + } + + layout_output->stitch_source_box.reset(); + } + + auto zones = prepare_stitch(configuration); + if (stitch_output && zones.empty()) + { + configuration[stitch_output].source = OUTPUT_IMAGE_SOURCE_NONE; } - if (configuration != get_current_configuration()) + // The public mirror state does not include source rectangles; reapply + // active groups even when only member order or slice geometry changed. + if (stitch_output || configuration != get_current_configuration()) { if (test_configuration(configuration)) { apply_configuration(configuration); } } + + if (stitch_output && !zones.empty()) + { + auto wo = outputs.at(stitch_output)->output.get(); + if (wo) + { + wo->get_data_safe()->zones = std::move(zones); + wo->render->damage_whole(); + LOGI("Combined desktop: ", stitch_output->width, "x", stitch_output->height, + " on STITCH-1"); + } + } else + { + for (auto& [handle, lo] : outputs) + { + lo->stitch_restore_state.reset(); + lo->stitch_power_off = false; + } + + destroy_stitch_output(); + } } /** @@ -1747,6 +2057,7 @@ class output_layout_t::impl /** Apply the given configuration. Config MUST be a valid configuration */ void apply_configuration(const output_configuration_t& config) { + release_mirror_buffers(); LOGC(OUTPUT, "Applying configuration:"); for (auto& entry : config) { @@ -1966,10 +2277,16 @@ class output_layout_t::impl } const bool wants_dpms = (ev->mode == ZWLR_OUTPUT_POWER_V1_MODE_OFF); - config[ev->output].source = (wants_dpms ? OUTPUT_IMAGE_SOURCE_DPMS : OUTPUT_IMAGE_SOURCE_SELF); + auto target = ev->output; + if (stitch_output && outputs.at(target)->stitch_source_box) + { + target = stitch_output; + } + + config[target].source = (wants_dpms ? OUTPUT_IMAGE_SOURCE_DPMS : OUTPUT_IMAGE_SOURCE_SELF); apply_configuration(config); - auto& wo = outputs[ev->output]; + auto& wo = outputs[target]; if (wo->inhibited != wants_dpms) { wo->inhibited = wants_dpms; @@ -1980,6 +2297,42 @@ class output_layout_t::impl } /* Public API functions */ + bool set_output_power(const std::string& name, bool enabled) + { + for (auto& [handle, lo] : outputs) + { + if (name != handle->name) + { + continue; + } + + if (lo->stitch_source_box) + { + lo->stitch_power_off = !enabled; + lo->apply_state(lo->current_state); + if (auto source = find_output(lo->current_state.mirror_from)) + { + source->render->damage_whole(); + } + + return true; + } + + if (!lo->output) + { + return false; + } + + wlr_output_power_v1_set_mode_event ev{}; + ev.output = handle; + ev.mode = enabled ? ZWLR_OUTPUT_POWER_V1_MODE_ON : ZWLR_OUTPUT_POWER_V1_MODE_OFF; + set_power_mode(&ev); + return true; + } + + return false; + } + wlr_output_layout *get_handle() { return output_layout; @@ -2077,6 +2430,11 @@ output_layout_t::output_layout_t(wlr_backend *b) : pimpl(new impl(b)) {} output_layout_t::~output_layout_t() = default; +bool output_layout_t::set_output_power(const std::string& name, bool enabled) +{ + return pimpl->set_output_power(name, enabled); +} + wlr_output_layout*output_layout_t::get_handle() { return pimpl->get_handle(); diff --git a/src/core/seat/input-manager.cpp b/src/core/seat/input-manager.cpp index 2d718cf..a124fe5 100644 --- a/src/core/seat/input-manager.cpp +++ b/src/core/seat/input-manager.cpp @@ -145,6 +145,8 @@ wf::input_manager_t::input_manager_t() configure_input_devices(); }); wf::get_core().output_layout->connect(&output_added); + layout_changed = [this] (auto) { configure_input_devices(); }; + wf::get_core().output_layout->connect(&layout_changed); } void wf::input_manager_t::set_exclusive_focus(wl_client *client) diff --git a/src/core/seat/input-manager.hpp b/src/core/seat/input-manager.hpp index 72a2a90..c3c3257 100644 --- a/src/core/seat/input-manager.hpp +++ b/src/core/seat/input-manager.hpp @@ -26,6 +26,7 @@ class input_manager_t wf::signal::connection_t config_updated; wf::signal::connection_t output_added; + wf::signal::connection_t layout_changed; public: /** diff --git a/src/core/seat/seat.cpp b/src/core/seat/seat.cpp index f32f116..6bf1092 100644 --- a/src/core/seat/seat.cpp +++ b/src/core/seat/seat.cpp @@ -10,6 +10,7 @@ #include "tablet.hpp" #include "input-manager.hpp" #include "wayfire/output-layout.hpp" +#include "wayfire/output-stitch.hpp" #include #include "wayfire/scene-input.hpp" #include "wayfire/scene.hpp" @@ -691,6 +692,28 @@ void wf::input_device_impl_t::map_to_output(std::shared_ptrget_outputs()) + { + if (auto group = output->get_data()) + { + for (auto& member : group->zones) + { + if (member.name == mapped_output) + { + auto origin = output->get_layout_geometry(); + wlr_box region{(int)(origin.x + member.geometry.x), + (int)(origin.y + member.geometry.y), + (int)member.geometry.width, (int)member.geometry.height}; + wlr_cursor_map_input_to_output(cursor, handle, nullptr); + wlr_cursor_map_input_to_region(cursor, handle, ®ion); + return; + } + } + } + } + auto wo = wf::get_core().output_layout->find_output(mapped_output); if (wo) {