diff --git a/src/backgrounds.cpp b/src/backgrounds.cpp new file mode 100644 index 0000000..0c3c272 --- /dev/null +++ b/src/backgrounds.cpp @@ -0,0 +1,293 @@ +#include "backgrounds.hpp" +#include "ipc.hpp" +#include +#include +#include + +namespace +{ +using json = nlohmann::json; +json empty_slot() { return {{"image", ""}, {"sizing", "fill"}, {"color", {0,0,0,0}}, {"opacity", 1.0}}; } +json empty_layout() { return {{"mode", "transparent"}, {"image", empty_slot()}, {"monitors", json::object()}}; } +void clear(Gtk::Box& box) { for (auto child : box.get_children()) box.remove(*child); } +void label_style(Gtk::Label& label) +{ label.set_halign(Gtk::ALIGN_START); label.set_line_wrap(); label.set_max_width_chars(75); label.set_selectable(true); } +json backdrop_request(json request) +{ + GError *error = nullptr; + auto child = g_subprocess_new(GSubprocessFlags(G_SUBPROCESS_FLAGS_STDIN_PIPE | G_SUBPROCESS_FLAGS_STDOUT_PIPE | + G_SUBPROCESS_FLAGS_STDERR_PIPE), &error, "wf-appearance", "backgrounds-api", nullptr); + if (!child) + { + std::string message = error ? error->message : "Cannot start wf-appearance"; + g_clear_error(&error); throw std::runtime_error(message + ". Install the matching wf-appearance package."); + } + std::unique_ptr guard(child, g_object_unref); + gchar *out = nullptr, *err = nullptr; + bool ok = g_subprocess_communicate_utf8(child, request.dump().c_str(), nullptr, &out, &err, &error); + std::string bytes = out ? out : "", message = err ? err : ""; + g_free(out); g_free(err); + if (error) { message = error->message; g_clear_error(&error); } + if (!ok || !g_subprocess_get_successful(child)) throw std::runtime_error(message); + return json::parse(bytes); +} +} +BackgroundsPage::json BackgroundsPage::request(const std::string& method, const json& data) +{ + wf::json_t input; wf::json_t::parse_string(data.dump(), input); + return json::parse(wcm_ipc_request(method, input).serialize()); +} +BackgroundsPage::BackgroundsPage(const std::string& config) : Gtk::Box(Gtk::ORIENTATION_VERTICAL, 10), config_file(config) +{ + set_border_width(12); pack_start(tabs, true, true); + workspace.set_border_width(10); backdrop.set_border_width(10); + tabs.append_page(workspace, "Workspace images"); tabs.append_page(backdrop, "Fixed backdrop"); + help.set_text("Each workspace has its own image. Transparent areas reveal the other cube faces, including while the cube is at rest. The fixed backdrop stays behind the cube. Selecting a workspace here does not switch your desktop."); + label_style(help); label_style(status); label_style(backdrop_help); label_style(backdrop_status); + workspace.pack_start(help, false, false); + outputs.get_accessible()->set_name("Background desktop output"); + selection.get_accessible()->set_name("Background workspace"); mode.get_accessible()->set_name("Workspace background layout"); + workspace.pack_start(outputs, false, false); workspace.pack_start(selection, false, false); + mode.append("inherit", "Use workspace default"); mode.append("transparent", "Transparent"); + mode.append("image", "One image — span the selected output"); mode.append("monitors", "Separate physical monitor images"); + workspace.pack_start(mode, false, false); workspace.pack_start(editor, false, false); + buttons.pack_start(reload, false, false); buttons.pack_start(all, false, false); buttons.pack_end(apply, false, false); + workspace.pack_start(buttons, false, false); workspace.pack_start(status, false, false); + all.set_tooltip_text("Replace every workspace override on this output with the selected layout."); + backdrop_help.set_text("This image remains behind the cube. Use rotation keeps the existing Wallhaven wallpaper service in control. Workspace backgrounds have a separate Apply button."); + backdrop.pack_start(backdrop_help, false, false); + backdrop_mode.append("individual", "Separate physical monitor images"); backdrop_mode.append("combined", "One image spanning VIRTUAL"); + for (auto name : {"fill", "fit", "tile"}) backdrop_sizing.append(name, name); + backdrop_mode.get_accessible()->set_name("Fixed backdrop layout"); backdrop_sizing.get_accessible()->set_name("Fixed backdrop sizing"); + backdrop.pack_start(backdrop_mode, false, false); backdrop.pack_start(backdrop_sizing, false, false); + backdrop.pack_start(backdrop_editor, false, false); + backdrop_buttons.pack_start(backdrop_reload, false, false); backdrop_buttons.pack_end(backdrop_apply, false, false); + backdrop.pack_start(backdrop_buttons, false, false); backdrop.pack_start(backdrop_status, false, false); + outputs.signal_changed().connect([this] { if (!loading) select_output(); }); + selection.signal_changed().connect([this] { if (!loading) { selected_workspace = selection.get_active_id(); show_layout(); } }); + mode.signal_changed().connect([this] + { + if (loading || !available) return; + auto id = mode.get_active_id().raw(); + if (id == "inherit") + { + if (selected_workspace == "default") { show_layout(); return; } + auto profile = state["outputs"][outputs.get_active_row_number()]["profile"].get(); + if (draft["profiles"].contains(profile)) draft["profiles"][profile]["workspaces"].erase(selected_workspace); + changed(); + } else { auto layout = current_layout(); layout["mode"] = id; store_layout(layout); } + show_layout(); + }); + reload.signal_clicked().connect([this] { refresh(); }); + apply.signal_clicked().connect([this] { save(); }); + all.signal_clicked().connect([this] + { + if (!available) return; + Gtk::MessageDialog confirm("Replace all workspace overrides on this output with this layout?", false, + Gtk::MESSAGE_QUESTION, Gtk::BUTTONS_OK_CANCEL, true); + if (confirm.run() != Gtk::RESPONSE_OK) return; + auto layout = current_layout(); auto output = state["outputs"][outputs.get_active_row_number()]; + auto profile = output["profile"].get(); + auto& target = draft["profiles"][profile]; + if (target.is_null()) target = {{"default", empty_layout()}, {"workspaces", json::object()}}; + for (int y=0; y() == pending_job) + { + pending_job = 0; + if (next["job_success"].get()) refresh(); + else { changed(); status.set_text(next["error_message"].get()); } + } + } catch (const std::exception& e) { pending_job = 0; changed(); status.set_text(e.what()); } + } + if (fixed_worker.valid() && fixed_worker.wait_for(std::chrono::seconds(0)) == std::future_status::ready) + { + try + { + fixed_state = fixed_worker.get(); fixed_draft = fixed_state["settings"]; + show_backdrop(); backdrop_status.set_text(fixed_state["status"].get()); + } catch (const std::exception& e) { backdrop_status.set_text(e.what()); } + backdrop_reload.set_sensitive(true); backdrop_apply.set_sensitive(fixed_draft.is_object()); + backdrop_editor.set_sensitive(true); backdrop_mode.set_sensitive(true); backdrop_sizing.set_sensitive(true); + } + return true; + }, 200); +} +BackgroundsPage::~BackgroundsPage() { timer.disconnect(); if (fixed_worker.valid()) fixed_worker.wait(); } +void BackgroundsPage::refresh() +{ + try + { + auto next = request("workspace-backgrounds/get"); + if (next["version"] != 1) throw std::runtime_error("Unsupported workspace background API"); + if (std::filesystem::weakly_canonical(next["config_file"].get()) != std::filesystem::weakly_canonical(config_file)) + throw std::runtime_error("Open WCM with the running session's configuration to edit workspace backgrounds"); + state = next; draft = original = state["settings"]; available = true; + loading = true; outputs.remove_all(); + for (auto& output : state["outputs"]) outputs.append(output["name"].get(), output["name"].get()); + if (!outputs.set_active_id(selected_output) && !state["outputs"].empty()) outputs.set_active(0); + loading = false; select_output(); + auto error = state["error_message"].get(); + status.set_text(!error.empty() ? error : state["applied"].get() ? "Saved and loaded by the workspace renderer" : "Saved; images are awaiting the workspace renderer"); + } catch (const std::exception& e) + { + available = false; loading = false; + status.set_text(std::string(e.what()) + "\nWorkspace images need the workspace-backgrounds and ipc plugins with the matching Wayfire build."); + } + changed(); +} +void BackgroundsPage::select_output() +{ + selected_output = outputs.get_active_id(); + if (selected_output.empty()) { available = false; changed(); return; } + loading = true; selection.remove_all(); selection.append("default", "Workspace default (inherited by unset workspaces)"); + auto output = state["outputs"][outputs.get_active_row_number()]; + for (int y=0; y(); + if (!draft["profiles"].contains(profile)) return empty_layout(); + const auto& target = draft["profiles"][profile]; + if (selected_workspace != "default" && target["workspaces"].contains(selected_workspace)) return target["workspaces"][selected_workspace]; + return target["default"]; +} +void BackgroundsPage::store_layout(const json& layout) +{ + auto profile = state["outputs"][outputs.get_active_row_number()]["profile"].get(); + auto& target = draft["profiles"][profile]; + if (target.is_null()) target = {{"default", empty_layout()}, {"workspaces", json::object()}}; + if (selected_workspace == "default") target["default"] = layout; else target["workspaces"][selected_workspace] = layout; + changed(); +} +void BackgroundsPage::changed() +{ + outputs.set_sensitive(available && !pending_job); selection.set_sensitive(available && !pending_job); + mode.set_sensitive(available && !pending_job); editor.set_sensitive(available && !pending_job && mode.get_active_id() != "inherit"); + apply.set_sensitive(available && !pending_job && draft != original); all.set_sensitive(available && !pending_job); + reload.set_sensitive(!pending_job); + if (available && draft != original && !pending_job) status.set_text("Unapplied workspace background changes"); +} +void BackgroundsPage::show_layout() +{ + if (!available) return; + loading = true; clear(editor); auto layout = current_layout(); + auto output = state["outputs"][outputs.get_active_row_number()]; auto profile = output["profile"].get(); + bool inherit = selected_workspace != "default" && (!draft["profiles"].contains(profile) || !draft["profiles"][profile]["workspaces"].contains(selected_workspace)); + mode.set_active_id(inherit ? "inherit" : layout["mode"].get()); + if (layout["mode"] == "image") + image_row(editor, output["name"].get() + " — full workspace", layout["image"], [this] (json slot) + { auto layout = current_layout(); layout["image"] = slot; store_layout(layout); }); + if (layout["mode"] == "monitors") + for (auto& monitor : output["monitors"]) + { + auto name = monitor["name"].get(); + image_row(editor, name, layout["monitors"].value(name, empty_slot()), [this, name] (json slot) + { auto layout = current_layout(); layout["monitors"][name] = slot; store_layout(layout); }); + } + loading = false; editor.show_all(); changed(); +} +void BackgroundsPage::image_row(Gtk::Box& parent, const std::string& label, const json& value, + std::function change, bool rotation) +{ + auto box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL, 6)); parent.pack_start(*box, false, false); + auto heading = Gtk::manage(new Gtk::Label(label)); heading->set_halign(Gtk::ALIGN_START); box->pack_start(*heading, false, false); + auto row = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL, 6)); box->pack_start(*row, false, false); + auto entry = Gtk::manage(new Gtk::Entry()); entry->set_text(value["image"].get()); + entry->set_placeholder_text(rotation ? "Wallhaven rotation" : "Transparent (or choose a color below)"); + entry->get_accessible()->set_name(label + " image"); + auto browse = Gtk::manage(new Gtk::Button("Choose image…")); auto reset = Gtk::manage(new Gtk::Button(rotation ? "Use rotation" : "Clear image")); + row->pack_start(*entry, true, true); row->pack_start(*browse, false, false); row->pack_start(*reset, false, false); + auto slot = std::make_shared(value); + entry->signal_changed().connect([entry, slot, change] { (*slot)["image"] = entry->get_text().raw(); change(*slot); }); + reset->signal_clicked().connect([entry] { entry->set_text(""); }); + browse->signal_clicked().connect([entry] + { + Gtk::FileChooserDialog dialog("Choose background image", Gtk::FILE_CHOOSER_ACTION_OPEN); + dialog.add_button("Cancel", Gtk::RESPONSE_CANCEL); dialog.add_button("Choose", Gtk::RESPONSE_OK); + auto filter = Gtk::FileFilter::create(); filter->set_name("Images"); filter->add_pixbuf_formats(); dialog.add_filter(filter); + if (dialog.run() == Gtk::RESPONSE_OK) entry->set_text(dialog.get_filename()); + }); + if (rotation) return; + auto options = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL, 10)); box->pack_start(*options, false, false); + auto sizing = Gtk::manage(new Gtk::ComboBoxText()); + sizing->append("fill", "Fill — crop to cover"); sizing->append("fit", "Fit — show the whole image"); sizing->append("tile", "Tile — repeat original size"); + sizing->set_active_id(value["sizing"].get()); sizing->get_accessible()->set_name(label + " image sizing"); + auto color = Gtk::manage(new Gtk::ColorButton()); Gdk::RGBA rgba; + rgba.set_rgba(value["color"][0], value["color"][1], value["color"][2], value["color"][3]); + color->set_use_alpha(true); color->set_rgba(rgba); color->set_title("Image margins and transparent areas"); color->set_tooltip_text("Color underneath the image; alpha 0 leaves transparent areas"); + color->get_accessible()->set_name(label + " background color"); + auto opacity = Gtk::manage(new Gtk::SpinButton()); opacity->set_range(0,100); opacity->set_increments(1,10); + opacity->set_value(value["opacity"].get() * 100); opacity->get_accessible()->set_name(label + " opacity percent"); + auto caption = Gtk::manage(new Gtk::Label("Opacity %")); + options->pack_start(*sizing, true, true); options->pack_start(*color, false, false); options->pack_start(*caption, false, false); options->pack_start(*opacity, false, false); + sizing->signal_changed().connect([sizing,slot,change] { (*slot)["sizing"] = sizing->get_active_id().raw(); change(*slot); }); + color->signal_color_set().connect([color,slot,change] { auto c=color->get_rgba(); (*slot)["color"]={c.get_red(),c.get_green(),c.get_blue(),c.get_alpha()}; change(*slot); }); + opacity->signal_value_changed().connect([opacity,slot,change] { (*slot)["opacity"] = opacity->get_value()/100; change(*slot); }); +} +void BackgroundsPage::save() +{ + try + { + auto response = request("workspace-backgrounds/apply", {{"revision",state["revision"]}, {"topology",state["topology"]}, {"settings",draft}}); + pending_job = response["job"]; status.set_text("Preparing workspace background images…"); changed(); + } catch (const std::exception& e) { status.set_text(e.what()); } +} +void BackgroundsPage::fixed_request(bool save) +{ + if (fixed_worker.valid()) return; + json input = {{"method", save ? "apply" : "get"}}; + if (save) { input["revision"] = fixed_state["revision"]; input["settings"] = fixed_draft; } + fixed_worker = std::async(std::launch::async, backdrop_request, input); + backdrop_status.set_text(save ? "Validating and saving fixed backdrop…" : "Loading fixed backdrop…"); + backdrop_apply.set_sensitive(false); backdrop_reload.set_sensitive(false); backdrop_editor.set_sensitive(false); + backdrop_mode.set_sensitive(false); backdrop_sizing.set_sensitive(false); +} +void BackgroundsPage::show_backdrop() +{ + if (!fixed_draft.is_object()) return; + loading = true; clear(backdrop_editor); + backdrop_mode.set_active_id(fixed_draft["mode"].get()); backdrop_sizing.set_active_id(fixed_draft["sizing"].get()); + auto add = [this] (const std::string& name, const std::string& path) + { + auto slot = empty_slot(); slot["image"] = path; + image_row(backdrop_editor, name, slot, [this,name] (json slot) + { + if (name == "VIRTUAL") fixed_draft["combined_image"] = slot["image"]; + else fixed_draft["monitor_images"][name] = slot["image"]; + backdrop_status.set_text("Unapplied backdrop changes"); + }, true); + }; + if (fixed_draft["mode"] == "combined") add("VIRTUAL", fixed_draft["combined_image"]); + std::set names, combined; + for (auto& [name, value] : fixed_draft["monitor_images"].items()) names.insert(name); + for (auto& output : fixed_state.value("outputs", json::array())) + { + if (output.contains("members") && !output["members"].empty()) + for (auto& member : output["members"]) { names.insert(member["name"]); combined.insert(member["name"]); } + else names.insert(output["name"]); + } + for (auto& name : names) + if (fixed_draft["mode"] != "combined" || !combined.count(name)) add(name, fixed_draft["monitor_images"].value(name, "")); + loading = false; backdrop_editor.show_all(); +} diff --git a/src/backgrounds.hpp b/src/backgrounds.hpp new file mode 100644 index 0000000..0b5d906 --- /dev/null +++ b/src/backgrounds.hpp @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include + +class BackgroundsPage : public Gtk::Box +{ + using json = nlohmann::json; + Gtk::Notebook tabs; + Gtk::Box workspace{Gtk::ORIENTATION_VERTICAL, 10}, backdrop{Gtk::ORIENTATION_VERTICAL, 10}; + Gtk::Box editor{Gtk::ORIENTATION_VERTICAL, 8}, backdrop_editor{Gtk::ORIENTATION_VERTICAL, 8}; + Gtk::Box buttons{Gtk::ORIENTATION_HORIZONTAL, 8}, backdrop_buttons{Gtk::ORIENTATION_HORIZONTAL, 8}; + Gtk::Label help, status, backdrop_help, backdrop_status; + Gtk::ComboBoxText outputs, selection, mode, backdrop_mode, backdrop_sizing; + Gtk::Button apply{"Apply workspace backgrounds"}, reload{"Revert workspace changes"}, all{"Copy to all workspaces"}; + Gtk::Button backdrop_apply{"Apply fixed backdrop"}, backdrop_reload{"Revert backdrop changes"}; + json state, draft, original, fixed_state, fixed_draft; + std::string config_file, selected_output, selected_workspace = "default"; + bool loading = false, available = false; + uint64_t pending_job = 0; + std::future fixed_worker; + sigc::connection timer; + static json request(const std::string& method, const json& data = json::object()); + void refresh(); + void select_output(); + void show_layout(); + json current_layout() const; + void store_layout(const json& layout); + void changed(); + void save(); + void fixed_request(bool save); + void show_backdrop(); + void image_row(Gtk::Box& parent, const std::string& label, const json& value, + std::function change, bool rotation = false); + public: + explicit BackgroundsPage(const std::string& config); + ~BackgroundsPage() override; +}; diff --git a/src/displays.cpp b/src/displays.cpp index 7a40bcd..71a7829 100644 --- a/src/displays.cpp +++ b/src/displays.cpp @@ -1,4 +1,5 @@ #include "displays.hpp" +#include "backgrounds.hpp" #include "ipc.hpp" #include #include @@ -61,6 +62,12 @@ DisplaysPage::DisplaysPage(const std::string& config, std::function on_s settings_tabs.append_page(left, "Desktop and window zones"); settings_tabs.append_page(right, "Physical monitor"); settings_tabs.append_page(scope_tab, "Application scope"); + settings_tabs.append_page(*Gtk::manage(new BackgroundsPage(config)), "Backgrounds"); + settings_tabs.signal_switch_page().connect([this] (Gtk::Widget*, guint page) + { + toolbar.set_visible(page != 3); actions.set_visible(page != 3); status.set_visible(page != 3); + diagram.set_visible(page != 3 && available); + }); 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); @@ -194,7 +201,8 @@ void DisplaysPage::refresh(bool discard) 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); identify_button.set_sensitive(false); + left.set_sensitive(false); right.set_sensitive(false); scope_tab.set_sensitive(false); + apply_button.set_sensitive(false); identify_button.set_sensitive(false); status.set_text("A display preview is awaiting confirmation in another window."); return; } bool recovering = !available; @@ -353,9 +361,10 @@ void DisplaysPage::changed() void DisplaysPage::update_controls() { bool preview = !transaction.empty(); - settings_state.set_visible_child(available ? "settings" : "unavailable"); - diagram.set_visible(available); - body.set_sensitive(available && !preview); refresh_button.set_sensitive(!preview); + settings_state.set_visible_child("settings"); + diagram.set_visible(available && settings_tabs.get_current_page() != 3); + left.set_sensitive(available && !preview); right.set_sensitive(available && !preview); + scope_tab.set_sensitive(available && !preview); refresh_button.set_sensitive(!preview); identify_button.set_sensitive(available && !heads.empty() && !preview); keep_button.set_visible(preview); revert_button.set_visible(preview); apply_button.set_visible(!preview); apply_button.set_sensitive(available && dirty && !preview); bool desktop = role.get_active_id() == "desktop"; diff --git a/src/displays.hpp b/src/displays.hpp index d3c2d36..dc5664b 100644 --- a/src/displays.hpp +++ b/src/displays.hpp @@ -72,4 +72,5 @@ class DisplaysPage : public Gtk::Box DisplaysPage(const std::string& config, std::function saved, std::function shell, std::function zones); ~DisplaysPage() override; + void show_backgrounds() { settings_tabs.set_current_page(3); } }; diff --git a/src/meson.build b/src/meson.build index 8a0d979..9eebcf2 100644 --- a/src/meson.build +++ b/src/meson.build @@ -7,9 +7,10 @@ libfmt = dependency('fmt', required: true) 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] +dep_list = [xml, gtkmm, wf_config, wf_protos, evdev, xkbregistry, libintl, libfmt, wf_json, layer_shell, + dependency('nlohmann_json'), dependency('threads')] -sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'displays.cpp', 'move-snap.cpp', 'vendor/wf-json.cpp') +sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'displays.cpp', 'backgrounds.cpp', 'move-snap.cpp', 'vendor/wf-json.cpp') executable(meson.project_name(), sources, install : true, diff --git a/src/wcm.cpp b/src/wcm.cpp index 31a7f1d..f1ba719 100644 --- a/src/wcm.cpp +++ b/src/wcm.cpp @@ -1591,9 +1591,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 == "displays") + if (start_plugin == "displays" || start_plugin == "backgrounds") { - Glib::signal_idle().connect_once([this] { open_displays(); }); + Glib::signal_idle().connect_once([this] + { open_displays(); if (start_plugin == "backgrounds") displays_page->show_backgrounds(); }); } else if (!start_plugin.empty()) { Plugin *launch_plugin = find_plugin_by_name(plugins, start_plugin);