diff --git a/docs/wiki/Configuration:-Animations.md b/docs/wiki/Configuration:-Animations.md
index 6441d33a46..fd9c60fa79 100644
--- a/docs/wiki/Configuration:-Animations.md
+++ b/docs/wiki/Configuration:-Animations.md
@@ -440,6 +440,52 @@ animations {
}
```
+#### `screen-transition`
+
+Since: next release
+
+The cross-fade animation of the `do-screen-transition` action.
+
+```kdl
+animations {
+ screen-transition {
+ curve "ease-out-expo"
+ }
+}
+```
+
+##### `custom-shader`
+
+Since: next release
+
+You can write a custom shader for drawing the screen during a screen transition animation.
+
+See [this example shader](./examples/screen_transition_custom_shader.frag) for a full documentation with several animations to experiment with.
+
+If a custom shader fails to compile, niri will print a warning and fall back to the default, or previous successfully compiled shader.
+When running niri as a systemd service, you can see the warnings in the journal: `journalctl -ef /usr/bin/niri`
+
+> [!WARNING]
+>
+> Custom shaders do not have a backwards compatibility guarantee.
+> I may need to change their interface as I'm developing new features.
+
+
+
+```kdl
+animations {
+ screen-transition {
+ custom-shader r"
+ vec4 screen_transition_color(vec3 coords_curr_geo, vec3 size_curr_geo) {
+ vec3 coords_tex = niri_geo_to_tex * coords_curr_geo;
+ vec4 color = texture2D(niri_tex_from, coords_tex.st);
+ return color * (1.0 - niri_clamped_progress);
+ }
+ "
+ }
+}
+```
+
### Synchronized Animations
Since: 0.1.5
diff --git a/docs/wiki/examples/screen_transition_custom_shader.frag b/docs/wiki/examples/screen_transition_custom_shader.frag
new file mode 100644
index 0000000000..8e8355d0d1
--- /dev/null
+++ b/docs/wiki/examples/screen_transition_custom_shader.frag
@@ -0,0 +1,170 @@
+// Your shader must contain one function (see the bottom of this file).
+//
+// It should not contain any uniform definitions or anything else, as niri
+// provides them for you.
+//
+// All symbols defined by niri will have a niri_ prefix, so don't use it for
+// your own variables and functions.
+
+// The function that you must define looks like this:
+vec4 screen_transition_color(vec3 coords_geo, vec3 size_geo) {
+ vec4 color = /* ...compute the color... */;
+ return color;
+}
+
+// It takes as input:
+//
+// * coords_geo: coordinates of the current pixel relative to the output.
+//
+// These are homogeneous (the Z component is equal to 1) and scaled in such a
+// way that the 0 to 1 coordinates cover the full output.
+//
+// * size_geo: size of the output in logical pixels.
+//
+// It is homogeneous (the Z component is equal to 1).
+//
+// The screen transition shader renders as an overlay above the live workspace.
+// Making pixels transparent (alpha = 0) will reveal the new workspace
+// underneath, while opaque pixels (alpha = 1) will show the captured old
+// workspace snapshot. As the transition progresses, you should generally make
+// more pixels transparent to reveal the new workspace.
+//
+// The function must return the color of the pixel (with premultiplied alpha).
+// The pixel color will be further processed by niri (e.g. to apply final
+// alpha).
+
+// Now let's go over the uniforms that niri defines.
+//
+// You should only rely on the uniforms documented here. Any other uniforms can
+// change or be removed without notice.
+
+// The captured texture of the old workspace (before the switch).
+uniform sampler2D niri_tex_from;
+
+// Matrix that converts geometry coordinates into the old workspace texture
+// coordinates.
+//
+// You must always use this matrix to sample the texture, like this:
+// vec3 coords_tex = niri_geo_to_tex * coords_geo;
+// vec4 color = texture2D(niri_tex_from, coords_tex.st);
+// This ensures correct sampling when the output has a transform (rotation).
+uniform mat3 niri_geo_to_tex;
+
+
+// Unclamped progress of the animation.
+//
+// Goes from 0 to 1 but may overshoot and oscillate.
+uniform float niri_progress;
+
+// Clamped progress of the animation.
+//
+// Goes from 0 to 1, but will stop at 1 as soon as it first reaches 1. Will not
+// overshoot or oscillate.
+uniform float niri_clamped_progress;
+
+// Random float in [0; 1), consistent for the duration of the animation.
+uniform float niri_random_seed;
+
+// Mouse position in logical output coordinates (0,0 at top-left).
+// Set to (-1, -1) if the cursor is not on this output.
+uniform vec2 niri_mouse_pos;
+
+// Now let's look at some examples. You can copy everything below this line
+// into your custom-shader to experiment.
+
+// Example: gradually fade out the old workspace, equivalent to the default
+// crossfade transition.
+vec4 default_crossfade(vec3 coords_geo, vec3 size_geo) {
+ vec3 coords_tex = niri_geo_to_tex * coords_geo;
+ vec4 color = texture2D(niri_tex_from, coords_tex.st);
+
+ // Fade out the old workspace to reveal the new one underneath.
+ color *= (1.0 - niri_clamped_progress);
+
+ return color;
+}
+
+// Example: horizontal wipe from left to right revealing the new workspace.
+vec4 horizontal_wipe(vec3 coords_geo, vec3 size_geo) {
+ vec3 coords_tex = niri_geo_to_tex * coords_geo;
+ vec4 color = texture2D(niri_tex_from, coords_tex.st);
+
+ // The wipe edge moves from left (x=0) to right (x=1) with progress.
+ // Pixels to the left of the edge become transparent, revealing the
+ // new workspace underneath.
+ float alpha = smoothstep(niri_clamped_progress - 0.05,
+ niri_clamped_progress + 0.05,
+ coords_geo.x);
+
+ return color * alpha;
+}
+
+// Example: the old workspace slides upward off the screen.
+vec4 slide_up(vec3 coords_geo, vec3 size_geo) {
+ // Shift the sampling position upward as the transition progresses.
+ vec3 shifted = vec3(coords_geo.x,
+ coords_geo.y + niri_clamped_progress,
+ 1.0);
+
+ // Pixels that have shifted beyond the old workspace should be
+ // transparent, letting the new workspace show through.
+ if (shifted.y > 1.0)
+ return vec4(0.0);
+
+ vec3 coords_tex = niri_geo_to_tex * shifted;
+ vec4 color = texture2D(niri_tex_from, coords_tex.st);
+
+ return color;
+}
+
+// Example: pixels randomly dissolve to reveal the new workspace.
+vec4 pixel_dissolve(vec3 coords_geo, vec3 size_geo) {
+ vec3 coords_tex = niri_geo_to_tex * coords_geo;
+ vec4 color = texture2D(niri_tex_from, coords_tex.st);
+
+ // Generate a pseudo-random value per pixel, offset by the random seed
+ // so each transition looks different.
+ vec2 seed = coords_geo.xy + vec2(niri_random_seed);
+ float random = fract(sin(dot(seed, vec2(12.9898, 78.233))) * 43758.5453);
+
+ // As progress increases, more pixels cross the threshold and become
+ // transparent, revealing the new workspace.
+ float threshold = niri_clamped_progress * 1.2;
+ float alpha = step(threshold, random);
+
+ return color * alpha;
+}
+
+// Example: expanding circle from mouse position revealing the new workspace.
+// Recommended setting: duration-ms 300
+vec4 circle_reveal(vec3 coords_geo, vec3 size_geo) {
+ vec3 coords_tex = niri_geo_to_tex * coords_geo;
+ vec4 color = texture2D(niri_tex_from, coords_tex.st);
+
+ // Use the mouse position as the circle center, or the screen center
+ // if the cursor is off this output (sentinel value -1, -1).
+ vec2 center = (niri_mouse_pos.x < 0.0)
+ ? vec2(0.5)
+ : niri_mouse_pos / size_geo.xy;
+
+ // Correct for aspect ratio so the reveal is a circle, not an ellipse.
+ vec2 aspect = size_geo.xy / length(size_geo.xy);
+ vec2 delta = (coords_geo.xy - center) * aspect;
+ float dist = length(delta);
+
+ // The circle expands with progress. Scale by ~1.5 to ensure
+ // it covers the full screen even when starting from a corner.
+ float radius = niri_clamped_progress * 1.5;
+
+ // Pixels inside the expanding circle become transparent (new workspace),
+ // pixels outside stay opaque (old workspace).
+ float alpha = smoothstep(radius - 0.05, radius, dist);
+
+ return color * alpha;
+}
+
+// This is the function that you must define.
+vec4 screen_transition_color(vec3 coords_geo, vec3 size_geo) {
+ // You can pick one of the example functions or write your own.
+ return circle_reveal(coords_geo, size_geo);
+}
diff --git a/niri-config/src/animations.rs b/niri-config/src/animations.rs
index 346b62517a..80f8f85b79 100644
--- a/niri-config/src/animations.rs
+++ b/niri-config/src/animations.rs
@@ -19,6 +19,7 @@ pub struct Animations {
pub screenshot_ui_open: ScreenshotUiOpenAnim,
pub overview_open_close: OverviewOpenCloseAnim,
pub recent_windows_close: RecentWindowsCloseAnim,
+ pub screen_transition: ScreenTransitionAnim,
}
impl Default for Animations {
@@ -37,6 +38,7 @@ impl Default for Animations {
screenshot_ui_open: Default::default(),
overview_open_close: Default::default(),
recent_windows_close: Default::default(),
+ screen_transition: Default::default(),
}
}
}
@@ -71,6 +73,8 @@ pub struct AnimationsPart {
pub overview_open_close: Option,
#[knuffel(child)]
pub recent_windows_close: Option,
+ #[knuffel(child)]
+ pub screen_transition: Option,
}
impl MergeWith for Animations {
@@ -97,6 +101,7 @@ impl MergeWith for Animations {
screenshot_ui_open,
overview_open_close,
recent_windows_close,
+ screen_transition,
);
}
}
@@ -326,6 +331,27 @@ impl Default for RecentWindowsCloseAnim {
}
}
+#[derive(Debug, Clone, PartialEq)]
+pub struct ScreenTransitionAnim {
+ pub anim: Animation,
+ pub custom_shader: Option,
+}
+
+impl Default for ScreenTransitionAnim {
+ fn default() -> Self {
+ Self {
+ anim: Animation {
+ off: false,
+ kind: Kind::Easing(EasingParams {
+ duration_ms: 300,
+ curve: Curve::EaseOutExpo,
+ }),
+ },
+ custom_shader: None,
+ }
+ }
+}
+
impl knuffel::Decode for WorkspaceSwitchAnim
where
S: knuffel::traits::ErrorSpan,
@@ -524,6 +550,32 @@ where
}
}
+impl knuffel::Decode for ScreenTransitionAnim
+where
+ S: knuffel::traits::ErrorSpan,
+{
+ fn decode_node(
+ node: &knuffel::ast::SpannedNode,
+ ctx: &mut knuffel::decode::Context,
+ ) -> Result> {
+ let default = Self::default().anim;
+ let mut custom_shader = None;
+ let anim = Animation::decode_node(node, ctx, default, |child, ctx| {
+ if &**child.node_name == "custom-shader" {
+ custom_shader = parse_arg_node("custom-shader", child, ctx)?;
+ Ok(true)
+ } else {
+ Ok(false)
+ }
+ })?;
+
+ Ok(Self {
+ anim,
+ custom_shader,
+ })
+ }
+}
+
impl Animation {
pub fn new_off() -> Self {
Self {
diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs
index b61fe1c1a0..acc4e81c76 100644
--- a/niri-config/src/lib.rs
+++ b/niri-config/src/lib.rs
@@ -1615,6 +1615,18 @@ mod tests {
),
},
),
+ screen_transition: ScreenTransitionAnim {
+ anim: Animation {
+ off: false,
+ kind: Easing(
+ EasingParams {
+ duration_ms: 300,
+ curve: EaseOutExpo,
+ },
+ ),
+ },
+ custom_shader: None,
+ },
},
gestures: Gestures {
dnd_edge_view_scroll: DndEdgeViewScroll {
diff --git a/src/animation/mod.rs b/src/animation/mod.rs
index 73a79c5e43..80bb6203d7 100644
--- a/src/animation/mod.rs
+++ b/src/animation/mod.rs
@@ -238,6 +238,19 @@ impl Animation {
self.clock.now() >= self.start_time + self.duration
}
+ /// Returns whether the animation is done as seen from a custom time `at`.
+ ///
+ /// Useful for delayed animations that derive their effective clock time from an offset
+ /// (e.g. `clock.now().saturating_sub(delay)`). Handles `should_complete_instantly`
+ /// the same way as `is_done`.
+ pub fn is_done_at(&self, at: Duration) -> bool {
+ if self.clock.should_complete_instantly() {
+ return true;
+ }
+
+ at >= self.start_time + self.duration
+ }
+
pub fn is_clamped_done(&self) -> bool {
if self.clock.should_complete_instantly() {
return true;
diff --git a/src/backend/tty.rs b/src/backend/tty.rs
index 259dde3091..8fd5d487d0 100644
--- a/src/backend/tty.rs
+++ b/src/backend/tty.rs
@@ -826,6 +826,9 @@ impl Tty {
if let Some(src) = config.animations.window_open.custom_shader.as_deref() {
shaders::set_custom_open_program(gles_renderer, Some(src));
}
+ if let Some(src) = config.animations.screen_transition.custom_shader.as_deref() {
+ shaders::set_custom_screen_transition_program(gles_renderer, Some(src));
+ }
drop(config);
niri.update_shaders();
diff --git a/src/backend/winit.rs b/src/backend/winit.rs
index beace1dce5..8064d25470 100644
--- a/src/backend/winit.rs
+++ b/src/backend/winit.rs
@@ -160,6 +160,9 @@ impl Winit {
if let Some(src) = config.animations.window_open.custom_shader.as_deref() {
shaders::set_custom_open_program(renderer, Some(src));
}
+ if let Some(src) = config.animations.screen_transition.custom_shader.as_deref() {
+ shaders::set_custom_screen_transition_program(renderer, Some(src));
+ }
drop(config);
niri.update_shaders();
diff --git a/src/niri.rs b/src/niri.rs
index 414f702d80..b17e3a728e 100644
--- a/src/niri.rs
+++ b/src/niri.rs
@@ -164,7 +164,7 @@ use crate::ui::config_error_notification::ConfigErrorNotification;
use crate::ui::exit_confirm_dialog::{ExitConfirmDialog, ExitConfirmDialogRenderElement};
use crate::ui::hotkey_overlay::HotkeyOverlay;
use crate::ui::mru::{MruCloseRequest, WindowMruUi, WindowMruUiRenderElement};
-use crate::ui::screen_transition::{self, ScreenTransition};
+use crate::ui::screen_transition::{self, ScreenTransition, ScreenTransitionRenderElement};
use crate::ui::screenshot_ui::{OutputScreenshot, ScreenshotUi, ScreenshotUiRenderElement};
use crate::utils::scale::{closest_representable_scale, guess_monitor_scale};
use crate::utils::spawning::{CHILD_DISPLAY, CHILD_ENV};
@@ -1563,6 +1563,16 @@ impl State {
shaders_changed = true;
}
+ if config.animations.screen_transition.custom_shader
+ != old_config.animations.screen_transition.custom_shader
+ {
+ let src = config.animations.screen_transition.custom_shader.as_deref();
+ self.backend.with_primary_renderer(|renderer| {
+ shaders::set_custom_screen_transition_program(renderer, src);
+ });
+ shaders_changed = true;
+ }
+
if config.cursor.hide_after_inactive_ms != old_config.cursor.hide_after_inactive_ms {
cursor_inactivity_timeout_changed = true;
}
@@ -4078,7 +4088,25 @@ impl Niri {
{
let state = self.output_state.get(output).unwrap();
if let Some(transition) = &state.screen_transition {
- push(transition.render(target).into());
+ let mouse_pos = self.global_space.output_geometry(output).and_then(|geo| {
+ let pos = self
+ .tablet_cursor_location
+ .unwrap_or_else(|| self.seat.get_pointer().unwrap().current_location());
+ let local =
+ Point::from((pos.x - f64::from(geo.loc.x), pos.y - f64::from(geo.loc.y)));
+
+ if local.x < 0.
+ || local.y < 0.
+ || f64::from(geo.size.w) <= local.x
+ || f64::from(geo.size.h) <= local.y
+ {
+ None
+ } else {
+ Some(local)
+ }
+ });
+
+ push(transition.render(renderer, target, mouse_pos).into());
}
}
@@ -5880,6 +5908,8 @@ impl Niri {
pub fn do_screen_transition(&mut self, renderer: &mut GlesRenderer, delay_ms: Option) {
let _span = tracy_client::span!("Niri::do_screen_transition");
+ let anim_config = self.config.borrow().animations.screen_transition.anim;
+
self.update_render_elements(None);
let textures: Vec<_> = self
@@ -5910,13 +5940,21 @@ impl Niri {
);
if let Err(err) = &res {
- warn!("error rendering output {}: {err:?}", output.name());
+ warn!(
+ "error rendering {:?} for output {}: {err:?}",
+ target,
+ output.name()
+ );
}
res
});
if textures.iter().any(|res| res.is_err()) {
+ warn!(
+ "skipping screen transition for output {} due to render errors",
+ output.name()
+ );
return None;
}
@@ -5944,6 +5982,7 @@ impl Niri {
state.screen_transition = Some(ScreenTransition::new(
from_texture,
delay,
+ anim_config,
self.clock.clone(),
));
}
@@ -6164,6 +6203,7 @@ niri_render_elements! {
ScreenshotUi = ScreenshotUiRenderElement,
WindowMruUi = WindowMruUiRenderElement,
ExitConfirmDialog = ExitConfirmDialogRenderElement,
+ ScreenTransition = ScreenTransitionRenderElement,
Texture = PrimaryGpuTextureRenderElement,
// Used for the CPU-rendered panels.
RelocatedMemoryBuffer = RelocateRenderElement>,
diff --git a/src/render_helpers/shaders/mod.rs b/src/render_helpers/shaders/mod.rs
index 6fccce7f59..9bfc999aba 100644
--- a/src/render_helpers/shaders/mod.rs
+++ b/src/render_helpers/shaders/mod.rs
@@ -18,6 +18,7 @@ pub struct Shaders {
pub custom_resize: RefCell