Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions data/gala.gresource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<file compressed="true">shaders/colorblindness-correction.frag</file>
<file compressed="true">shaders/monochrome.frag</file>
<file compressed="true">shaders/rounded-corners.frag</file>
<file compressed="true">shaders/tv-effect.frag</file>
</gresource>
<gresource prefix="/io/elementary/desktop/gala-daemon">
<file compressed="true">gala-daemon.css</file>
Expand Down
63 changes: 63 additions & 0 deletions data/shaders/tv-effect.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2026 elementary, Inc. <https://elementary.io>
* SPDX-License-Identifier: GPL-3.0-or-later
*/

uniform sampler2D tex;
uniform float OCCLUSION; // 0.0 when the TV is fully on, 1.0 when it's fully off
uniform float HEIGHT; // Screen height in pixels

float cubicBezier(float x, float a, float b, float c, float d) {
float _x = 1.0 - x;
return a * _x * _x * _x +
b * 3.0 * _x * _x * x +
c * 3.0 * _x * x * x +
d * x * x * x;
}

vec4 tvEffect(vec2 uv, float scale, float occlusion) {
float y_scaled = 0.5 + (uv.y - 0.5) / (scale * scale);
float x_scaled = uv.x;

// Wait for scale to get small enough before shrinking the bright line to a dot
if (scale < 0.1) {
float scale_fract = scale / 0.1;
x_scaled = 0.5 + (uv.x - 0.5) / (scale_fract * scale_fract);
}

// Outside of the scaled area should be black
if (scale >= 0.0 && y_scaled >= 0.0 && y_scaled <= 1.0 && x_scaled >= 0.0 && x_scaled <= 1.0) {
vec4 color = texture2D(tex, vec2(x_scaled, y_scaled));
// Make it brighter as the window gets smaller
return color + occlusion * color + occlusion * 0.75;
} else {
return vec4(0.0, 0.0, 0.0, 1.0);
}
}

void main() {
// Ease out the occlusion
float occlusion = cubicBezier(OCCLUSION, 0.0, 0.98, 0.75, 1.0);
float scale = 1.0 - occlusion;
vec2 uv = cogl_tex_coord0_in.xy;

// Apply a 5x5 Gaussian blur, with support of 0.5 and sigma 1.0
float kernel[5];
kernel[0] = 0.0614;
kernel[1] = 0.2448;
kernel[2] = 0.3877;
kernel[3] = 0.2448;
kernel[4] = 0.0614;

float blurSize = occlusion * 5.0 / HEIGHT;

vec4 color = vec4(0.0);
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 5; j++) {
vec2 offset = vec2(float(i - 2), float(j - 2)) * blurSize;
color += tvEffect(uv + offset, scale, occlusion) * kernel[i] * kernel[j];
}
}

cogl_color_out = color;
}
45 changes: 45 additions & 0 deletions src/Curtains/TVEffect.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2026 elementary, Inc. <https://elementary.io>
* SPDX-License-Identifier: LGPL-3.0-or-later
*/

public class Gala.TVEffect : Clutter.ShaderEffect {
private float _occlusion = 0.0f;
public float occlusion { get {
return _occlusion;
} set {
_occlusion = value;
set_uniform_value ("OCCLUSION", value);
queue_repaint ();
}}

private float _height = 512.0f;
public float height { get {
return _height;
} set {
_height = value;
set_uniform_value ("HEIGHT", value);
queue_repaint ();
}}

public TVEffect (float occlusion = 0.0f) {
Object (
#if HAS_MUTTER48
shader_type: Cogl.ShaderType.FRAGMENT,
#else
shader_type: Clutter.ShaderType.FRAGMENT_SHADER,
#endif
occlusion: occlusion
);

try {
var bytes = GLib.resources_lookup_data (
"/io/elementary/desktop/gala/shaders/tv-effect.frag",
GLib.ResourceLookupFlags.NONE
);
set_shader_source ((string) bytes.get_data ());
} catch (Error e) {
warning ("Failed to load TV effect shader: %s", e.message);
}
}
}
3 changes: 3 additions & 0 deletions src/Main.vala
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ namespace Gala {

WindowStateSaver.on_shutdown ();

var shutdown_curtain = new Gala.ShutdownCurtain (ctx);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK at this point Mutter has already completely shutdown. So we don't have a stage nor a frame clock nor anything anymore

shutdown_curtain.animate ();

return Posix.EXIT_SUCCESS;
}
}
43 changes: 43 additions & 0 deletions src/Widgets/Curtains/ShutdownCurtain.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020, 2025, 2026 elementary, Inc. (https://elementary.io)
*/

public class Gala.ShutdownCurtain : Clutter.Actor {
private unowned Clutter.Stage stage;
public uint animation_duration { get; set construct; default = 300; }

public ShutdownCurtain (Meta.Context context) {
int screen_width, screen_height;
context.get_display ().get_size (out screen_width, out screen_height);
width = screen_width;
height = screen_height;
stage = (Clutter.Stage) context.get_display ().get_stage ();
}

public void animate () {
var animation_thread = new Thread<void> ("tv-effect-animation", start);
animation_thread.join ();
}

private void start () {
int time = 0;
var tv_effect = new TVEffect ();
tv_effect.occlusion = 0;
tv_effect.height = height;
stage.add_effect_with_name (
"tv-effect",
tv_effect
);

while (time < animation_duration) {
tv_effect.occlusion = (animation_duration - time) / (float) animation_duration;

time += 16;
Thread.usleep (16000);
}

stage.remove_effect_by_name ("tv-effect");
Thread.usleep (2000);
}
}
2 changes: 2 additions & 0 deletions src/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ gala_bin_sources = files(
'Widgets/PixelPicker.vala',
'Widgets/PointerLocator.vala',
'Widgets/SessionLocker.vala',
'Widgets/Curtains/ShutdownCurtain.vala',
'Widgets/SelectionArea.vala',
'Widgets/WindowOverview.vala',
'Widgets/WindowSwitcher/WindowSwitcher.vala',
'Widgets/WindowSwitcher/WindowSwitcherIcon.vala',
'Curtains/TVEffect.vala'
)

gala_bin = executable(
Expand Down
Loading