--- /dev/null +++ b/metadata/ambient-background.xml @@ -0,0 +1,78 @@ + + + + <_short>Ambient Background + <_long>Continuous Hyperspace or Digital Matrix, with independent desktop fading on each physical monitor. + Desktop + + <_short>Ambient background + + + + + + + + + + + + + + --- /dev/null +++ b/plugins/ambient-background/ambient-background.cpp @@ -0,0 +1,452 @@ +#include "../cube/ambient-api.hpp" +#include "../cube/hyperspace-background.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include +#include +} + +namespace { +using clock_t = std::chrono::steady_clock; +double now() { return std::chrono::duration(clock_t::now().time_since_epoch()).count(); } +struct zone_t { + std::string name; + wf::geometry_t box; + float opacity = 1, target = 1, black = 0, effect = 1; +}; +class ambient_fade_t : public wf::scene::view_2d_transformer_t { + public: + using view_2d_transformer_t::view_2d_transformer_t; + std::vector> regions; + class instance_t : public wf::scene::transformer_render_instance_t { + public: + using transformer_render_instance_t::transformer_render_instance_t; + void render(const wf::scene::render_instruction_t& data) override { + auto normal = data.damage; + auto texture = get_texture(data.target.scale); + if (!texture) return; + auto box = self->get_bounding_box(); + for (const auto& [region, alpha] : self->regions) { + auto part = normal & region; + normal ^= part; + if (!part.empty() && alpha > 0) + data.pass->add_texture(texture, data.target, box, part, alpha); + } + if (!normal.empty()) data.pass->add_texture(texture, data.target, box, normal); + } + }; + void gen_render_instances(std::vector& list, + wf::scene::damage_callback damage, wf::output_t *output) override { + list.push_back(std::make_unique(this, damage, output)); + } +}; +class ambient_node_t : public wf::scene::node_t { + wf::output_t *output; + std::function paint; + public: + ambient_node_t(wf::output_t *o, decltype(paint) p) : node_t(false), output(o), paint(p) {} + wf::geometry_t get_bounding_box() override { return output->get_layout_geometry(); } + class instance_t : public wf::scene::render_instance_t { + ambient_node_t *self; + public: + explicit instance_t(ambient_node_t *s) : self(s) {} + void schedule_instructions(std::vector& list, + const wf::render_target_t& target, wf::regionf_t& damage) override { + auto clipped = damage & self->get_bounding_box(); + if (clipped.empty()) return; + list.push_back({.instance=this, .target=target, .damage=clipped}); + damage ^= clipped; + } + void render(const wf::scene::render_instruction_t& data) override { + auto box = self->get_bounding_box(); + auto damage = data.damage; + damage += wf::pointf_t{-double(box.x), -double(box.y)}; + auto target = data.target.translated({-box.x, -box.y}); + data.pass->custom_gles_subpass([&] { self->paint(target, damage); }); + } + }; + void gen_render_instances(std::vector& list, + wf::scene::damage_callback, wf::output_t*) override { + list.push_back(std::make_unique(this)); + } +}; +class shield_t : public wf::scene::node_t { + std::function()> regions; + public: + explicit shield_t(decltype(regions) fn) : node_t(false), regions(fn) {} + std::optional find_node_at(const wf::pointf_t& point) override { + for (auto b : regions()) + if (point.x >= b.x && point.y >= b.y && point.x < b.x+b.width && point.y < b.y+b.height) + return wf::scene::input_node_t{this, point}; + return {}; + } +}; +class ambient_plugin_t : public wf::plugin_interface_t { + wf::option_wrapper_t enabled, black_enabled; + wf::option_wrapper_t backend, controller; + wf::option_wrapper_t focused_delay, inactive_delay, fade_seconds, wake_seconds, + min_opacity, manual_seconds; + wf::option_wrapper_t toggle_binding, restore_binding; + wf::shared_data::ref_ptr_t repo; + struct output_state_t { + std::vector zones; + std::unique_ptr hyperspace; + wf::scene::node_ptr node; + }; + std::map outputs; + std::map> fades; + wf::scene::node_ptr shield; + wf::wl_timer timer; + bool manual = false, daemon_started = false, cursor_hidden = false; + unsigned reset_generation = 0; + double last_tick = now(), heartbeat = 0, restore_until = 0, last_launch = 0; + std::weak_ptr previous_focus; + + template void option(wf::option_wrapper_t& wrapper, const char *name, T value) { + auto section = wf::get_core().config->get_section("ambient-background"); + if (!section) { + section = std::make_shared("ambient-background"); + wf::get_core().config->merge_section(section); + } + auto old = section->get_option_or(name); + if (!std::dynamic_pointer_cast>(old)) { + auto typed = std::make_shared>(name, value); + if (old) typed->set_value_str(old->get_value_str()); + section->register_new_option(typed); + } + wrapper.load_option(std::string("ambient-background/") + name); + } + static void insert_before(wf::scene::node_ptr node, wf::scene::layer layer) { + auto root = wf::get_core().scene(); + auto children = root->get_children(); + auto anchor = root->layers[(int)layer]; + auto it = std::find(children.begin(), children.end(), anchor); + children.insert(it, node); + root->set_children_list(children); + } + bool locked() const { + for (auto& [o, s] : outputs) if (o->is_inhibited()) return true; + return false; + } + static std::vector targets(wf::output_t *o) { + if (auto stitch = o->get_data()) + if (!stitch->zones.empty()) return stitch->zones; + return {{o->to_string(), o->get_relative_geometry()}}; + } + void update_zones(wf::output_t *o, output_state_t& state) { + auto old = state.zones; + state.zones.clear(); + for (auto z : targets(o)) { + zone_t zone{z.name, z.geometry}; + for (auto& p : old) if (p.name == zone.name) { + zone.opacity=p.opacity; zone.target=p.target; zone.black=p.black; zone.effect=p.effect; + } + state.zones.push_back(zone); + } + } + void add_output(wf::output_t *o) { + if (outputs.count(o)) return; + auto& state = outputs[o]; + update_zones(o, state); + state.hyperspace = std::make_unique([o] { o->render->damage_whole(); }); + state.hyperspace->set_active(enabled && std::string(backend) == "hyperspace"); + o->add_activator(toggle_binding, &toggle); + o->add_activator(restore_binding, &restore); + if (enabled) attach(o, state); + } + void attach(wf::output_t *o, output_state_t& state) { + if (state.node) return; + state.node = std::make_shared(o, [this,o] (auto& t,auto& d) { paint(o,t,d); }); + insert_before(state.node, wf::scene::layer::BOTTOM); + o->render->damage_whole(); + } + static bool belongs(wayfire_view view, wf::output_t *output, bool wallpaper = false) { + if (!view->is_mapped()) return false; + using layer = wf::scene::layer; + auto layers = wallpaper ? std::vector{layer::BACKGROUND} : + std::vector{layer::BOTTOM,layer::WORKSPACE,layer::TOP,layer::UNMANAGED,layer::OVERLAY,layer::DWIDGET}; + for (wf::scene::node_t *n=view->get_root_node().get(); n; n=n->parent()) + for (auto l:layers) if (n == output->node_for_layer(l).get()) return true; + return false; + } + void clear_fades() { + for (auto& [v,f]:fades) + if (auto alive=f->view.lock()) alive->get_transformed_node()->rem_transformer(f); + fades.clear(); + } + void update_fades() { + if (!enabled) { clear_fades(); return; } + std::map>> desired; + for (auto view:wf::get_core().get_all_views()) { + auto output=view->get_output(); + if (!outputs.count(output) || !belongs(view,output)) continue; + auto box=view->get_bounding_box(), screen=output->get_relative_geometry(); + for (auto& z:outputs.at(output).zones) { + if (z.opacity >= .999f) continue; + // Workspace faces keep their monitor's alpha, including off-screen rows. + int xmin=std::floor(double(box.x-z.box.x-z.box.width)/screen.width)+1; + int xmax=std::floor(double(box.x+box.width-1-z.box.x)/screen.width); + int ymin=std::floor(double(box.y-z.box.y-z.box.height)/screen.height)+1; + int ymax=std::floor(double(box.y+box.height-1-z.box.y)/screen.height); + for (int x=xmin;x<=xmax && xfirst)) { + if (auto alive=it->second->view.lock()) alive->get_transformed_node()->rem_transformer(it->second); + it=fades.erase(it); + } else ++it; + } + for (auto& [view,regions]:desired) { + auto& fade=fades[view]; + if (!fade) { + fade=std::make_shared(view); + view->get_transformed_node()->add_transformer(fade,wf::TRANSFORMER_BLUR+2,"ambient-background-fade"); + } + fade->regions=std::move(regions); + wf::scene::damage_node(fade,fade->get_bounding_box()); + } + } + void update_shield() { + auto cursor=wf::get_core().get_cursor_position(); + bool hide=manual; + for (auto& [o,s]:outputs) { + auto layout=o->get_layout_geometry(); + for (auto& z:s.zones) { + auto b=z.box;b.x+=layout.x;b.y+=layout.y; + if ((b & cursor) && z.opacity<.02f) hide=true; + } + } + hide &= bool(enabled) && !locked(); + if (hide != cursor_hidden) { + if (hide) wf::get_core().hide_cursor(); else wf::get_core().unhide_cursor(); + cursor_hidden=hide; + } + bool needed = manual; + for (auto& [o,s]:outputs) for (auto& z:s.zones) needed |= z.opacity < .02f; + needed &= bool(enabled) && !locked(); + if (needed && !shield) { + shield=std::make_shared([this] { + std::vector result; + for (auto& [o,s]:outputs) { + auto layout=o->get_layout_geometry(); + for (auto& z:s.zones) if (manual || z.opacity < .02f) { + auto b=z.box; b.x+=layout.x; b.y+=layout.y; result.push_back(b); + } + } + return result; + }); + insert_before(shield,wf::scene::layer::SCREENSAVER); + } else if (!needed && shield) { + wf::scene::remove_child(shield); shield.reset(); + } + if (shield) wf::scene::update(shield,wf::scene::update_flag::INPUT_STATE); + } + void set_manual(bool state) { + if (state == manual) return; + if (state && (!enabled || locked())) return; + manual=state; + if (state) { if (auto v=wf::get_core().seat->get_active_view()) previous_focus=v->weak_from_this(); } + else { + ++reset_generation; restore_until=now()+1; + for (auto& [o,s]:outputs) for (auto& z:s.zones) { z.target=1; z.black=0; z.effect=1; } + if (auto view=previous_focus.lock()) if (view->is_mapped()) wf::get_core().seat->focus_view(view->self()); + previous_focus.reset(); + } + update_shield(); + } + wf::activator_callback toggle = [this] (auto) { if (!enabled || locked()) return false; set_manual(!manual); return true; }; + wf::activator_callback restore = [this] (auto) { if (!manual) return false; set_manual(false); return true; }; + wf::signal::connection_t> on_key = [this] (auto ev) { + if (manual && !locked()) ev->mode=wf::input_event_processing_mode_t::NO_CLIENT; + }; + void paint(wf::output_t *o,const wf::render_target_t& target,const wf::regionf_t& damage) { + auto it=outputs.find(o); if (it==outputs.end() || !enabled) return; + auto& state=it->second; + GL_CALL(glDisable(GL_DEPTH_TEST)); GL_CALL(glDisable(GL_CULL_FACE)); + wf::gles::for_each_scissor_rect(target,damage,[&] { + OpenGL::render_rectangle(o->get_relative_geometry(),{0,0,0,1},wf::gles::render_target_orthographic_projection(target)); + }); + if (std::string(backend) == "hyperspace") + state.hyperspace->render(target,damage,wf::dimensions(o->get_relative_geometry())); + else { + // Import only background layer clients: wallpaper and Matrix's own GPU buffers. + for (bool matrix:{false,true}) { + if (matrix) for (auto& z:state.zones) { + float black=manual ? 0 : z.black; + if (black>0) wf::gles::for_each_scissor_rect(target,damage & z.box,[&] { + OpenGL::render_rectangle(z.box,{0,0,0,black},wf::gles::render_target_orthographic_projection(target)); + }); + } + for (auto view:wf::get_core().get_all_views()) { + if (!belongs(view,o,true) || !view->get_wlr_surface()) continue; + bool ambient=view->get_app_id() == "ambient-matrix"; + if (ambient != matrix) continue; + auto texture=wlr_surface_get_texture(view->get_wlr_surface()); + if (!texture) continue; + auto box=view->get_bounding_box(); + wf::gles::for_each_scissor_rect(target,damage & box,[&] { + OpenGL::render_texture(wf::gles_texture_t(texture),target,box); + }); + } + } + } + for (auto& z:state.zones) { + auto d=damage & z.box; + float alpha=manual ? 0 : 1-z.effect; + if (alpha > 0) wf::gles::for_each_scissor_rect(target,d,[&] { + OpenGL::render_rectangle(z.box,{0,0,0,alpha},wf::gles::render_target_orthographic_projection(target)); + }); + } + GL_CALL(glDisable(GL_BLEND)); + } + bool tick() { + double t=now(), dt=std::clamp(t-last_tick,0.0,.1); last_tick=t; + if (locked() && manual) set_manual(false); + bool stale=(heartbeat ? t-heartbeat>3 : daemon_started && t-last_launch>3); + if (stale && manual) set_manual(false); + for (auto& [o,s]:outputs) { + update_zones(o,s); + for (auto& z:s.zones) { + float target = manual ? 0 : (stale || trender->damage_whole(); + } + update_fades(); update_shield(); + if (enabled && stale && !std::string(controller).empty() && t-last_launch>10) { + last_launch=t;wf::get_core().run(std::string(controller)); + } + return true; + } + wf::json_t status() { + wf::json_t j=wf::ipc::json_ok(); + j["enabled"]=bool(enabled);j["backend"]=std::string(backend);j["manual"]=manual; + j["reset_generation"]=int(reset_generation);j["black_enabled"]=bool(black_enabled); + j["focused_delay"]=double(focused_delay);j["inactive_delay"]=double(inactive_delay); + j["fade_seconds"]=double(fade_seconds);j["wake_seconds"]=double(wake_seconds); + j["min_opacity"]=double(min_opacity);j["manual_seconds"]=double(manual_seconds); + j["outputs"]=wf::json_t::array(); + for (auto& [o,s]:outputs) for (auto& z:s.zones) { + wf::json_t item;item["name"]=z.name;item["desktop"]=o->to_string(); + item["opacity"]=double(z.opacity);item["target"]=double(z.target); + j["outputs"].append(item); + } + return j; + } + void changed() { + if (!enabled) { set_manual(false);clear_fades(); } + for (auto& [o,s]:outputs) { + s.hyperspace->set_active(enabled && std::string(backend)=="hyperspace"); + if (enabled) attach(o,s); + else if (s.node) { wf::scene::remove_child(s.node);s.node.reset(); } + o->render->damage_whole(); + } + update_shield(); + wf::ambient_changed_signal signal;wf::get_core().emit(&signal); + if (enabled && !daemon_started && !std::string(controller).empty()) { + daemon_started=true;last_launch=now();wf::get_core().run(std::string(controller)); + } + } + wf::signal::connection_t query = [this] (auto ev) { + ev->enabled=enabled && outputs.count(ev->output); + }; + wf::signal::connection_t render = [this] (auto ev) { + if (enabled && outputs.count(ev->output)) { paint(ev->output,*ev->target,*ev->damage);ev->handled=true; } + }; + wf::signal::connection_t transition = [this] (auto ev) { + if (outputs.count(ev->output)) outputs.at(ev->output).hyperspace->set_transition(ev->moving); + }; + wf::signal::connection_t added = [this] (auto ev) { add_output(ev->output); }; + wf::signal::connection_t removed = [this] (auto ev) { + clear_fades(); + if (outputs.count(ev->output)) { + ev->output->rem_binding(&toggle);ev->output->rem_binding(&restore); + auto& s=outputs.at(ev->output);if(s.node)wf::scene::remove_child(s.node); + outputs.erase(ev->output); + } + }; + public: + void init() override { + option(enabled,"enabled",false);option(black_enabled,"fade_to_black",true); + option(backend,"backend",std::string("hyperspace")); + option(controller,"controller",std::string("ambient-background --daemon")); + option(focused_delay,"focused_delay",240.0);option(inactive_delay,"inactive_delay",120.0); + option(fade_seconds,"fade_duration",20.0);option(wake_seconds,"restore_duration",.25); + option(min_opacity,"minimum_opacity",0.0);option(manual_seconds,"manual_duration",.7); + option(toggle_binding,"toggle",wf::activatorbinding_t{}); + option(restore_binding,"restore",wf::option_type::from_string("KEY_ESC").value()); + for (auto o:wf::get_core().output_layout->get_outputs()) add_output(o); + wf::get_core().output_layout->connect(&added);wf::get_core().output_layout->connect(&removed); + wf::get_core().connect(&query);wf::get_core().connect(&render);wf::get_core().connect(&transition); + wf::get_core().connect(&on_key); + enabled.set_callback([this]{changed();});backend.set_callback([this]{changed();}); + repo->register_method("ambient/status",[this](wf::json_t){return status();}); + repo->register_method("ambient/toggle",[this](wf::json_t){set_manual(!manual);return status();}); + repo->register_method("ambient/restore",[this](wf::json_t){set_manual(false);return status();}); + repo->register_method("ambient/sync",[this](wf::json_t data){ + if (!data.has_member("outputs") || !data["outputs"].is_array())return wf::ipc::json_error("outputs array required"); + for (size_t i=0;i1) + return wf::ipc::json_error("opacity, black and effect must be in [0,1]"); + } + } + for (size_t i=0;irem_binding(&toggle);o->rem_binding(&restore); + if(s.node)wf::scene::remove_child(s.node); + o->render->damage_whole(); + } + outputs.clear(); + for(auto m:{"ambient/status","ambient/toggle","ambient/restore","ambient/sync"})repo->unregister_method(m); + wf::ambient_changed_signal signal;wf::get_core().emit(&signal); + } +}; +} +DECLARE_WAYFIRE_PLUGIN(ambient_plugin_t); --- /dev/null +++ b/plugins/ambient-background/ambient-background.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Per-physical-monitor ambient controller; no credentials or desktop capture. + +The compositor owns rendering, bindings and reversible alpha transforms. This +ordinary-user process reads Digital Rain's proven activity/inhibition clocks. +A connection-bound ownership lease leaves those clocks running and restores +legacy saver behavior automatically if this process disappears. +""" +import argparse +import fcntl +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import time +import tomllib + +MAX_MESSAGE = 1024 * 1024 +STOP = False + + +def peer(stream): + pid, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + if uid != os.geteuid(): + raise PermissionError('Ambient IPC requires the same desktop user') + return pid + + +def connect(path, kind=socket.SOCK_STREAM): + stream = socket.socket(socket.AF_UNIX, kind) + stream.settimeout(2) + try: + stream.connect(str(path)) + peer(stream) + return stream + except BaseException: + stream.close() + raise + + +def read(stream, count): + result = bytearray() + while len(result) < count: + part = stream.recv(count - len(result)) + if not part: + raise ConnectionError('Ambient IPC disconnected') + result.extend(part) + return result + + +def ipc(method, data=None): + with connect(os.environ['WAYFIRE_SOCKET']) as stream: + payload = json.dumps({'method': method, 'data': data or {}}).encode() + stream.sendall(struct.pack('=I', len(payload)) + payload) + count, = struct.unpack('=I', read(stream, 4)) + if not 0 < count <= MAX_MESSAGE: + raise ValueError('Oversized compositor response') + result = json.loads(read(stream, count)) + if isinstance(result, dict) and result.get('error'): + raise RuntimeError(result['error']) + return result + + +def dr_request(stream, method, **data): + stream.send(json.dumps({'version': 1, 'method': method, **data}).encode()) + payload, _, flags, _ = stream.recvmsg(MAX_MESSAGE) + if flags & socket.MSG_TRUNC or not payload: + raise ConnectionError('Invalid Digital Rain response') + result = json.loads(payload) + if result.get('error'): + raise RuntimeError(result['error']) + return result + + +def clamp(value): + return min(1., max(0., value)) + + +def phase(options, output, config): + """Pure timeline: UI fades, backdrop blackens, then the animation fades. + + Legacy black delays begin at idle activation, never at daemon startup. + Zero delays retain DR's meaning of a disabled stage. + """ + if output.get('application_inhibited') or output.get('timed_inhibited'): + return {'opacity': 1., 'black': 0., 'effect': 1.} + delay = options['focused_delay'] if output['focused'] else options['inactive_delay'] + override = config.get('output_timeouts', {}).get(output['name'], 0) + if not output['focused'] and override > 0: + delay = override + age = max(0., output['idle_seconds'] - delay) + fade = max(.05, options['fade_seconds']) + alpha = 1. - clamp(age / fade) * (1. - clamp(options['min_opacity'])) + matrix = config.get('matrix', {}) + black = 0. if matrix.get('TRANSPARENT', True) else 1. + effect = 1. + if options['black_enabled'] and output['idle_seconds'] >= delay: + seconds = max(1, matrix.get('FADE_FRAMES', 20)) / 30. + black_delay = matrix.get('TRANSPARENT_TIMEOUT', 600) + if black_delay > 0: + black = clamp((age - black_delay) / seconds) + rain_delay = matrix.get('BLACK_RAIN_TIMEOUT', 600) + black_start = black_delay + seconds if matrix.get('TRANSPARENT', True) else 0 + if rain_delay > 0 and (not matrix.get('TRANSPARENT', True) or black_delay > 0): + effect = 1. - clamp((age - black_start - rain_delay) / seconds) + return {'opacity': alpha, 'black': black, 'effect': effect} + + +class Matrix: + def __init__(self, name, config, binary): + m = config.get('matrix', {}) + args = [binary, '--render', '--ambient', '--output', name, '--transparent', + '--transparent-timeout', '0', '--black-rain-timeout', '0', '--fade-frames', '1'] + for key in ('FONT_SIZE', 'DEPTH_LAYERS', 'MIN_SPEED', 'MAX_SPEED', 'AVG_SPEED', + 'MIN_TRAIL', 'MAX_TRAIL', 'MIN_DENSITY', 'MAX_DENSITY', 'HEAD_HOLD_FRAMES', + 'HEAD_MUTATION_CHANCE', 'HEAD_WHITE_MIN', 'HEAD_WHITE_MAX', + 'LAYER_NEAR_DENSITY', 'LAYER_FAR_DENSITY'): + if key in m: + args += ['--' + key.lower().replace('_', '-'), str(m[key])] + args += ['--color', config.get('output_colors', {}).get(name, m.get('COLOR', 'green'))] + self.channel, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET) + self.channel.setblocking(False) + args += ['--report-fd', str(child.fileno())] + try: + self.process = subprocess.Popen(args, pass_fds=(child.fileno(),), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except BaseException: + self.channel.close() + raise + finally: + child.close() + + def alive(self): + # Drain bounded renderer reports so their channel never blocks rendering. + for _ in range(8): + try: + if not self.channel.recv(8192): + break + except BlockingIOError: + break + return self.process.poll() is None + + def close(self): + self.channel.close() # EOF is the renderer's explicit lifetime boundary. + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.terminate() + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=1) + + +class Controller: + def __init__(self, binary): + self.binary = binary + self.dr = None + self.matrix = {} + self.config = {} + self.config_key = None + self.fingerprint = None + self.generation = None + self.error = None + + def release(self): + for child in self.matrix.values(): + child.close() + self.matrix.clear() + if self.dr: + self.dr.close() + self.dr = None + + def tick(self, options): + if not options['enabled']: + self.release() + return + if not self.dr: + self.dr = connect(Path(os.environ['XDG_RUNTIME_DIR']) / 'digitalrain/control.sock', socket.SOCK_SEQPACKET) + dr_request(self.dr, 'ambient', enabled=True) + generation = options['reset_generation'] + if self.generation is not None and generation != self.generation: + dr_request(self.dr, 'wake') + self.generation = generation + status = dr_request(self.dr, 'status') + path = status.get('config_file', '') + key = (path, status.get('settings_revision')) + if key != self.config_key: + # DR validates edits before changing this revision. Retain the last + # valid settings if an editor is midway through a replacement. + try: + with open(path, 'rb') as handle: + candidate = tomllib.load(handle) + # Scalars come from the manager's validated current settings, + # including runtime API changes and defaults absent from TOML. + for qualified, entry in dr_request(self.dr, 'schema').items(): + group, name = qualified.split('.', 1) + candidate.setdefault(group, {})[name] = entry['value'] + self.config = candidate + self.config_key = key + except (OSError, ValueError): + pass + targets = {item['name'] for item in options['outputs']} + wanted = targets if options['backend'] == 'matrix' else set() + fingerprint = json.dumps([self.config.get('matrix', {}), self.config.get('output_colors', {})], sort_keys=True) + for name in list(self.matrix): + if name not in wanted or fingerprint != self.fingerprint or not self.matrix[name].alive(): + self.matrix.pop(name).close() + for name in sorted(wanted): + if name not in self.matrix: + self.matrix[name] = Matrix(name, self.config, self.binary) + self.fingerprint = fingerprint + activity = {item['name']: item for item in status['outputs']} + updates = [] + for name in sorted(targets): + output = activity.get(name) + state = phase(options, output, self.config) if output and status['lock'] == 'unlocked' else { + 'opacity': 1., 'black': 0., 'effect': 1.} + updates.append({'name': name, **state}) + ipc('ambient/sync', {'outputs': updates}) + self.error = None + + +def main(): + parser = argparse.ArgumentParser(description='Continuous ambient background and per-monitor desktop fading') + parser.add_argument('--daemon', action='store_true', help='run the compositor helper') + parser.add_argument('--toggle', action='store_true', help='toggle ambient-only mode on all monitors') + parser.add_argument('--restore', action='store_true', help='restore desktop elements and keyboard focus') + parser.add_argument('--status', action='store_true') + parser.add_argument('--digitalrain', default='/usr/bin/digitalrain', help=argparse.SUPPRESS) + args = parser.parse_args() + if args.toggle or args.restore or args.status: + print(json.dumps(ipc('ambient/toggle' if args.toggle else 'ambient/restore' if args.restore else 'ambient/status'), indent=2)) + return + if not args.daemon: + parser.print_help() + return + if os.geteuid() == 0: + parser.error('Run the ambient controller as the desktop user') + # One daemon per compositor, including independent test sessions. + runtime = Path(os.environ['XDG_RUNTIME_DIR']) + with connect(os.environ['WAYFIRE_SOCKET']) as stream: + pid = peer(stream) + handle = open(runtime / f'wayfire-ambient-{pid}.lock', 'w') + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + def stop(*_): + global STOP + STOP = True + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + controller = Controller(args.digitalrain) + try: + while not STOP: + try: + options = ipc('ambient/status') + except (OSError, RuntimeError, ValueError): + break # compositor exit or plugin unload + try: + controller.tick(options) + except (OSError, RuntimeError, ValueError) as error: + controller.release() + message = str(error) + if message != controller.error: + print('Ambient controller:', message, file=sys.stderr, flush=True) + controller.error = message + # Fail open immediately; the plugin also has an independent watchdog. + try: + ipc('ambient/sync', {'outputs': [{'name': z['name'], 'opacity': 1., 'black': 0., 'effect': 1.} + for z in options['outputs']]}) + except (OSError, RuntimeError, ValueError): + break + time.sleep(.25) + finally: + controller.release() + handle.close() + + +if __name__ == '__main__': + main() --- /dev/null +++ b/plugins/cube/ambient-api.hpp @@ -0,0 +1,14 @@ +#pragma once +#include +#include +namespace wf { +struct ambient_query_signal { wf::output_t *output; bool enabled = false; }; +struct ambient_render_signal { + wf::output_t *output; + const wf::render_target_t *target; + const wf::regionf_t *damage; + bool handled = false; +}; +struct ambient_transition_signal { wf::output_t *output; bool moving; }; +struct ambient_changed_signal {}; +} --- a/plugins/cube/cube.cpp +++ b/plugins/cube/cube.cpp @@ -1,3 +1,4 @@ +#include "ambient-api.hpp" #include #include #include @@ -466,14 +467,28 @@ (std::string(background_mode) == "warp" || std::string(background_mode) == "hyperspace"); } + bool ambient_active() const + { + wf::ambient_query_signal query{output}; + wf::get_core().emit(&query); + return query.enabled; + } + wf::signal::connection_t ambient_changed = [this] (auto) + { + sync_background_animation(); + rebuild_cube_render_node(); + output->render->damage_whole(); + }; + bool show_backdrop_image() const { - return std::string(background_mode) != "none" && + return !ambient_active() && std::string(background_mode) != "none" && double(backdrop_image_opacity) > 0 && !procedural_background_active(); } bool procedural_should_animate() const { + if (ambient_active()) return false; const auto mode = std::string(background_mode); const bool animate_at_rest = mode == "warp" ? bool(warp_animate_at_rest) : mode == "hyperspace" && bool(hyperspace_animate_at_rest); @@ -491,6 +506,9 @@ void sync_background_animation() { if (!warp_background || !hyperspace_background) return; + wf::ambient_transition_signal transition{output, + !resting_renderer && !input_is_grabbed && !animation.in_exit && animation.cube_animation.running()}; + wf::get_core().emit(&transition); const auto mode = std::string(background_mode); const bool enabled = procedural_should_animate(); warp_background->set_active(enabled && mode == "warp"); @@ -666,6 +684,7 @@ void init() override { + wf::get_core().connect(&ambient_changed); // Promote ini values read with older metadata when hot-loading Cube. auto section = wf::get_core().config->get_section("cube"); for (const auto& name : {"mouse_overview", "render_at_rest"}) @@ -1680,9 +1699,11 @@ // Fill the pixels withheld from the wallpaper pass before drawing // the faces. Never fade windows or foreground desktop surfaces. + wf::ambient_render_signal ambient{output, &data.target, &data.damage}; + wf::get_core().emit(&ambient); const bool no_background = std::string(background_mode) == "none"; - const float opacity = (no_background || procedural_background_active()) ? 1.0f : - 1.0f - std::clamp(double(backdrop_image_opacity), 0.0, 1.0); + const float opacity = ambient.handled ? 0.0f : ((no_background || procedural_background_active()) ? 1.0f : + 1.0f - std::clamp(double(backdrop_image_opacity), 0.0, 1.0)); if (opacity > 0 && std::string(background_mode) == "warp") { warp_background->render(data.target, data.damage, --- a/plugins/cube/meson.build +++ b/plugins/cube/meson.build @@ -4,3 +4,10 @@ dependencies: [wlroots, pixman, wfconfig, json, plugin_pch_dep], install: true, install_dir: join_paths(get_option('libdir'), 'wayfire')) + +shared_module('ambient-background', + ['../ambient-background/ambient-background.cpp', 'hyperspace-background.cpp'], + include_directories: [wayfire_api_inc, wayfire_conf_inc, plugins_common_inc, ipc_include_dirs], + dependencies: [wlroots, pixman, wfconfig, json, plugin_pch_dep], + install: true, + install_dir: join_paths(get_option('libdir'), 'wayfire'))