diff --git a/metadata/core.xml b/metadata/core.xml index ffda987..7b780ca 100644 --- a/metadata/core.xml +++ b/metadata/core.xml @@ -11,7 +11,12 @@ <_short>Combined output members - <_long>Space-separated connector names, in left-to-right order. The combined output is named STITCH-1. + <_long>Space-separated connector names, in left-to-right order. The combined output is named VIRTUAL-1. + + + + <_short>Default output for new windows + <_long>Desktop output name used for new native windows, for example VIRTUAL-1. Empty or unavailable uses the active output. Dialogs follow their parent; window rules and explicit later moves can override placement. diff --git a/metadata/grid.xml b/metadata/grid.xml index a03849a..72dc576 100644 --- a/metadata/grid.xml +++ b/metadata/grid.xml @@ -14,6 +14,12 @@ <_long>On combined outputs, hold Shift while maximizing or dragging to fill a physical monitor zone. Dragging shows a preview. true + + <_short>Monitor-zone outer gap + <_long>Inset each physical monitor zone by this many logical pixels after panel reservations. Applies to zone maximize and drag previews; ordinary maximize and fullscreen keep their full size. + 0 + 0 + <_short>Duration <_long>Sets the duration of the animation in milliseconds. diff --git a/metadata/output.xml b/metadata/output.xml index 663b702..9b24ecd 100644 --- a/metadata/output.xml +++ b/metadata/output.xml @@ -8,6 +8,11 @@ auto + + <_short>Hide from desktop, keep signal active + <_long>Keep this physical output enabled with a black image, but exclude it from application display lists, window placement and desktop power management. Useful for HDMI audio. Explicit output power commands and mode=off can still turn it off. Masked outputs cannot be combined desktop members. + false + 1.0 0.1 diff --git a/plugins/grid/grid.cpp b/plugins/grid/grid.cpp index 5e30063..1a474ec 100644 --- a/plugins/grid/grid.cpp +++ b/plugins/grid/grid.cpp @@ -61,6 +61,7 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke 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 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 @@ -114,6 +115,17 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke zone.geometry = wf::geometry_intersection(zone.geometry, output->workarea->get_workarea()); if ((zone.geometry.width > 0) && (zone.geometry.height > 0)) { + // Inset each physical region, including the internal seams. + // Clamp independently so even a small region stays usable. + auto& g = zone.geometry; + double gx = std::clamp(zone_gap, 0, + std::max(0.0, std::floor((g.width - 1) / 2))); + double gy = std::clamp(zone_gap, 0, + std::max(0.0, std::floor((g.height - 1) / 2))); + g.x += gx; + g.y += gy; + g.width -= 2 * gx; + g.height -= 2 * gy; return zone; } } @@ -238,6 +250,7 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke void init() override { init_output_tracking(); + zone_gap.set_callback([this] () { reflow_zones(); }); restore.set_handler(handle_restore); zone_maximize.set_handler([=] (wf::output_t *wo, wayfire_view view) { @@ -419,8 +432,7 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke return false; } - wf::signal::connection_t on_layout_changed = - [this] (auto) + void reflow_zones() { // Member order can change without changing the combined workarea. for (auto output : wf::get_core().output_layout->get_outputs()) @@ -430,6 +442,20 @@ class wayfire_grid : public wf::plugin_interface_t, public wf::per_output_tracke reflow_zone(view); } } + + 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 = + [this] (auto) + { + reflow_zones(); }; wf::signal::connection_t on_workarea_changed = diff --git a/src/api/wayfire/output-layout.hpp b/src/api/wayfire/output-layout.hpp index ddc11cd..d40ff3c 100644 --- a/src/api/wayfire/output-layout.hpp +++ b/src/api/wayfire/output-layout.hpp @@ -106,6 +106,10 @@ enum output_image_source_t OUTPUT_IMAGE_SOURCE_DPMS = 0x3, /** Output is in mirroring state */ OUTPUT_IMAGE_SOURCE_MIRROR = 0x4, + /** Active black scanout, excluded from the desktop (for example HDMI audio). */ + OUTPUT_IMAGE_SOURCE_MASKED = 0x8, + /** Masked output explicitly put into DPMS; remains excluded from the desktop. */ + OUTPUT_IMAGE_SOURCE_MASKED_DPMS = 0xa, }; /** Represents the current state of an output as the output layout sees it */ diff --git a/src/core/output-layout.cpp b/src/core/output-layout.cpp index 6d88d6a..41c5d10 100644 --- a/src/core/output-layout.cpp +++ b/src/core/output-layout.cpp @@ -154,6 +154,12 @@ std::string_view wf::layout_detail::get_output_source_name(output_image_source_t case OUTPUT_IMAGE_SOURCE_MIRROR: return "mirror"; + + case OUTPUT_IMAGE_SOURCE_MASKED: + return "masked"; + + case OUTPUT_IMAGE_SOURCE_MASKED_DPMS: + return "masked-dpms"; } return "unknown"; @@ -486,7 +492,7 @@ 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. + // A source rectangle turns a mirror into one physical slice of VIRTUAL-1. std::optional stitch_source_box; std::optional stitch_restore_state; std::vector stitch_zones; @@ -503,6 +509,7 @@ struct output_layout_output_t wf::option_wrapper_t vrr_opt; wf::option_wrapper_t hdr_opt; wf::option_wrapper_t depth_opt; + wf::option_wrapper_t masked_opt; wf::option_wrapper_t use_ext_config{ "workarounds/use_external_output_configuration"}; @@ -517,6 +524,7 @@ struct output_layout_output_t vrr_opt.load_option(config_section, "vrr"); hdr_opt.load_option(config_section, "hdr"); depth_opt.load_option(config_section, "depth"); + masked_opt.load_option(config_section, "masked"); } output_layout_output_t(wlr_output *handle) @@ -716,11 +724,23 @@ struct output_layout_output_t return false; } - /** - * Load the state the output is configured with. - * This is typically the config file, but in case of daemons like kanshi this - * might be the external configuration. - */ + /** Apply desktop visibility without discarding explicit power state. */ + void apply_mask_policy(output_state_t& state) + { + // Desktop visibility is independent of the connector's power state. + // Off remains off; a configured mirror becomes a black keepalive output. + if (masked_opt && (state.source != OUTPUT_IMAGE_SOURCE_NONE)) + { + state.source = (state.source & OUTPUT_IMAGE_SOURCE_NONE) ? + OUTPUT_IMAGE_SOURCE_MASKED_DPMS : OUTPUT_IMAGE_SOURCE_MASKED; + } else if (!masked_opt && (state.source & OUTPUT_IMAGE_SOURCE_MASKED)) + { + state.source = (state.source & OUTPUT_IMAGE_SOURCE_NONE) ? + OUTPUT_IMAGE_SOURCE_DPMS : OUTPUT_IMAGE_SOURCE_SELF; + } + } + + /** Load configured or externally managed state, including desktop visibility. */ output_state_t load_configured_state() { // Ensure custom modes from the config are enabled @@ -731,7 +751,9 @@ struct output_layout_output_t if (should_ignore_config_state()) { // Current state is what was requested by the client. - return this->current_state; + auto state = this->current_state; + apply_mask_policy(state); + return state; } output_state_t state; @@ -775,6 +797,7 @@ struct output_layout_output_t state.vrr = vrr_opt; state.hdr = hdr_opt; state.depth = depth_opt; + apply_mask_policy(state); return state; } @@ -1085,6 +1108,31 @@ struct output_layout_output_t return pending_state.commit(handle); } + bool render_black_output() + { + auto pass = wlr_output_begin_render_pass(handle, &pending_state.pending, nullptr); + if (!pass) + { + pending_state.reset(); + return false; + } + + wlr_render_rect_options black{}; + black.box = {0, 0, handle->width, handle->height}; + black.color = {0, 0, 0, 1}; + black.blend_mode = WLR_RENDER_BLEND_MODE_NONE; + wlr_render_pass_add_rect(pass, &black); + if (!wlr_render_pass_submit(pass)) + { + pending_state.reset(); + return false; + } + + // Scanout repeats the committed black buffer in hardware. There is no + // frame listener or render loop for a masked output. + return pending_state.commit(handle); + } + /* Load output contents and render them */ wlr_buffer *source_back_buffer = NULL; wf::explicit_sync_point_t source_wait_point; @@ -1248,7 +1296,7 @@ struct output_layout_output_t */ void emit_configuration_changed(uint32_t changed_fields) { - if ((handle->data != WF_NOOP_OUTPUT_MAGIC) && changed_fields) + if (output && (handle->data != WF_NOOP_OUTPUT_MAGIC) && changed_fields) { wf::output_configuration_changed_signal data{current_state}; data.output = output.get(); @@ -1302,7 +1350,8 @@ struct output_layout_output_t * up again, in case the output to mirror from changed */ teardown_mirror(); - if (state.source == OUTPUT_IMAGE_SOURCE_NONE || (stitch_source_box && stitch_power_off)) + if (state.source == OUTPUT_IMAGE_SOURCE_NONE || state.source == OUTPUT_IMAGE_SOURCE_MASKED_DPMS || + (stitch_source_box && stitch_power_off)) { /* output is OFF */ destroy_wayfire_output(); @@ -1337,6 +1386,13 @@ struct output_layout_output_t ensure_wayfire_output(get_effective_size()); emit_configuration_changed(changed_fields); output->render->damage_whole(); + } else if (state.source == OUTPUT_IMAGE_SOURCE_MASKED) + { + destroy_wayfire_output(); + if (!render_black_output()) + { + LOGE("Failed to commit black keepalive frame on masked output ", handle->name); + } } else /* state.source == OUTPUT_IMAGE_SOURCE_MIRROR */ { destroy_wayfire_output(); @@ -1534,6 +1590,10 @@ class output_layout_t::impl state.source = OUTPUT_IMAGE_SOURCE_SELF; + // Output-management clients may edit modes or power, but must not + // accidentally expose an output configured as masked. + outputs.at(handle)->apply_mask_policy(state); + if (head->state.mode) { state.mode = *head->state.mode; @@ -1844,9 +1904,9 @@ class output_layout_t::impl { for (auto& [handle, state] : config) { - if (std::string(handle->name) == "STITCH-1") + if (std::string(handle->name) == "VIRTUAL-1") { - LOGE("Cannot stitch: STITCH-1 is already in use."); + LOGE("Cannot stitch: VIRTUAL-1 is already in use."); return {}; } } @@ -1858,7 +1918,7 @@ class output_layout_t::impl return {}; } - wlr_output_set_name(stitch_output, "STITCH-1"); + wlr_output_set_name(stitch_output, "VIRTUAL-1"); wlr_output_set_description(stitch_output, "Combined desktop output"); if (!wlr_output_init_render(stitch_output, get_core().allocator, get_core().renderer)) { @@ -1908,7 +1968,7 @@ class output_layout_t::impl 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"; + state.mirror_from = "VIRTUAL-1"; } return zones; @@ -1960,7 +2020,7 @@ class output_layout_t::impl 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"); + " on VIRTUAL-1"); } } else { @@ -2186,14 +2246,14 @@ class output_layout_t::impl } } - /* Fourth: enable mirrored outputs */ + /* Fourth: configure outputs which have no desktop of their own. */ for (auto& entry : config) { auto& handle = entry.first; auto& state = entry.second; auto& lo = this->outputs[handle]; - if (state.source == OUTPUT_IMAGE_SOURCE_MIRROR) + if (state.source == OUTPUT_IMAGE_SOURCE_MIRROR || (state.source & OUTPUT_IMAGE_SOURCE_MASKED)) { lo->apply_state(state); wlr_output_layout_remove(output_layout, handle); @@ -2278,6 +2338,12 @@ class output_layout_t::impl const bool wants_dpms = (ev->mode == ZWLR_OUTPUT_POWER_V1_MODE_OFF); auto target = ev->output; + if (config[target].source & OUTPUT_IMAGE_SOURCE_MASKED) + { + config[target].source = wants_dpms ? OUTPUT_IMAGE_SOURCE_MASKED_DPMS : OUTPUT_IMAGE_SOURCE_MASKED; + apply_configuration(config); + return; + } if (stitch_output && outputs.at(target)->stitch_source_box) { target = stitch_output; @@ -2318,7 +2384,7 @@ class output_layout_t::impl return true; } - if (!lo->output) + if (!lo->output && !(lo->current_state.source & OUTPUT_IMAGE_SOURCE_MASKED)) { return false; } diff --git a/src/view/xdg-shell/xdg-toplevel-view.cpp b/src/view/xdg-shell/xdg-toplevel-view.cpp index d8d558a..0b1bf6f 100644 --- a/src/view/xdg-shell/xdg-toplevel-view.cpp +++ b/src/view/xdg-shell/xdg-toplevel-view.cpp @@ -14,6 +14,7 @@ #include "wayfire/util.hpp" #include "wayfire/view.hpp" #include +#include #include #include #include @@ -258,7 +259,9 @@ std::shared_ptr wf::xdg_toplevel_view_t::create(wlr_xdg self->set_surface_root_node(self->surface_root_node); // Set the output early, so that we can emit the signals on the output - self->set_output(wf::get_core().seat->get_active_output()); + wf::option_wrapper_t default_output{"core/default_output"}; + auto initial_output = wf::get_core().output_layout->find_output(default_output.value()); + self->set_output(initial_output ?: wf::get_core().seat->get_active_output()); self->handle_title_changed(nonull(toplevel->title)); self->handle_app_id_changed(nonull(toplevel->app_id));