diff --git a/src/displays.cpp b/src/displays.cpp new file mode 100644 index 0000000..10b45d8 --- /dev/null +++ b/src/displays.cpp @@ -0,0 +1,445 @@ +#include "displays.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +std::string mode_id(int w, int h, int refresh) +{ + // wf-config serializes refresh in milli-Hz; preserve exact advertised rates. + return std::to_string(w) + "x" + std::to_string(h) + "@" + std::to_string(refresh); +} +std::string mode_label(const std::string& text) +{ + auto m = wf::option_type::from_string(text); + if (!m || m->get_type() != wf::output_config::MODE_RESOLUTION) return text; + std::ostringstream out; + out << m->get_width() << " × " << m->get_height() << " " << std::fixed << std::setprecision(3) + << m->get_refresh() / 1000.0 << " Hz"; + return out.str(); +} +void field(Gtk::Grid& grid, int row, const std::string& name, Gtk::Widget& control) +{ + auto label = Gtk::manage(new Gtk::Label(name)); + label->set_halign(Gtk::ALIGN_START); + grid.attach(*label, 0, row, 1, 1); grid.attach(control, 1, row, 1, 1); + control.set_hexpand(true); +} +} + +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; } +} + +DisplaysPage::DisplaysPage(const std::string& config, std::function on_saved, + std::function shell, std::function zones) : + Gtk::Box(Gtk::ORIENTATION_VERTICAL, 12), config_file(config), saved(on_saved), show_shell(shell), show_zones(zones) +{ + set_border_width(20); + heading.set_markup("Displays"); heading.set_halign(Gtk::ALIGN_START); + description.set_text("One desktop for applications. Physical monitors remain available for placement and display controls."); + description.set_line_wrap(); description.set_max_width_chars(65); description.set_halign(Gtk::ALIGN_START); + pack_start(heading, false, false); pack_start(description, false, false); + toolbar.pack_start(refresh_button, false, false); toolbar.pack_start(identify_button, false, false); + pack_start(toolbar, false, false); + diagram.set_size_request(560, 165); diagram.signal_draw().connect(sigc::mem_fun(*this, &DisplaysPage::draw)); + pack_start(diagram, false, false); + 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"); + 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); + 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_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); + shell_note.set_text("Panel placement and appearance use wf-shell settings. Physical-region bars and richer zone layouts require additional compositor/shell support."); + shell_note.set_line_wrap(); shell_note.set_max_width_chars(48); shell_note.set_halign(Gtk::ALIGN_START); + left.pack_start(shell_note, false, false); + right.pack_start(monitors, false, false); selection_label.set_line_wrap(); selection_label.set_max_width_chars(55); right.pack_start(selection_label, false, false); + monitor_grid.set_row_spacing(10); monitor_grid.set_column_spacing(12); + role.append("desktop", "Desktop monitor"); role.append("masked", "Audio only — keep signal active"); role.append("off", "Disabled — signal off"); + field(monitor_grid, 0, "Use", role); field(monitor_grid, 1, "Mode / refresh", mode); + position.set_placeholder_text("auto or x,y"); field(monitor_grid, 2, "Desktop position", position); + scale.set_range(.25, 8); scale.set_increments(.25, 1); scale.set_digits(2); field(monitor_grid, 3, "Scale", scale); + for (auto text : {"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}) rotation.append(text, text); + field(monitor_grid, 4, "Rotation", rotation); + local_gap.set_range(-1, 1000); local_gap.set_increments(1, 5); + local_gap.get_accessible()->set_name("Physical monitor zone gap"); + local_gap.set_tooltip_text("-1 inherits the shared monitor-zone gap; 0 removes the gap for this physical monitor."); + field(monitor_grid, 5, "Zone gap (−1 = inherit)", local_gap); + right.pack_start(monitor_grid, false, false); right.pack_start(include, false, false); + order_buttons.pack_start(left_button, true, true); order_buttons.pack_start(right_button, true, true); + right.pack_start(order_buttons, false, false); + status.set_line_wrap(); status.set_max_width_chars(65); status.set_halign(Gtk::ALIGN_START); status.set_selectable(true); pack_start(status, false, false); + actions.pack_end(apply_button, false, false); actions.pack_end(keep_button, false, false); actions.pack_end(revert_button, false, false); + pack_start(actions, false, false); + keep_button.set_no_show_all(); revert_button.set_no_show_all(); + refresh_button.signal_clicked().connect([this] () + { + if (dirty) + { + Gtk::MessageDialog dialog("Discard unapplied display changes?", false, Gtk::MESSAGE_QUESTION, Gtk::BUTTONS_OK_CANCEL, true); + if (dialog.run() != Gtk::RESPONSE_OK) return; + } + refresh(true); + }); + identify_button.signal_clicked().connect(sigc::mem_fun(*this, &DisplaysPage::identify)); + apply_button.signal_clicked().connect(sigc::mem_fun(*this, &DisplaysPage::apply)); + keep_button.signal_clicked().connect([this] { finish(true); }); revert_button.signal_clicked().connect([this] { finish(false); }); + shell_button.signal_clicked().connect([this] { show_shell(); }); zones_button.signal_clicked().connect([this] { show_zones(); }); + 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); + scale.signal_value_changed().connect(change); gap.signal_value_changed().connect(change); + local_gap.signal_value_changed().connect(change); + auto move = [this] (int delta) + { + auto it = std::find(order.begin(), order.end(), selected); + if (it == order.end()) return; + int i = it - order.begin(), target = i + delta; + if (target < 0 || target >= (int)order.size()) return; + std::swap(order[i], order[target]); update_members(); changed(); + }; + left_button.signal_clicked().connect([move] { move(-1); }); right_button.signal_clicked().connect([move] { move(1); }); + refresh(true); + timer = Glib::signal_timeout().connect([this] + { + if (!transaction.empty()) + { + --countdown; + status.set_text("Keep these display settings? Reverting automatically in " + std::to_string(std::max(0, countdown)) + " seconds."); + if (countdown <= 0) { transaction.clear(); refresh(true); status.set_text("Display preview expired; previous settings restored."); } + } else if (get_mapped() && !dirty) refresh(); + return true; + }, 1000); +} + +DisplaysPage::~DisplaysPage() +{ + timer.disconnect(); identify_timer.disconnect(); + if (!transaction.empty()) { try { wf::json_t data; data["token"] = transaction; request("displays/revert", data); } catch (...) {} } +} + +std::string DisplaysPage::get(const std::string& key) const +{ + auto it = draft.find(key); return it == draft.end() ? "" : it->second; +} +void DisplaysPage::set(const std::string& key, const std::string& value) { draft[key] = value; } +void DisplaysPage::error(const std::string& message) { status.set_text(message); } + +void DisplaysPage::refresh(bool discard) +{ + try + { + auto data = request("displays/get"); + if (data["version"].as_int() != 1) throw std::runtime_error("Unsupported display-management version"); + if (std::filesystem::weakly_canonical(data["config_file"].as_string()) != std::filesystem::weakly_canonical(config_file)) + throw std::runtime_error("WCM is editing a different configuration from the running compositor. Open its session configuration: " + data["config_file"].as_string()); + if (data["pending"].as_bool() && transaction.empty()) + { + body.set_sensitive(false); apply_button.set_sensitive(false); + status.set_text("A display preview is awaiting confirmation in another window."); return; + } + if (discard || revision != data["revision"].as_string()) load(data); + else update_controls(); + } catch (const std::exception& e) + { + error(std::string(e.what()) + "\nDisplays needs the output-control and ipc plugins from the matching Wayfire package."); + apply_button.set_sensitive(false); + } +} + +void DisplaysPage::load(const wf::json_t& data) +{ + loading = true; state = data; revision = data["revision"].as_string(); + original.clear(); draft.clear(); heads.clear(); order.clear(); monitors.remove_all(); + wf::json_t opts = data["options"]; + for (auto& key : opts.get_member_names()) original[key] = opts[key].as_string(); + draft = original; + wf::json_t physical = data["physical"]; + for (size_t i = 0; i < physical.size(); i++) + { + wf::json_t item = physical[i]; Head h; + h.name = item["name"].as_string(); h.prefix = item["prefix"].as_string(); h.description = item["description"].as_string(); + h.width = item["width"].as_int(); h.height = item["height"].as_int(); h.refresh = item["refresh"].as_int(); + std::set unique; + wf::json_t modes = item["modes"]; + for (size_t j = 0; j < modes.size(); ++j) + { + wf::json_t m = modes[j]; + auto id = mode_id(m["width"].as_int(), m["height"].as_int(), m["refresh"].as_int()); + if (unique.insert(id).second) h.modes.push_back(id); + } + heads[h.name] = h; monitors.append(h.name, h.name); + } + 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"); + 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; + monitors.set_active_id(selected); + loading = false; select_monitor(); dirty = false; update_controls(); diagram.queue_draw(); + status.set_text("Changes are previewed for 20 seconds. Keep Changes saves them; closing WCM lets the preview revert."); +} + +void DisplaysPage::select_monitor() +{ + if (loading) return; + selected = monitors.get_active_id(); + if (!heads.count(selected)) return; + loading = true; + auto& h = heads.at(selected); auto pre = h.prefix; + selection_label.set_text(h.description); + role.set_active_id(get(pre + "mode") == "off" ? "off" : get(pre + "masked") == "true" ? "masked" : "desktop"); + mode.remove_all(); mode.append("auto", "Preferred mode"); + std::set added{"auto"}; + for (auto& id : h.modes) if (added.insert(id).second) mode.append(id, mode_label(id)); + auto current = get(pre + "mode"); if (current == "off") current = "auto"; + if (added.insert(current).second) mode.append(current, mode_label(current)); + mode.set_active_id(current); + position.set_text(get(pre + "position")); scale.set_value(std::stod(get(pre + "scale"))); + rotation.set_active_id(get(pre + "transform")); + local_gap.set_value(std::stoi(get(pre + "zone_gap"))); + include.set_active(std::find(order.begin(), order.end(), selected) != order.end()); + loading = false; update_controls(); diagram.queue_draw(); +} + +void DisplaysPage::update_members() +{ + std::string members; + for (auto& name : order) { if (!members.empty()) members += " "; members += name; } + set("core/stitch_outputs", members); +} + +void DisplaysPage::update_defaults() +{ + auto desired = get("core/default_output"); + bool was_loading = loading; loading = true; + default_output.remove_all(); default_output.append("", "Follow focused output"); + if (combined.get_active()) default_output.append("VIRTUAL-1", "VIRTUAL-1 — combined desktop"); + for (auto& [name, h] : heads) + if (get(h.prefix + "mode") != "off" && get(h.prefix + "masked") != "true" && + (!combined.get_active() || std::find(order.begin(), order.end(), name) == order.end())) default_output.append(name, name); + if (!default_output.set_active_id(desired)) { default_output.set_active(0); set("core/default_output", ""); } + loading = was_loading; +} + +void DisplaysPage::changed() +{ + if (loading) return; + set("core/stitch_enabled", combined.get_active() ? "true" : "false"); + set("core/default_output", default_output.get_active_id()); + 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()); + if (heads.count(selected)) + { + auto pre = heads.at(selected).prefix; + set(pre + "masked", role.get_active_id() == "masked" ? "true" : "false"); + set(pre + "mode", role.get_active_id() == "off" ? "off" : std::string(mode.get_active_id())); + set(pre + "position", position.get_text()); + std::ostringstream numeric; numeric << scale.get_value(); set(pre + "scale", numeric.str()); + set(pre + "transform", rotation.get_active_id()); + set(pre + "zone_gap", std::to_string(local_gap.get_value_as_int())); + auto it = std::find(order.begin(), order.end(), selected); + if (include.get_active() && role.get_active_id() == "desktop") { if (it == order.end()) order.push_back(selected); } + else if (it != order.end()) order.erase(it); + } + update_members(); update_defaults(); dirty = draft != original; + update_controls(); diagram.queue_draw(); + status.set_text(dirty ? "Unapplied changes" : "No unapplied changes"); +} + +void DisplaysPage::update_controls() +{ + bool preview = !transaction.empty(); + body.set_sensitive(!preview); refresh_button.set_sensitive(!preview); identify_button.set_sensitive(!heads.empty() && !preview); + keep_button.set_visible(preview); revert_button.set_visible(preview); apply_button.set_visible(!preview); apply_button.set_sensitive(dirty && !preview); + bool desktop = role.get_active_id() == "desktop"; + include.set_sensitive(desktop); + local_gap.set_sensitive(desktop && include.get_active()); + position.set_sensitive(desktop); scale.set_sensitive(desktop); rotation.set_sensitive(desktop); + mode.set_sensitive(role.get_active_id() != "off"); + auto it = std::find(order.begin(), order.end(), selected); + left_button.set_sensitive(it != order.end() && it != order.begin()); + right_button.set_sensitive(it != order.end() && it + 1 != order.end()); +} + +void DisplaysPage::apply() +{ + try + { + wf::json_t options, data; + for (auto& [key, value] : draft) if (!original.count(key) || original.at(key) != value) options[key] = value; + data["options"] = options; data["revision"] = revision; + auto response = request("displays/apply", data); + transaction = response["token"].as_string(); countdown = response["timeout"].as_int(); + status.set_text("Keep these display settings? They revert automatically in 20 seconds."); update_controls(); + keep_button.grab_focus(); + } catch (const std::exception& e) { error(e.what()); } +} + +void DisplaysPage::finish(bool keep) +{ + try + { + wf::json_t data; data["token"] = transaction; + request(keep ? "displays/confirm" : "displays/revert", data); + transaction.clear(); if (keep) saved(); refresh(true); + status.set_text(keep ? "Display settings saved." : "Previous display settings restored."); + } catch (const std::exception& e) + { + transaction.clear(); refresh(true); error(e.what()); + } +} + +bool DisplaysPage::draw(const Cairo::RefPtr& cr) +{ + auto a = diagram.get_allocation(); double width = a.get_width(); + cr->set_source_rgba(.15, .17, .20, .10); cr->paint(); + std::vector visible; + if (combined.get_active()) for (auto& name : order) if (heads.count(name)) visible.push_back(name); + for (auto& [name, h] : heads) + if (get(h.prefix + "mode") != "off" && get(h.prefix + "masked") != "true" && std::find(visible.begin(), visible.end(), name) == visible.end()) visible.push_back(name); + cr->select_font_face("Sans", Cairo::FONT_SLANT_NORMAL, Cairo::FONT_WEIGHT_BOLD); cr->set_font_size(14); + cr->set_source_rgb(.35, .6, .9); cr->move_to(18, 25); + cr->show_text(combined.get_active() ? "VIRTUAL-1 · one desktop, one workspace set" : "Separate desktop outputs"); + double x = 18, cell = visible.empty() ? 120 : (width - 36) / visible.size(); + if (combined.get_active() && !order.empty()) + { + cr->set_source_rgb(.35, .6, .9); cr->set_line_width(2); + cr->rectangle(18, 36, cell * std::min(order.size(), visible.size()), 84); cr->stroke(); + } + for (auto& name : visible) + { + auto& h = heads.at(name); + cr->set_source_rgba(.25, .5, .8, name == selected ? .7 : .3); + cr->rectangle(x + 3, 40, cell - 6, 76); cr->fill(); + cr->set_source_rgb(.95, .95, .95); cr->move_to(x + 12, 67); cr->show_text(name); + auto m = wf::option_type::from_string(get(h.prefix + "mode")); + auto dimensions = std::to_string(m && m->get_type() == wf::output_config::MODE_RESOLUTION ? m->get_width() : h.width) + " × " + + std::to_string(m && m->get_type() == wf::output_config::MODE_RESOLUTION ? m->get_height() : h.height); + cr->move_to(x + 12, 94); cr->show_text(dimensions); x += cell; + } + std::string masked; + for (auto& [name, h] : heads) if (get(h.prefix + "masked") == "true") masked += name + " "; + cr->set_source_rgb(.5, .5, .5); cr->move_to(18, 151); + cr->show_text(masked.empty() ? "Select a monitor below to edit its role and mode." : "Outside desktop: " + masked + "(audio only / active signal)"); + return true; +} + +void DisplaysPage::identify() +{ + try + { + identifiers.clear(); identify_timer.disconnect(); + auto live = request("displays/get"); wf::json_t desktops = live["desktops"]; + auto display = Gdk::Display::get_default(); + for (size_t i = 0; i < desktops.size(); ++i) + { + wf::json_t desktop = desktops[i]; + Glib::RefPtr monitor; + for (int n = 0; n < display->get_n_monitors(); ++n) + { + auto candidate = display->get_monitor(n); Gdk::Rectangle g; candidate->get_geometry(g); + if (g.get_x() == (int)desktop["x"].as_double() && g.get_y() == (int)desktop["y"].as_double()) { monitor = candidate; break; } + } + if (!monitor) continue; + wf::json_t members = desktop["members"]; + std::vector names; + for (size_t n = 0; n < members.size(); n++) names.push_back(members[n].as_string()); + if (names.empty()) names.push_back(desktop["name"].as_string()); + double offset = 0; + for (auto& name : names) + { + double w = names.size() == 1 ? desktop["width"].as_double() : heads.at(name).width; + auto window = std::make_unique(); + auto label = Gtk::manage(new Gtk::Label()); + label->set_markup("" + Glib::Markup::escape_text(name) + ""); + label->set_margin_top(24); label->set_margin_bottom(24); label->set_margin_left(24); label->set_margin_right(24); + window->add(*label); window->set_decorated(false); + gtk_layer_init_for_window(window->gobj()); gtk_layer_set_namespace(window->gobj(), "wcm-identify"); + gtk_layer_set_layer(window->gobj(), GTK_LAYER_SHELL_LAYER_OVERLAY); + gtk_layer_set_monitor(window->gobj(), monitor->gobj()); gtk_layer_set_exclusive_zone(window->gobj(), -1); + gtk_layer_set_anchor(window->gobj(), GTK_LAYER_SHELL_EDGE_TOP, true); gtk_layer_set_anchor(window->gobj(), GTK_LAYER_SHELL_EDGE_LEFT, true); + gtk_layer_set_margin(window->gobj(), GTK_LAYER_SHELL_EDGE_LEFT, offset + std::max(0.0, w / 2 - 120)); + gtk_layer_set_margin(window->gobj(), GTK_LAYER_SHELL_EDGE_TOP, 80); + window->show_all(); window->get_window()->input_shape_combine_region(Cairo::Region::create(), 0, 0); + identifiers.push_back(std::move(window)); offset += w; + } + } + identify_timer = Glib::signal_timeout().connect([this] { identifiers.clear(); return false; }, 3000); + } catch (const std::exception& e) { error(e.what()); } +} diff --git a/src/displays.hpp b/src/displays.hpp new file mode 100644 index 0000000..999b329 --- /dev/null +++ b/src/displays.hpp @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include +#include + +// Native WCM page; all configuration validation and persistence is performed +// by the compositor. This page never writes a second display configuration. +class DisplaysPage : public Gtk::Box +{ + struct Head + { + std::string name, description, prefix; + int width = 0, height = 0, refresh = 0; + std::vector modes; + }; + Gtk::Label heading{"Displays"}, description, status, selection_label; + Gtk::Box toolbar{Gtk::ORIENTATION_HORIZONTAL, 8}, body{Gtk::ORIENTATION_HORIZONTAL, 16}; + Gtk::Box left{Gtk::ORIENTATION_VERTICAL, 8}, right{Gtk::ORIENTATION_VERTICAL, 8}; + Gtk::Notebook settings_tabs; + Gtk::ScrolledWindow settings_scroll; + Gtk::Box actions{Gtk::ORIENTATION_HORIZONTAL, 8}, order_buttons{Gtk::ORIENTATION_HORIZONTAL, 8}; + Gtk::Button refresh_button{"Refresh"}, identify_button{"Identify"}, apply_button{"Apply"}; + Gtk::Button keep_button{"Keep Changes"}, revert_button{"Revert"}, left_button{"Move Left"}, right_button{"Move Right"}; + 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::Entry position, zone_binding, drag_binding; + Gtk::SpinButton scale, gap, local_gap; + Gtk::Grid general_grid, monitor_grid; + Gtk::DrawingArea diagram; + Gtk::Label constraints, shell_note; + std::map heads; + std::vector order; + std::map original, draft; + wf::json_t state; + std::string revision, transaction, selected, config_file; + bool loading = false, dirty = false; + int countdown = 0; + sigc::connection timer, identify_timer; + std::vector> identifiers; + std::function saved, show_shell, show_zones; + + wf::json_t request(const std::string& method, wf::json_t data = {}); + void refresh(bool discard = false); + void load(const wf::json_t& data); + void select_monitor(); + void changed(); + void update_members(); + void update_defaults(); + void update_controls(); + void apply(); + void finish(bool keep); + void identify(); + bool draw(const Cairo::RefPtr& cr); + std::string get(const std::string& key) const; + void set(const std::string& key, const std::string& value); + void error(const std::string& message); + public: + DisplaysPage(const std::string& config, std::function saved, + std::function shell, std::function zones); + ~DisplaysPage() override; +}; diff --git a/src/meson.build b/src/meson.build index 9700a30..92f39bd 100644 --- a/src/meson.build +++ b/src/meson.build @@ -5,9 +5,11 @@ xkbregistry = dependency('xkbregistry', required: true) libintl = dependency('intl', required: true) libfmt = dependency('fmt', required: true) -dep_list = [xml, gtkmm, wf_config, wf_protos, evdev, xkbregistry, libintl, libfmt] +wf_json = dependency('yyjson') +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') +sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'displays.cpp', 'vendor/wf-json.cpp') executable(meson.project_name(), sources, install : true, diff --git a/src/vendor/wf-json.LICENSE b/src/vendor/wf-json.LICENSE new file mode 100644 index 0000000..139a276 --- /dev/null +++ b/src/vendor/wf-json.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Wayfire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/vendor/wf-json.cpp b/src/vendor/wf-json.cpp new file mode 100644 index 0000000..f02d40e --- /dev/null +++ b/src/vendor/wf-json.cpp @@ -0,0 +1,563 @@ +#include +#include +#include +#include + +namespace wf +{ +bool json_reference_t::is_array() const +{ + return yyjson_mut_is_arr(v); +} + +json_reference_t json_reference_t::operator [](const size_t& idx) const +{ + assert(is_array()); + assert(size() > idx); + return json_reference_t{doc, yyjson_mut_arr_get(v, idx)}; +} + +void json_reference_t::append(const json_reference_t& elem) +{ + assert(is_array()); + yyjson_mut_arr_append(v, yyjson_mut_val_mut_copy(doc, elem.v)); +} + +void json_reference_t::append(int value) +{ + assert(is_array()); + yyjson_mut_arr_add_int(doc, v, value); +} + +void json_reference_t::append(unsigned int value) +{ + assert(is_array()); + yyjson_mut_arr_add_uint(doc, v, value); +} + +void json_reference_t::append(int64_t value) +{ + assert(is_array()); + yyjson_mut_arr_add_sint(doc, v, value); +} + +void json_reference_t::append(uint64_t value) +{ + assert(is_array()); + yyjson_mut_arr_add_uint(doc, v, value); +} + +void json_reference_t::append(double value) +{ + assert(is_array()); + yyjson_mut_arr_add_real(doc, v, value); +} + +void json_reference_t::append(bool value) +{ + assert(is_array()); + yyjson_mut_arr_add_bool(doc, v, value); +} + +void json_reference_t::append(const std::string_view& value) +{ + assert(is_array()); + yyjson_mut_arr_add_strncpy(doc, v, value.data(), value.size()); +} + +void json_reference_t::append(const char *value) +{ + assert(is_array()); + yyjson_mut_arr_add_strcpy(doc, v, value); +} + +size_t json_reference_t::size() const +{ + assert(is_array()); + return yyjson_mut_arr_size(v); +} + +bool json_reference_t::has_member(const std::string_view& key) const +{ + return is_object() && (yyjson_mut_obj_getn(v, key.data(), key.size()) != NULL); +} + +bool json_reference_t::is_object() const +{ + return yyjson_mut_is_obj(v); +} + +bool json_reference_t::is_null() const +{ + return yyjson_mut_is_null(v); +} + +json_reference_t json_reference_t::operator [](const char *key) const +{ + return this->operator [](std::string_view{key}); +} + +json_reference_t json_reference_t::operator [](const std::string_view& key) const +{ + assert((is_object() || is_null()) && "Trying to access into JSON value that is not an object!"); + if (is_null()) + { + yyjson_mut_set_obj(v); + } + + auto ptr = yyjson_mut_obj_getn(v, key.data(), key.size()); + if (ptr != NULL) + { + return json_reference_t{doc, ptr}; + } + + auto key_yy = yyjson_mut_strncpy(doc, key.data(), key.size()); + auto value = yyjson_mut_null(doc); + + yyjson_mut_obj_add(v, key_yy, value); + return json_reference_t{doc, value}; +} + +std::vector json_reference_t::get_member_names() const +{ + std::vector members; + yyjson_mut_obj_iter iter = yyjson_mut_obj_iter_with(v); + while (yyjson_mut_obj_iter_has_next(&iter)) + { + auto key = yyjson_mut_obj_iter_next(&iter); + members.push_back(yyjson_mut_get_str(key)); + } + + return members; +} + +static void copy_yyjson(yyjson_mut_doc *dst_doc, yyjson_mut_val *dst, yyjson_mut_val *src) +{ + if (yyjson_mut_is_null(src)) + { + yyjson_mut_set_null(dst); + return; + } + + if (yyjson_mut_is_bool(src)) + { + yyjson_mut_set_bool(dst, yyjson_mut_get_bool(src)); + return; + } + + if (yyjson_mut_is_sint(src)) + { + yyjson_mut_set_sint(dst, yyjson_mut_get_sint(src)); + return; + } + + if (yyjson_mut_is_uint(src)) + { + yyjson_mut_set_uint(dst, yyjson_mut_get_uint(src)); + return; + } + + if (yyjson_mut_is_real(src)) + { + yyjson_mut_set_real(dst, yyjson_mut_get_real(src)); + return; + } + + if (yyjson_mut_is_str(src)) + { + // copy ownership of string to dst_doc + auto val = yyjson_mut_val_mut_copy(dst_doc, src); + yyjson_mut_set_str(dst, yyjson_mut_get_str(val)); + return; + } + + if (yyjson_mut_is_arr(src)) + { + yyjson_mut_set_arr(dst); + yyjson_mut_arr_iter iter = yyjson_mut_arr_iter_with(src); + while (yyjson_mut_arr_iter_has_next(&iter)) + { + auto elem = yyjson_mut_arr_iter_next(&iter); + elem = yyjson_mut_val_mut_copy(dst_doc, elem); + yyjson_mut_arr_append(dst, elem); + } + + return; + } + + if (yyjson_mut_is_obj(src)) + { + yyjson_mut_set_obj(dst); + yyjson_mut_obj_iter iter = yyjson_mut_obj_iter_with(src); + while (yyjson_mut_obj_iter_has_next(&iter)) + { + auto key = yyjson_mut_obj_iter_next(&iter); + auto value = yyjson_mut_obj_iter_get_val(key); + // Copy ownership to our doc + key = yyjson_mut_val_mut_copy(dst_doc, key); + value = yyjson_mut_val_mut_copy(dst_doc, value); + yyjson_mut_obj_add(dst, key, value); + } + + return; + } + + assert(false && "Unsupported JSON type?"); +} + +json_reference_t& json_reference_t::operator =(const json_reference_t& other) +{ + copy_yyjson(doc, v, other.v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const json_reference_t&& other) +{ + // FIXME: maybe we can check whether the docs are the same or something to make this faster? + copy_yyjson(doc, v, other.v); + return *this; +} + +// --------------------------------------- Basic data types support ------------------------------------------ +json_reference_t& json_reference_t::operator =(const int& v) +{ + yyjson_mut_set_sint(this->v, v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const unsigned int& v) +{ + yyjson_mut_set_uint(this->v, v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const int64_t& v) +{ + yyjson_mut_set_sint(this->v, v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const uint64_t& v) +{ + yyjson_mut_set_uint(this->v, v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const bool& v) +{ + yyjson_mut_set_bool(this->v, v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const double& v) +{ + yyjson_mut_set_real(this->v, v); + return *this; +} + +json_reference_t& json_reference_t::operator =(const std::string_view& v) +{ + // copy ownership of string to doc + auto our_v = yyjson_mut_strncpy(doc, v.data(), v.size()); + yyjson_mut_set_str(this->v, yyjson_mut_get_str(our_v)); + return *this; +} + +json_reference_t& json_reference_t::operator =(const char *v) +{ + // copy ownership of string to doc + auto our_v = yyjson_mut_strcpy(doc, v); + yyjson_mut_set_str(this->v, yyjson_mut_get_str(our_v)); + return *this; +} + +bool json_reference_t::is_int() const +{ + if (yyjson_mut_is_sint(v)) + { + return (std::numeric_limits::min() <= yyjson_mut_get_sint(v)) && + (yyjson_mut_get_sint(v) <= std::numeric_limits::max()); + } + + if (yyjson_mut_is_uint(v)) + { + return (yyjson_mut_get_uint(v) <= std::numeric_limits::max()); + } + + return false; +} + +json_reference_t::operator int() const +{ + assert(is_int()); + return yyjson_mut_get_int(v); +} + +int json_reference_t::as_int() const +{ + return (int)(*this); +} + +bool json_reference_t::is_int64() const +{ + if (yyjson_mut_is_uint(v)) + { + return yyjson_mut_get_uint(v) <= std::numeric_limits::max(); + } + + return yyjson_mut_is_sint(v); +} + +json_reference_t::operator int64_t() const +{ + assert(is_int64()); + if (yyjson_mut_is_uint(v)) + { + return yyjson_mut_get_uint(v); + } else + { + return yyjson_mut_get_sint(v); + } +} + +int64_t json_reference_t::as_int64() const +{ + return (int64_t)(*this); +} + +bool json_reference_t::is_uint() const +{ + return yyjson_mut_is_uint(v) && + (yyjson_mut_get_uint(v) <= std::numeric_limits::max()); +} + +json_reference_t::operator unsigned int() const +{ + assert(is_uint()); + return yyjson_mut_get_uint(v); +} + +unsigned int json_reference_t::as_uint() const +{ + return (unsigned int)(*this); +} + +bool json_reference_t::is_uint64() const +{ + return yyjson_mut_is_uint(v); +} + +json_reference_t::operator uint64_t() const +{ + assert(is_uint64()); + return yyjson_mut_get_uint(v); +} + +uint64_t json_reference_t::as_uint64() const +{ + return (uint64_t)(*this); +} + +bool json_reference_t::is_bool() const +{ + return yyjson_mut_is_bool(v); +} + +json_reference_t::operator bool() const +{ + assert(is_bool()); + return yyjson_mut_get_bool(v); +} + +bool json_reference_t::as_bool() const +{ + return (bool)(*this); +} + +bool json_reference_t::is_double() const +{ + return yyjson_mut_is_num(v); +} + +json_reference_t::operator double() const +{ + assert(is_double()); + return yyjson_mut_get_num(v); +} + +double json_reference_t::as_double() const +{ + return (double)(*this); +} + +bool json_reference_t::is_string() const +{ + return yyjson_mut_is_str(v); +} + +json_reference_t::operator std::string() const { + assert(is_string()); + return std::string(yyjson_mut_get_str(v)); +} + +std::string json_reference_t::as_string() const +{ + return (std::string)*this; +} + +void json_t::init() +{ + this->doc = yyjson_mut_doc_new(NULL); + this->v = yyjson_mut_null(this->doc); + yyjson_mut_doc_set_root(doc, v); +} + +json_t::json_t() +{ + init(); +} + +json_t::~json_t() +{ + yyjson_mut_doc_free(doc); +} + +json_t::json_t(const json_reference_t& ref) : json_t() +{ + copy_yyjson(doc, this->v, ref.get_raw_value()); +} + +json_t::json_t(yyjson_mut_doc *doc) +{ + this->doc = doc; + this->v = yyjson_mut_doc_get_root(this->doc); +} + +json_t::json_t(const json_t& other) : json_reference_t() +{ + *this = other; +} + +json_t& json_t::operator =(const json_t& other) +{ + if (this != &other) + { + yyjson_mut_doc_free(doc); + init(); + copy_yyjson(doc, this->v, other.v); + } + + return *this; +} + +json_t::json_t(json_t&& other) +{ + *this = std::move(other); +} + +json_t& json_t::operator =(json_t&& other) +{ + if (this != &other) + { + yyjson_mut_doc_free(doc); + this->doc = other.doc; + this->v = other.v; + + other.doc = NULL; + other.v = NULL; + } + + return *this; +} + +json_t::json_t(int v) : json_t() +{ + *this = v; +} + +json_t::json_t(unsigned v) : json_t() +{ + *this = v; +} + +json_t::json_t(int64_t v) : json_t() +{ + *this = v; +} + +json_t::json_t(uint64_t v) : json_t() +{ + *this = v; +} + +json_t::json_t(double v) : json_t() +{ + *this = v; +} + +json_t::json_t(const std::string_view& v) : json_t() +{ + *this = v; +} + +json_t::json_t(const char *v) : json_t() +{ + *this = v; +} + +json_t::json_t(bool v) : json_t() +{ + *this = v; +} + +json_t json_t::array() +{ + json_t r; + yyjson_mut_set_arr(r.v); + return r; +} + +json_t json_t::null() +{ + json_t r; + yyjson_mut_set_null(r.v); + return r; +} + +std::optional json_t::parse_string(const std::string_view& source, + json_t& result) +{ + yyjson_read_err error; + auto doc = yyjson_read_opts((char*)source.data(), source.length(), 0, NULL, &error); + + if (!doc) + { + return std::string("Failed to parse JSON, error ") + + std::to_string(error.code) + ": " + error.msg + std::string("(at offset ") + + std::to_string(error.pos) + ")"; + } + + result = json_t{yyjson_doc_mut_copy(doc, NULL)}; + yyjson_doc_free(doc); + return std::nullopt; +} + +void json_t::map_serialized(std::function callback) const +{ + size_t len; + char *result = yyjson_mut_write(this->doc, 0, &len); + callback(result, len); + free(result); +} + +std::string json_t::serialize() const +{ + std::string result; + map_serialized([&] (const char *source, size_t length) + { + result.append(source, length); + }); + + return result; +} +} // namespace wf diff --git a/src/wcm.cpp b/src/wcm.cpp index a9ffa02..ce3f890 100644 --- a/src/wcm.cpp +++ b/src/wcm.cpp @@ -12,8 +12,6 @@ #include #include -#define OUTPUT_CONFIG_PROGRAM "wdisplays" - constexpr int OPTION_LABEL_SIZE = 200; bool KeyEntry::check_and_confirm(const std::string & key_str) @@ -1184,6 +1182,12 @@ void MainPage::Category::add_plugin(Plugin *plugin) MainPage::MainPage(const std::vector & plugins) : plugins(plugins) { add(vbox); + displays_button.set_image_from_icon_name("video-display", Gtk::ICON_SIZE_DND); + displays_button.set_always_show_image(true); + displays_button.set_tooltip_text("Virtual desktop, physical monitors, window zones and audio-only outputs"); + displays_button.signal_clicked().connect([] { WCM::get_instance()->open_displays(); }); + categories[0].flowbox.add(displays_button); + size_group->add_widget(displays_button); for (auto *plugin : plugins) { std::find_if(categories.begin(), categories.end() - 1, @@ -1223,6 +1227,9 @@ void MainPage::set_filter(const Glib::ustring & filter) category_visible[_(plug->category.c_str())] |= plug_visible; } + bool displays_visible = find_string("displays monitors outputs virtual zones audio", filter); + displays_button.get_parent()->set_visible(displays_visible); + category_visible[categories[0].name] |= displays_visible; categories[0].vbox.set_visible(category_visible[categories[0].name]); for (int i = 0; i < NUM_CATEGORIES - 1; ++i) { @@ -1547,19 +1554,9 @@ void WCM::create_main_layout() main_left_panel_layout.pack_end(close_button, false, false); output_config_button.property_margin().set_value(10); - output_config_button.signal_clicked().connect([] - { - Glib::spawn_command_line_async(OUTPUT_CONFIG_PROGRAM); - }); + output_config_button.signal_clicked().connect([this] { open_displays(); }); main_left_panel_layout.pack_end(output_config_button, false, false); - if (system("command -v " OUTPUT_CONFIG_PROGRAM " > /dev/null 2>&1") != 0) - { - output_config_button.set_sensitive(false); - output_config_button.set_tooltip_markup( - _("Cannot find program wdisplays")); - } - plugin_left_panel_layout.pack_start(plugin_name_label, false, false); plugin_name_label.set_line_wrap(); plugin_name_label.set_max_width_chars(15); @@ -1599,7 +1596,10 @@ void WCM::create_main_layout() global_layout.pack_start(left_stack, false, false); global_layout.pack_start(main_stack, true, true); window->add(global_layout); - if (!start_plugin.empty()) + if (start_plugin == "displays") + { + Glib::signal_idle().connect_once([this] { open_displays(); }); + } else if (!start_plugin.empty()) { Plugin *launch_plugin = find_plugin_by_name(plugins, start_plugin); std::cout << "Opening Plugin: " << start_plugin << std::endl; @@ -1609,6 +1609,12 @@ void WCM::create_main_layout() void WCM::open_page(Plugin *plugin) { + if (displays_page) + { + displays_scroll.remove(); + main_stack.remove(displays_scroll); + displays_page.reset(); + } if (plugin) { std::string gettext_domain_name = "wf-plugin-" + plugin->name; @@ -1635,6 +1641,26 @@ void WCM::open_page(Plugin *plugin) current_plugin = plugin; } +void WCM::open_displays() +{ + open_page(); + window->resize(1100, 880); + current_plugin = nullptr; + 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")); }); }); + displays_scroll.add(*displays_page); + displays_scroll.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); + main_stack.add(displays_scroll); + displays_scroll.show_all(); + main_stack.set_visible_child(displays_scroll); + plugin_name_label.set_text("Displays"); + plugin_description_label.set_text("Virtual desktop and physical monitors"); + plugin_enabled_box.hide(); + left_stack.set_visible_child(plugin_left_panel_layout); +} + static std::string wordexp_str(const char *str) { wordexp_t exp; diff --git a/src/wcm.hpp b/src/wcm.hpp index 2cff977..2acf16d 100644 --- a/src/wcm.hpp +++ b/src/wcm.hpp @@ -40,6 +40,7 @@ #include #include "metadata.hpp" +#include "displays.hpp" struct animate_option { @@ -73,6 +74,7 @@ class MainPage : public Gtk::ScrolledWindow }; const std::vector & plugins; + Gtk::Button displays_button{"Displays"}; Gtk::Box vbox = Gtk::Box(Gtk::ORIENTATION_VERTICAL, 10); Glib::RefPtr size_group = Gtk::SizeGroup::create( Gtk::SIZE_GROUP_BOTH); @@ -327,6 +329,8 @@ class WCM Gtk::Stack main_stack; /* for animated transition */ std::unique_ptr main_page; std::unique_ptr plugin_page; + std::unique_ptr displays_page; + Gtk::ScrolledWindow displays_scroll; Gtk::Stack left_stack; /* for animated transition */ @@ -366,6 +370,7 @@ class WCM } void open_page(Plugin *plugin = nullptr); + void open_displays(); void set_plugin_enabled(Plugin *plugin, bool enabled); std::string find_icon(const std::string & icon_name);