--- a/src/displays.cpp +++ b/src/displays.cpp @@ -103,20 +103,43 @@ left.set_border_width(12); right.set_border_width(12); settings_tabs.append_page(left, "Desktop and window zones"); settings_tabs.append_page(right, "Physical monitor"); + settings_tabs.append_page(scope_tab, "Application scope"); + scope_tab.set_border_width(12); + scope_help.set_text("Physical scope keeps maximize and fullscreen on one monitor. VIRTUAL spans the combined desktop. Dialogs follow their parent; moving a window updates its current monitor."); + scope_help.set_line_wrap(); scope_help.set_max_width_chars(65); scope_help.set_halign(Gtk::ALIGN_START); + scope_tab.pack_start(scope_help, false, false); + rules_help.set_text("Application exceptions — first matching rule wins. App IDs are case sensitive; * matches any text and ? matches one character. Use a running application to select its exact ID."); + rules_help.set_line_wrap(); rules_help.set_max_width_chars(65); rules_help.set_halign(Gtk::ALIGN_START); + scope_tab.pack_start(rules_help, false, false); + running_apps.get_accessible()->set_name("Running application ID"); + rule_actions.pack_start(running_apps, true, true); rule_actions.pack_start(add_running, false, false); + scope_tab.pack_start(rule_actions, false, false); scope_tab.pack_start(rules_box, false, false); + scope_tab.pack_start(add_rule, false, false); + scope_shortcuts.set_row_spacing(8); scope_shortcuts.set_column_spacing(12); + field(scope_shortcuts, 0, "Use VIRTUAL shortcut", virtual_binding); + field(scope_shortcuts, 1, "Use physical monitor shortcut", physical_binding); + field(scope_shortcuts, 2, "Restore application policy shortcut", reset_binding); + field(scope_shortcuts, 3, "Move to next monitor shortcut", next_binding); + scope_tab.pack_start(scope_shortcuts, false, false); + add_rule.signal_clicked().connect([this] { append_rule("", "virtual"); }); + add_running.signal_clicked().connect([this] { if (!running_apps.get_active_id().empty()) append_rule(running_apps.get_active_id(), "virtual"); }); body.pack_start(settings_tabs, true, true); settings_scroll.add(body); settings_scroll.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); settings_scroll.set_min_content_height(120); pack_start(settings_scroll, true, true); left.pack_start(combined, false, false); general_grid.set_row_spacing(10); general_grid.set_column_spacing(12); - field(general_grid, 0, "Default window destination", default_output); + field(general_grid, 0, "Default desktop output", default_output); + default_scope.append("physical", "Physical monitor"); default_scope.append("virtual", "VIRTUAL — combined desktop"); + default_scope.get_accessible()->set_name("Default window scope"); + field(general_grid, 4, "Default window scope", default_scope); gap.set_range(0, 1000); gap.set_increments(1, 5); gap.get_accessible()->set_name("Shared monitor-zone gap"); field(general_grid, 1, "Monitor-zone outer gap (px)", gap); field(general_grid, 2, "Zone maximize shortcut", zone_binding); field(general_grid, 3, "Zone drag binding", drag_binding); left.pack_start(general_grid, false, false); left.pack_start(shift, false, false); - constraints.set_text("Combined monitors: horizontal arrangement, matching heights and refresh rates, scale 1, normal rotation, SDR/8-bit and fixed refresh. Ordinary maximize and fullscreen use the whole virtual desktop."); + constraints.set_text("Combined monitors: horizontal arrangement, matching heights and refresh rates, scale 1, normal rotation, SDR/8-bit and fixed refresh. Window scope controls whether maximize and fullscreen use one physical monitor or the combined desktop."); constraints.set_line_wrap(); constraints.set_max_width_chars(48); constraints.set_halign(Gtk::ALIGN_START); left.pack_start(constraints, false, false); left.pack_start(zones_button, false, false); left.pack_start(shell_button, false, false); @@ -158,8 +181,8 @@ monitors.signal_changed().connect(sigc::mem_fun(*this, &DisplaysPage::select_monitor)); auto change = [this] { if (!loading) changed(); }; for (auto *widget : {&combined, &include, &shift}) widget->signal_toggled().connect(change); - for (auto *widget : {&default_output, &role, &mode, &rotation}) widget->signal_changed().connect(change); - for (auto *widget : {&position, &zone_binding, &drag_binding}) widget->signal_changed().connect(change); + for (auto *widget : {&default_output, &role, &mode, &rotation, &default_scope}) widget->signal_changed().connect(change); + for (auto *widget : {&position, &zone_binding, &drag_binding, &virtual_binding, &physical_binding, &reset_binding, &next_binding}) widget->signal_changed().connect(change); scale.signal_value_changed().connect(change); gap.signal_value_changed().connect(change); local_gap.signal_value_changed().connect(change); auto move = [this] (int delta) @@ -226,6 +249,14 @@ wf::json_t opts = data["options"]; for (auto& key : opts.get_member_names()) original[key] = opts[key].as_string(); draft = original; + if (!draft.count("core/window_scope_rules")) + { + loading = false; + throw std::runtime_error("Application scope needs Wayfire r8 or later. Restart into the matching compositor after installing it."); + } + default_scope.set_active_id(get("core/window_scope")); + virtual_binding.set_text(get("grid/use_virtual")); physical_binding.set_text(get("grid/use_physical")); + reset_binding.set_text(get("grid/reset_scope")); next_binding.set_text(get("grid/next_monitor")); wf::json_t physical = data["physical"]; for (size_t i = 0; i < physical.size(); i++) { @@ -242,6 +273,32 @@ } heads[h.name] = h; monitors.append(h.name, h.name); } + scope_rows.clear(); + for (auto child : rules_box.get_children()) rules_box.remove(*child); + wf::json_t rules; + if (!wf::json_t::parse_string(get("core/window_scope_rules"), rules) && rules.is_array()) + for (size_t i = 0; i < rules.size(); ++i) + { + wf::json_t rule = rules[i]; + if (rule.is_object() && rule.has_member("app_id") && rule["app_id"].is_string() && + rule.has_member("scope") && rule["scope"].is_string()) + append_rule(rule["app_id"].as_string(), rule["scope"].as_string()); + } + running_apps.remove_all(); + try + { + auto views = request("window-rules/list-views"); std::set apps; + for (size_t i = 0; i < views.size(); ++i) + { + wf::json_t view = views[i]; + if (view["mapped"].as_bool() && view["role"].as_string() == "toplevel") + { + auto app = view["app-id"].as_string(); + if (!app.empty() && apps.insert(app).second) running_apps.append(app, app); + } + } + running_apps.set_active(0); + } catch (...) {} // Rules remain editable when ipc-rules is disabled. 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"); @@ -301,6 +358,9 @@ if (loading) return; set("core/stitch_enabled", combined.get_active() ? "true" : "false"); set("core/default_output", default_output.get_active_id()); + set("core/window_scope", default_scope.get_active_id()); + 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("grid/zone_maximize", zone_binding.get_text()); set("move/activate_zone", drag_binding.get_text()); @@ -443,3 +503,56 @@ identify_timer = Glib::signal_timeout().connect([this] { identifiers.clear(); return false; }, 3000); } catch (const std::exception& e) { error(e.what()); } } + + +void DisplaysPage::append_rule(const std::string& app, const std::string& scope) +{ + auto row = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL, 6)); + auto entry = Gtk::manage(new Gtk::Entry()); + auto choice = Gtk::manage(new Gtk::ComboBoxText()); + auto up = Gtk::manage(new Gtk::Button("↑")); + auto down = Gtk::manage(new Gtk::Button("↓")); + auto remove = Gtk::manage(new Gtk::Button("Remove")); + entry->set_text(app); entry->set_placeholder_text("Application ID or pattern"); + entry->get_accessible()->set_name("Application ID pattern"); entry->set_hexpand(true); + choice->append("virtual", "VIRTUAL"); choice->append("physical", "Current monitor"); + for (auto& [name, head] : heads) + if (get(head.prefix + "masked") != "true") choice->append(name, name); + if (!choice->set_active_id(scope)) { choice->append(scope, scope + " (unavailable)"); choice->set_active_id(scope); } + choice->get_accessible()->set_name("Application window scope"); + up->set_tooltip_text("Move rule earlier"); down->set_tooltip_text("Move rule later"); + row->pack_start(*entry, true, true); row->pack_start(*choice, false, false); + row->pack_start(*up, false, false); row->pack_start(*down, false, false); row->pack_start(*remove, false, false); + rules_box.pack_start(*row, false, false); scope_rows.push_back({row, entry, choice}); + auto move = [this, row] (int delta) + { + auto it = std::find_if(scope_rows.begin(), scope_rows.end(), [row] (auto& r) { return r.row == row; }); + int i = it - scope_rows.begin(), target = i + delta; + if (it == scope_rows.end() || target < 0 || target >= (int)scope_rows.size()) return; + std::swap(scope_rows[i], scope_rows[target]); + for (size_t n = 0; n < scope_rows.size(); ++n) rules_box.reorder_child(*scope_rows[n].row, n); + store_rules(); + }; + up->signal_clicked().connect([move] { move(-1); }); down->signal_clicked().connect([move] { move(1); }); + remove->signal_clicked().connect([this, row] + { + scope_rows.erase(std::remove_if(scope_rows.begin(), scope_rows.end(), [row] (auto& r) { return r.row == row; }), scope_rows.end()); + rules_box.remove(*row); store_rules(); + }); + entry->signal_changed().connect([this] { store_rules(); }); choice->signal_changed().connect([this] { store_rules(); }); + row->show_all(); + if (!loading) { store_rules(); entry->grab_focus(); } +} + +void DisplaysPage::store_rules() +{ + if (loading) return; + wf::json_t rules = wf::json_t::array(); + for (auto& row : scope_rows) + { + wf::json_t rule; + rule["app_id"] = row.app->get_text().raw(); rule["scope"] = row.scope->get_active_id().raw(); + rules.append(rule); + } + set("core/window_scope_rules", rules.serialize()); changed(); +} --- a/src/displays.hpp +++ b/src/displays.hpp @@ -25,7 +25,16 @@ Gtk::Button shell_button{"Panel Settings"}, zones_button{"More Window-Zone Settings"}; Gtk::CheckButton combined{"Combine selected monitors into VIRTUAL-1"}, include{"Include in combined desktop"}; Gtk::CheckButton shift{"Shift-maximize uses a physical monitor"}; - Gtk::ComboBoxText default_output, monitors, role, mode, rotation; + Gtk::ComboBoxText default_output, monitors, role, mode, rotation, default_scope, running_apps; + Gtk::Box scope_tab{Gtk::ORIENTATION_VERTICAL, 10}, rules_box{Gtk::ORIENTATION_VERTICAL, 6}, rule_actions{Gtk::ORIENTATION_HORIZONTAL, 8}; + Gtk::Label scope_help, rules_help; + Gtk::Button add_rule{"Add Rule"}, add_running{"Add Selected Application"}; + Gtk::Grid scope_shortcuts; + Gtk::Entry virtual_binding, physical_binding, reset_binding, next_binding; + struct ScopeRule { Gtk::Box *row; Gtk::Entry *app; Gtk::ComboBoxText *scope; }; + std::vector scope_rows; + void append_rule(const std::string& app, const std::string& scope); + void store_rules(); Gtk::Entry position, zone_binding, drag_binding; Gtk::SpinButton scale, gap, local_gap; Gtk::Grid general_grid, monitor_grid;