Skip to content

Commit 91317e1

Browse files
committed
ipc/ui: support custom transition shaders and animation config
Adds the shader_path request (hyprpaper_wallpaper v2) so a custom fragment shader can be supplied per wallpaper apply, and an existing monitor wallpaper now transitions to the new path instead of replacing outright. - IPC::CWallpaperObject stores and forwards the shader path. - CConfigManager::getAnimationConfig reads fadeLayersIn/fadeLayersOut (enabled + duration) from hyprland.conf, cached per name. - CWallpaperTarget::transitionTo / replaceImmediate delegate to the image element; CUI reuses the existing target when one already exists for the monitor, animating only when an animation config is enabled. - Example custom shaders under shaders/ (circle-reveal, diagonal-wipe).
1 parent 20fc0fa commit 91317e1

9 files changed

Lines changed: 402 additions & 4 deletions

File tree

hw-protocols/hyprpaper_core.xml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
<value idx="2" name="unknown_error" description="unknown error"/>
8989
</enum>
9090

91-
<object name="hyprpaper_wallpaper" version="1">
91+
<object name="hyprpaper_wallpaper" version="2">
9292
<description summary="wallpaper object">
9393
This is an object describing a wallpaper
9494
</description>
@@ -147,6 +147,14 @@
147147
Destroys this object.
148148
</description>
149149
</c2s>
150+
151+
<c2s name="shader_path" since="2">
152+
<description summary="Set a custom transition shader">
153+
Set a custom fragment shader used for the wallpaper transition animation.
154+
If unset, the default transition shader is used.
155+
</description>
156+
<arg name="shader_path" type="varchar" summary="absolute path to .frag shader file"/>
157+
</c2s>
150158
</object>
151159

152160
<object name="hyprpaper_status" version="2">

shaders/circle-reveal.frag

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#version 300 es
2+
3+
precision highp float;
4+
5+
in vec2 v_texcoord;
6+
7+
uniform sampler2D tex1; // Start texture (T1)
8+
uniform sampler2D tex2; // End texture (T2)
9+
uniform float progress; // 0.0 = fully T1, 1.0 = fully T2
10+
uniform float alpha;
11+
12+
uniform vec2 topLeft;
13+
uniform vec2 fullSize;
14+
uniform float radius;
15+
16+
uniform vec2 tex1Size;
17+
uniform vec2 tex2Size;
18+
uniform int fitMode;
19+
uniform vec2 randomPixel; // Circle center (0-1 normalized)
20+
uniform float u_duration; // Host transition duration in seconds (from hyprland config)
21+
22+
// Shader-defined visual duration override: the circle effect completes in 0.5s
23+
// regardless of the host's transition duration. The host's u_duration is used
24+
// to remap progress so the effect fits within this window.
25+
const float SHADER_DURATION = 0.5;
26+
27+
layout(location = 0) out vec4 fragColor;
28+
29+
vec2 containUV(vec2 uv, vec2 texSize, vec2 boxSize) {
30+
float boxAspect = boxSize.x / boxSize.y;
31+
float texAspect = texSize.x / texSize.y;
32+
33+
vec2 scale;
34+
vec2 offset = vec2(0.0);
35+
36+
if (texAspect > boxAspect) {
37+
scale.x = 1.0;
38+
scale.y = boxAspect / texAspect;
39+
offset.y = (1.0 - scale.y) * 0.5;
40+
} else {
41+
scale.x = texAspect / boxAspect;
42+
scale.y = 1.0;
43+
offset.x = (1.0 - scale.x) * 0.5;
44+
}
45+
46+
return (uv - offset) / scale;
47+
}
48+
49+
vec2 coverUV(vec2 uv, vec2 texSize, vec2 boxSize) {
50+
float boxAspect = boxSize.x / boxSize.y;
51+
float texAspect = texSize.x / texSize.y;
52+
53+
vec2 scale;
54+
vec2 offset = vec2(0.0);
55+
56+
if (texAspect > boxAspect) {
57+
scale.x = boxAspect / texAspect;
58+
scale.y = 1.0;
59+
offset.x = (1.0 - scale.x) * 0.5;
60+
} else {
61+
scale.x = 1.0;
62+
scale.y = texAspect / boxAspect;
63+
offset.y = (1.0 - scale.y) * 0.5;
64+
}
65+
66+
return (uv - offset) / scale;
67+
}
68+
69+
void main() {
70+
vec2 uv = v_texcoord;
71+
72+
// Remap progress so the effect completes in SHADER_DURATION seconds.
73+
// progress goes 0->1 over u_duration (host config). We compress it so the
74+
// effect finishes at SHADER_DURATION, then holds at the final state.
75+
float p = (u_duration > 0.0)
76+
? clamp(progress * (u_duration / SHADER_DURATION), 0.0, 1.0)
77+
: progress;
78+
79+
// Apply UV adjustment based on fit mode
80+
vec2 uv1 = uv;
81+
vec2 uv2 = uv;
82+
83+
if (fitMode == 1) { // cover
84+
uv1 = coverUV(uv, tex1Size, fullSize);
85+
uv2 = coverUV(uv, tex2Size, fullSize);
86+
} else if (fitMode == 2) { // contain
87+
uv1 = containUV(uv, tex1Size, fullSize);
88+
uv2 = containUV(uv, tex2Size, fullSize);
89+
}
90+
91+
// Sample both textures
92+
vec4 startColor = texture(tex1, uv1);
93+
vec4 endColor = texture(tex2, uv2);
94+
95+
// For contain mode, make out-of-bounds pixels black
96+
if (fitMode == 2) {
97+
if (uv1.x < 0.0 || uv1.x > 1.0 || uv1.y < 0.0 || uv1.y > 1.0)
98+
startColor = vec4(0.0, 0.0, 0.0, 1.0);
99+
if (uv2.x < 0.0 || uv2.x > 1.0 || uv2.y < 0.0 || uv2.y > 1.0)
100+
endColor = vec4(0.0, 0.0, 0.0, 1.0);
101+
}
102+
103+
// Circle reveal effect
104+
// randomPixel is 0-1 normalized, use as circle center
105+
vec2 center = randomPixel;
106+
107+
// Convert to pixel space for aspect-ratio correct circle
108+
vec2 pixelPos = uv * fullSize;
109+
vec2 centerPixel = center * fullSize;
110+
111+
// Euclidean distance in pixel space (aspect-ratio correct)
112+
float dist = distance(pixelPos, centerPixel);
113+
114+
// Calculate max distance (farthest corner from center in pixel space)
115+
float maxDist = 0.0;
116+
maxDist = max(maxDist, distance(vec2(0.0, 0.0), centerPixel));
117+
maxDist = max(maxDist, distance(vec2(fullSize.x, 0.0), centerPixel));
118+
maxDist = max(maxDist, distance(vec2(0.0, fullSize.y), centerPixel));
119+
maxDist = max(maxDist, distance(fullSize, centerPixel));
120+
121+
// Normalize distance (0 at center, 1 at farthest corner)
122+
float normalizedDist = dist / maxDist;
123+
124+
// Circle mask: 0 = show start, 1 = show end
125+
// Smooth edge for anti-aliasing
126+
float edgeWidth = 0.02;
127+
float circleMask = smoothstep(p - edgeWidth, p + edgeWidth, normalizedDist);
128+
129+
// Mix: where normalizedDist < progress, show endColor (new)
130+
vec4 blended = mix(endColor, startColor, circleMask);
131+
132+
// Apply alpha
133+
fragColor = blended * alpha;
134+
}

shaders/diagonal-wipe.frag

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#version 300 es
2+
3+
precision highp float;
4+
5+
in vec2 v_texcoord;
6+
7+
uniform sampler2D tex1; // Start texture (T1)
8+
uniform sampler2D tex2; // End texture (T2)
9+
uniform float progress; // 0.0 = fully T1, 1.0 = fully T2
10+
uniform float alpha;
11+
12+
uniform vec2 topLeft;
13+
uniform vec2 fullSize;
14+
uniform float radius;
15+
16+
uniform vec2 tex1Size;
17+
uniform vec2 tex2Size;
18+
uniform int fitMode;
19+
20+
layout(location = 0) out vec4 fragColor;
21+
22+
vec2 containUV(vec2 uv, vec2 texSize, vec2 boxSize) {
23+
float boxAspect = boxSize.x / boxSize.y;
24+
float texAspect = texSize.x / texSize.y;
25+
26+
vec2 scale;
27+
vec2 offset = vec2(0.0);
28+
29+
if (texAspect > boxAspect) {
30+
scale.x = 1.0;
31+
scale.y = boxAspect / texAspect;
32+
offset.y = (1.0 - scale.y) * 0.5;
33+
} else {
34+
scale.x = texAspect / boxAspect;
35+
scale.y = 1.0;
36+
offset.x = (1.0 - scale.x) * 0.5;
37+
}
38+
39+
return (uv - offset) / scale;
40+
}
41+
42+
vec2 coverUV(vec2 uv, vec2 texSize, vec2 boxSize) {
43+
float boxAspect = boxSize.x / boxSize.y;
44+
float texAspect = texSize.x / texSize.y;
45+
46+
vec2 scale;
47+
vec2 offset = vec2(0.0);
48+
49+
if (texAspect > boxAspect) {
50+
scale.x = boxAspect / texAspect;
51+
scale.y = 1.0;
52+
offset.x = (1.0 - scale.x) * 0.5;
53+
} else {
54+
scale.x = 1.0;
55+
scale.y = texAspect / boxAspect;
56+
offset.y = (1.0 - scale.y) * 0.5;
57+
}
58+
59+
return (uv - offset) / scale;
60+
}
61+
62+
void main() {
63+
vec2 uv = v_texcoord;
64+
65+
// Apply UV adjustment based on fit mode
66+
vec2 uv1 = uv;
67+
vec2 uv2 = uv;
68+
69+
if (fitMode == 1) { // cover
70+
uv1 = coverUV(uv, tex1Size, fullSize);
71+
uv2 = coverUV(uv, tex2Size, fullSize);
72+
} else if (fitMode == 2) { // contain
73+
uv1 = containUV(uv, tex1Size, fullSize);
74+
uv2 = containUV(uv, tex2Size, fullSize);
75+
}
76+
77+
// Sample both textures
78+
vec4 startColor = texture(tex1, uv1);
79+
vec4 endColor = texture(tex2, uv2);
80+
81+
// For contain mode, make out-of-bounds pixels black
82+
if (fitMode == 2) {
83+
if (uv1.x < 0.0 || uv1.x > 1.0 || uv1.y < 0.0 || uv1.y > 1.0)
84+
startColor = vec4(0.0, 0.0, 0.0, 1.0);
85+
if (uv2.x < 0.0 || uv2.x > 1.0 || uv2.y < 0.0 || uv2.y > 1.0)
86+
endColor = vec4(0.0, 0.0, 0.0, 1.0);
87+
}
88+
89+
// Diagonal wipe from top-left corner
90+
// Distance along diagonal (top-left to bottom-right)
91+
float diagonal = (uv.x + uv.y) * 0.5;
92+
93+
// Edge width for smooth transition
94+
float edgeWidth = 0.05;
95+
96+
// wipeMask: 0 = show end (new), 1 = show start (old)
97+
// Wipe expands from top-left corner as progress increases
98+
float wipeMask = smoothstep(progress - edgeWidth, progress + edgeWidth, diagonal);
99+
100+
// Mix: where diagonal < progress, show endColor (new)
101+
vec4 blended = mix(endColor, startColor, wipeMask);
102+
103+
// Apply alpha
104+
fragColor = blended * alpha;
105+
}

src/config/ConfigManager.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <filesystem>
44
#include <glob.h>
55
#include <random>
6+
#include <fstream>
67
#include <hyprlang.hpp>
78
#include <hyprutils/path/Path.hpp>
89
#include <hyprutils/string/String.hpp>
@@ -290,3 +291,72 @@ static Hyprlang::CParseResult handleSource(const char* COMMAND, const char* VALU
290291

291292
return result;
292293
}
294+
295+
CConfigManager::SAnimationConfig CConfigManager::getAnimationConfig(const std::string& name) {
296+
if (m_animationCache.contains(name))
297+
return m_animationCache[name];
298+
299+
static const auto hyprlandConfigPath = []() {
300+
const char* home = getenv("HOME");
301+
if (!home)
302+
return std::string{};
303+
return std::string(home) + "/.config/hypr/hyprland.conf";
304+
}();
305+
306+
SAnimationConfig config;
307+
if (hyprlandConfigPath.empty()) {
308+
m_animationCache[name] = config;
309+
return config;
310+
}
311+
312+
// Format: animation = fadeLayersIn, 1, 1.79, almostLinear
313+
try {
314+
std::ifstream file(hyprlandConfigPath);
315+
if (!file.is_open()) {
316+
g_logger->log(LOG_WARN, "Could not open hyprland.conf at {}", hyprlandConfigPath);
317+
m_animationCache[name] = config;
318+
return config;
319+
}
320+
321+
std::string line;
322+
while (std::getline(file, line)) {
323+
auto trimmed = Hyprutils::String::trim(line);
324+
if (trimmed.starts_with('#') || !trimmed.starts_with("animation"))
325+
continue;
326+
327+
auto eqPos = trimmed.find('=');
328+
if (eqPos == std::string::npos)
329+
continue;
330+
331+
auto value = Hyprutils::String::trim(trimmed.substr(eqPos + 1));
332+
auto comma1 = value.find(',');
333+
if (comma1 == std::string::npos)
334+
continue;
335+
336+
// <name>, <enabled>, <duration>, <bezier>
337+
if (Hyprutils::String::trim(value.substr(0, comma1)) != name)
338+
continue;
339+
340+
auto rest = value.substr(comma1 + 1);
341+
auto comma2 = rest.find(',');
342+
if (comma2 == std::string::npos)
343+
continue;
344+
345+
auto enabledStr = Hyprutils::String::trim(rest.substr(0, comma2));
346+
rest = rest.substr(comma2 + 1);
347+
348+
// bezier (4th) is optional and ignored here.
349+
auto comma3 = rest.find(',');
350+
auto durationStr = Hyprutils::String::trim(comma3 == std::string::npos ? rest : rest.substr(0, comma3));
351+
352+
config.enabled = std::stoi(enabledStr) != 0;
353+
config.duration = std::stof(durationStr);
354+
break;
355+
}
356+
} catch (const std::exception& e) {
357+
g_logger->log(LOG_ERR, "Error parsing animation config: {}", e.what());
358+
}
359+
360+
m_animationCache[name] = config;
361+
return config;
362+
}

src/config/ConfigManager.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
#include "../helpers/Memory.hpp"
44
#include <hyprlang.hpp>
55
#include <vector>
6+
#include <string>
7+
#include <unordered_map>
68

79
class CConfigManager {
810
public:
@@ -19,6 +21,12 @@ class CConfigManager {
1921
std::string order = "default";
2022
int timeout = 0;
2123
uint32_t id = 0;
24+
std::string shaderPath;
25+
};
26+
27+
struct SAnimationConfig {
28+
bool enabled = true;
29+
float duration = 1.0f;
2230
};
2331

2432
constexpr static const uint32_t SETTING_INVALID = 0;
@@ -30,10 +38,15 @@ class CConfigManager {
3038

3139
const std::string& getCurrentConfigPath() const;
3240

41+
// Reads animation settings from hyprland.conf.
42+
SAnimationConfig getAnimationConfig(const std::string& name);
43+
3344
private:
3445
Hyprlang::CConfig m_config;
3546

3647
std::string m_currentConfigPath;
48+
49+
std::unordered_map<std::string, SAnimationConfig> m_animationCache;
3750
};
3851

3952
inline UP<CConfigManager> g_config;

0 commit comments

Comments
 (0)