diff --git a/data/gala.gresource.xml b/data/gala.gresource.xml index cc9d395a..727942e9 100644 --- a/data/gala.gresource.xml +++ b/data/gala.gresource.xml @@ -22,5 +22,7 @@ gala.css + shaders/colorblindness-correction.vert + shaders/monochrome.vert diff --git a/data/gala.gschema.xml b/data/gala.gschema.xml index ee21b517..d502591e 100644 --- a/data/gala.gschema.xml +++ b/data/gala.gschema.xml @@ -394,4 +394,38 @@ The action that corresponds to performing a horizontal swipe gesture with four fingers + + + + + + + + + + + + + "none" + Colorblind correction filter. + + + + + 1.0 + The strength of colorblindness correction filter. + + + + false + Enable monochrome filter. + + + + + 1.0 + The strength of monochrome filter. + + + diff --git a/data/shaders/colorblindness-correction.vert b/data/shaders/colorblindness-correction.vert new file mode 100644 index 00000000..e4da573e --- /dev/null +++ b/data/shaders/colorblindness-correction.vert @@ -0,0 +1,90 @@ +/* + * Copyright 2022-2023 GdH + * Copyright 2023 elementary, Inc. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + + +uniform sampler2D tex; +uniform int COLORBLIND_MODE; +uniform float STRENGTH; +void main() { + vec4 c = texture2D(tex, cogl_tex_coord0_in.xy); + + // RGB to LMS matrix + float L = (17.8824f * c.r) + (43.5161f * c.g) + (4.11935f * c.b); + float M = (3.45565f * c.r) + (27.1554f * c.g) + (3.86714f * c.b); + float S = (0.0299566f * c.r) + (0.184309f * c.g) + (1.46709f * c.b); + + float l, m, s; + + // Remove invisible colors + if ( COLORBLIND_MODE == 1 || COLORBLIND_MODE == 2) { // Protanopia - reds are greatly reduced + l = 0.0f * L + 2.02344f * M + -2.52581f * S; + m = 0.0f * L + 1.0f * M + 0.0f * S; + s = 0.0f * L + 0.0f * M + 1.0f * S; + } else if ( COLORBLIND_MODE == 3 || COLORBLIND_MODE == 4) { // Deuteranopia - greens are greatly reduced + l = 1.0f * L + 0.0f * M + 0.0f * S; + m = 0.494207f * L + 0.0f * M + 1.24827f * S; + s = 0.0f * L + 0.0f * M + 1.0f * S; + } else if ( COLORBLIND_MODE == 5 ) { // Tritanopia - blues are greatly reduced (1 of 10 000) + l = 1.0f * L + 0.0f * M + 0.0f * S; + m = 0.0f * L + 1.0f * M + 0.0f * S; + // GdH - trinatopia vector calculated by me, all public sources were off + s = -0.012491378299329402f * L + 0.07203451899279534f * M + 0.0f * S; + } + + // LMS to RGB matrix conversion + vec4 error; + error.r = (0.0809444479f * l) + (-0.130504409f * m) + (0.116721066f * s); + error.g = (-0.0102485335f * l) + (0.0540193266f * m) + (-0.113614708f * s); + error.b = (-0.000365296938f * l) + (-0.00412161469f * m) + (0.693511405f * s); + // The error is what they see + + // ratio between original and error colors allows adjusting filter for weaker forms of dichromacy + error = error * STRENGTH + c * (1.0 - STRENGTH); + error.a = 1.0; + + // Isolate invisible colors to color vision deficiency (calculate error matrix) + error = (c - error); + + // Shift colors + vec4 correction; + // protanopia / protanomaly corrections + if ( COLORBLIND_MODE == 1 ) { + //(kwin effect values) + correction.r = error.r * 0.56667 + error.g * 0.43333 + error.b * 0.00000; + correction.g = error.r * 0.55833 + error.g * 0.44267 + error.b * 0.00000; + correction.b = error.r * 0.00000 + error.g * 0.24167 + error.b * 0.75833; + // tries to mimic Android, GdH + //correction.r = error.r * -0.5 + error.g * -0.3 + error.b * 0.0; + //correction.g = error.r * 0.2 + error.g * 0.0 + error.b * 0.0; + //correction.b = error.r * 0.2 + error.g * 1.0 + error.b * 1.0; + // protanopia / protanomaly high contrast G-R corrections + } else if ( COLORBLIND_MODE == 2 ) { + correction.r = error.r * 2.56667 + error.g * 0.43333 + error.b * 0.00000; + correction.g = error.r * 1.55833 + error.g * 0.44267 + error.b * 0.00000; + correction.b = error.r * 0.00000 + error.g * 0.24167 + error.b * 0.75833; + // deuteranopia / deuteranomaly corrections (tries to mimic Android, GdH) + } else if ( COLORBLIND_MODE == 3 ) { + correction.r = error.r * -0.7 + error.g * 0.0 + error.b * 0.0; + correction.g = error.r * 0.5 + error.g * 1.0 + error.b * 0.0; + correction.b = error.r * -0.3 + error.g * 0.0 + error.b * 1.0; + // deuteranopia / deuteranomaly high contrast R-G corrections + } else if ( COLORBLIND_MODE == 4 ) { + correction.r = error.r * -1.5 + error.g * 1.5 + error.b * 0.0; + correction.g = error.r * -1.5 + error.g * 1.5 + error.b * 0.0; + correction.b = error.r * 1.5 + error.g * 0.0 + error.b * 0.0; + // tritanopia / tritanomaly corrections (GdH) + } else if ( COLORBLIND_MODE == 5 ) { + correction.r = error.r * 0.3 + error.g * 0.5 + error.b * 0.4; + correction.g = error.r * 0.5 + error.g * 0.7 + error.b * 0.3; + correction.b = error.r * 0.0 + error.g * 0.0 + error.b * 1.0; + } + + // Add compensation to original values + correction = c + correction; + correction.a = c.a; + + cogl_color_out = correction; +} diff --git a/data/shaders/monochrome.vert b/data/shaders/monochrome.vert new file mode 100644 index 00000000..f1792937 --- /dev/null +++ b/data/shaders/monochrome.vert @@ -0,0 +1,19 @@ +/* + * Copyright 2023 elementary, Inc. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +uniform sampler2D tex; +uniform float STRENGTH; +void main() { + vec2 uv = cogl_tex_coord0_in.xy; + vec4 sample = texture2D (tex, uv); + vec3 luminance = vec3 (0.2126, 0.7512, 0.0722); + float gray = luminance.r * sample.r + luminance.g * sample.g + luminance.b * sample.b; + cogl_color_out = vec4 ( + sample.r * (1.0 - STRENGTH) + gray * STRENGTH, + sample.g * (1.0 - STRENGTH) + gray * STRENGTH, + sample.b * (1.0 - STRENGTH) + gray * STRENGTH, + sample.a + ) ; +} diff --git a/src/ColorFilters/ColorblindnessCorrectionEffect.vala b/src/ColorFilters/ColorblindnessCorrectionEffect.vala new file mode 100644 index 00000000..8ed14618 --- /dev/null +++ b/src/ColorFilters/ColorblindnessCorrectionEffect.vala @@ -0,0 +1,47 @@ +/* + * Copyright 2023 elementary, Inc. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +public class Gala.ColorblindnessCorrectionEffect : Clutter.ShaderEffect { + public const string EFFECT_NAME = "colorblindness-correction-filter"; + + private int _mode; + public int mode { + get { return _mode; } + construct set { + _mode = value; + set_uniform_value ("COLORBLIND_MODE", _mode); + } + } + private double _strength; + public double strength { + get { return _strength; } + construct set { + _strength = value; + + set_uniform_value ("STRENGTH", value); + queue_repaint (); + } + } + + /* + * Used for fading in and out the effect, since you can't add transitions to effects. + */ + public Clutter.Actor? transition_actor { get; set; default = null; } + + public ColorblindnessCorrectionEffect (int mode, double strength) { + Object ( + shader_type: Clutter.ShaderType.FRAGMENT_SHADER, + mode: mode, + strength: strength + ); + + try { + var bytes = GLib.resources_lookup_data ("/io/elementary/desktop/gala/shaders/colorblindness-correction.vert", GLib.ResourceLookupFlags.NONE); + set_shader_source ((string) bytes.get_data ()); + } catch (Error e) { + critical ("Unable to load colorblindness-correction.vert: %s", e.message); + } + } +} diff --git a/src/ColorFilters/FilterManager.vala b/src/ColorFilters/FilterManager.vala new file mode 100644 index 00000000..d3eda7ed --- /dev/null +++ b/src/ColorFilters/FilterManager.vala @@ -0,0 +1,293 @@ +/* + * Copyright 2023 elementary, Inc. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +public class Gala.FilterManager : Object { + private const string TRANSITION_NAME = "strength-transition"; + private const int TRANSITION_DURATION = 500; + + private static FilterManager instance; + private static GLib.Settings settings; + public WindowManager wm { get; construct; } + + public static void init (WindowManager wm) { + if (instance != null) { + return; + } + + instance = new FilterManager (wm); + } + + private FilterManager (WindowManager wm) { + Object (wm: wm); + } + + static construct { + settings = new GLib.Settings ("io.elementary.desktop.wm.accessibility"); + } + + construct { + settings.changed["colorblindness-correction-filter"].connect (update_colorblindness_filter); + settings.changed["colorblindness-correction-filter-strength"].connect (update_colorblindness_strength); + settings.changed["enable-monochrome-filter"].connect (update_monochrome_filter); + settings.changed["monochrome-filter-strength"].connect (update_monochrome_strength); + + load_filters (); + } + + private void load_filters () { + load_colorblindness_filter (); + load_monochrome_filter (); + } + + private void load_colorblindness_filter () { + // When gala launches and there is an effect active, it shouldn't be faded in + var filter_variant = settings.get_enum ("colorblindness-correction-filter"); + var strength = settings.get_double ("colorblindness-correction-filter-strength"); + if (filter_variant != 0 && strength > 0.0) { + wm.stage.add_effect_with_name ( + ColorblindnessCorrectionEffect.EFFECT_NAME, + new ColorblindnessCorrectionEffect (filter_variant, strength) + ); + } + } + + private void update_colorblindness_filter () { + var filter_variant = settings.get_enum ("colorblindness-correction-filter"); + var strength = settings.get_double ("colorblindness-correction-filter-strength"); + + // Fade out applied effects + foreach (unowned var _effect in wm.stage.get_effects ()) { + if (_effect is ColorblindnessCorrectionEffect) { + unowned var effect = (ColorblindnessCorrectionEffect) _effect; + + if (effect.mode == filter_variant) { + continue; + } + + // Since you can't add a transition to an effect + // add it to a transition actor and bind one of its properties to the effect + + // stop transition (if there is one in progress) + if (effect.transition_actor != null) { + effect.transition_actor.destroy (); + } + + // create a new transition + var transition = new Clutter.PropertyTransition ("scale_x") { + duration = TRANSITION_DURATION, + progress_mode = Clutter.AnimationMode.LINEAR, + remove_on_complete = true + }; + transition.set_from_value (effect.strength); + transition.set_to_value (0.0); + + // create a transition actor and bind its `scale_x` to effect's `strength` + effect.transition_actor = new Clutter.Actor () { + visible = false + }; + wm.ui_group.add_child (effect.transition_actor); + effect.transition_actor.bind_property ("scale_x", effect, "strength"); + + transition.completed.connect (() => { + effect.transition_actor.destroy (); + wm.stage.remove_effect (effect); + }); + + effect.transition_actor.add_transition (TRANSITION_NAME, transition); + } + } + + // Apply a new filter + if (filter_variant == 0 || strength == 0.0) { + return; + } + + var new_effect = new ColorblindnessCorrectionEffect (filter_variant, 0.0); + wm.stage.add_effect_with_name (ColorblindnessCorrectionEffect.EFFECT_NAME, new_effect); + + // Transition new effect in the same way + var new_transition = new Clutter.PropertyTransition ("scale_x") { + duration = TRANSITION_DURATION, + progress_mode = Clutter.AnimationMode.LINEAR, + remove_on_complete = true + }; + new_transition.set_from_value (0.0); + new_transition.set_to_value (strength); + + new_effect.transition_actor = new Clutter.Actor () { + visible = false + }; + wm.ui_group.add_child (new_effect.transition_actor); + new_effect.transition_actor.bind_property ("scale_x", new_effect, "strength"); + + new_transition.completed.connect (() => { + new_effect.transition_actor.destroy (); + }); + + new_effect.transition_actor.add_transition (TRANSITION_NAME, new_transition); + } + + private void update_colorblindness_strength () { + var filter_variant = settings.get_enum ("colorblindness-correction-filter"); + var strength = settings.get_double ("colorblindness-correction-filter-strength"); + + foreach (unowned var _effect in wm.stage.get_effects ()) { + if (_effect is ColorblindnessCorrectionEffect) { + unowned var effect = (ColorblindnessCorrectionEffect) _effect; + + if (effect.mode != filter_variant) { + continue; + } + + // stop transition (if there is one in progress) + if (effect.transition_actor != null) { + effect.transition_actor.destroy (); + } + + // create a new transition + var transition = new Clutter.PropertyTransition ("scale_x") { + duration = TRANSITION_DURATION, + progress_mode = Clutter.AnimationMode.LINEAR, + remove_on_complete = true + }; + transition.set_from_value (effect.strength); + transition.set_to_value (strength); + + // create a transition actor and bind its `scale_x` to effect's `strength` + effect.transition_actor = new Clutter.Actor () { + visible = false + }; + wm.ui_group.add_child (effect.transition_actor); + effect.transition_actor.bind_property ("scale_x", effect, "strength"); + + transition.completed.connect (() => { + effect.transition_actor.destroy (); + }); + + effect.transition_actor.add_transition (TRANSITION_NAME, transition); + + return; + } + } + } + + private void load_monochrome_filter () { + // When gala launches and there is an effect active, it shouldn't be faded in + var enable = settings.get_boolean ("enable-monochrome-filter"); + var strength = settings.get_double ("monochrome-filter-strength"); + if (enable && strength > 0.0) { + wm.stage.add_effect_with_name ( + MonochromeEffect.EFFECT_NAME, + new MonochromeEffect (strength) + ); + } + } + + private void update_monochrome_filter () { + var enabled = settings.get_boolean ("enable-monochrome-filter"); + var strength = settings.get_double ("monochrome-filter-strength"); + unowned var effect = (MonochromeEffect) wm.stage.get_effect (MonochromeEffect.EFFECT_NAME); + + if ((effect != null) == enabled) { + return; + } + + // Fade out applied effects + if (effect != null) { + // Since you can't add a transition to an effect + // add it to a transition actor and bind one of its properties to the effect + + // stop transition (if there is one in progress) + if (effect.transition_actor != null) { + effect.transition_actor.destroy (); + } + + // create a new transition + var transition = new Clutter.PropertyTransition ("scale_x") { + duration = TRANSITION_DURATION, + progress_mode = Clutter.AnimationMode.LINEAR, + remove_on_complete = true + }; + transition.set_from_value (effect.strength); + transition.set_to_value (0.0); + + // create a transition actor and bind its `scale_x` to effect's `strength` + effect.transition_actor = new Clutter.Actor () { + visible = false + }; + wm.ui_group.add_child (effect.transition_actor); + effect.transition_actor.bind_property ("scale_x", effect, "strength"); + + transition.completed.connect (() => { + effect.transition_actor.destroy (); + wm.stage.remove_effect (effect); + }); + + effect.transition_actor.add_transition (TRANSITION_NAME, transition); + } + + // Apply a new filter + if (!enabled || strength == 0.0) { + return; + } + + var new_effect = new MonochromeEffect (0.0); + wm.stage.add_effect_with_name (MonochromeEffect.EFFECT_NAME, new_effect); + + // Transition new effect in the same way + var new_transition = new Clutter.PropertyTransition ("scale_x") { + duration = TRANSITION_DURATION, + progress_mode = Clutter.AnimationMode.LINEAR, + remove_on_complete = true + }; + new_transition.set_from_value (0.0); + new_transition.set_to_value (strength); + + new_effect.transition_actor = new Clutter.Actor () { + visible = false + }; + wm.ui_group.add_child (new_effect.transition_actor); + new_effect.transition_actor.bind_property ("scale_x", new_effect, "strength"); + + new_transition.completed.connect (() => { + new_effect.transition_actor.destroy (); + }); + + new_effect.transition_actor.add_transition (TRANSITION_NAME, new_transition); + } + + private void update_monochrome_strength () { + var strength = settings.get_double ("monochrome-filter-strength"); + + unowned var effect = (MonochromeEffect) wm.stage.get_effect (MonochromeEffect.EFFECT_NAME); + + // stop transition (if there is one in progress) + if (effect.transition_actor != null) { + effect.transition_actor.destroy (); + } + + // create a new transition + var transition = new Clutter.PropertyTransition ("scale_x") { + duration = TRANSITION_DURATION, + progress_mode = Clutter.AnimationMode.LINEAR, + remove_on_complete = true + }; + transition.set_from_value (effect.strength); + transition.set_to_value (strength); + + // create a transition actor and bind its `scale_x` to effect's `strength` + effect.transition_actor = new Clutter.Actor () { + visible = false + }; + wm.ui_group.add_child (effect.transition_actor); + effect.transition_actor.bind_property ("scale_x", effect, "strength"); + + transition.completed.connect (() => { + effect.transition_actor.destroy (); + }); + + effect.transition_actor.add_transition (TRANSITION_NAME, transition); + } +} diff --git a/src/ColorFilters/MonochromeEffect.vala b/src/ColorFilters/MonochromeEffect.vala new file mode 100644 index 00000000..6be200e5 --- /dev/null +++ b/src/ColorFilters/MonochromeEffect.vala @@ -0,0 +1,38 @@ +/* + * Copyright 2023 elementary, Inc. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +public class Gala.MonochromeEffect : Clutter.ShaderEffect { + public const string EFFECT_NAME = "monochrome-filter"; + + private double _strength; + public double strength { + get { return _strength; } + construct set { + _strength = value; + + set_uniform_value ("STRENGTH", value); + queue_repaint (); + } + } + + /* + * Used for fading in and out the effect, since you can't add transitions to effects. + */ + public Clutter.Actor? transition_actor { get; set; default = null; } + + public MonochromeEffect (double strength) { + Object ( + shader_type: Clutter.ShaderType.FRAGMENT_SHADER, + strength: strength + ); + + try { + var bytes = GLib.resources_lookup_data ("/io/elementary/desktop/gala/shaders/monochrome.vert", GLib.ResourceLookupFlags.NONE); + set_shader_source ((string) bytes.get_data ()); + } catch (Error e) { + critical ("Unable to load monochrome.vert: %s", e.message); + } + } +} diff --git a/src/WindowManager.vala b/src/WindowManager.vala index 75eec501..c1c6e247 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -242,6 +242,8 @@ namespace Gala { stage.remove_child (top_window_group); ui_group.add_child (top_window_group); + FilterManager.init (this); + /*keybindings*/ var keybinding_settings = new GLib.Settings (Config.SCHEMA + ".keybindings"); diff --git a/src/meson.build b/src/meson.build index 11abcd11..6177a429 100644 --- a/src/meson.build +++ b/src/meson.build @@ -29,6 +29,9 @@ gala_bin_sources = files( 'Background/BackgroundManager.vala', 'Background/BackgroundSource.vala', 'Background/SystemBackground.vala', + 'ColorFilters/ColorblindnessCorrectionEffect.vala', + 'ColorFilters/FilterManager.vala', + 'ColorFilters/MonochromeEffect.vala', 'Gestures/Gesture.vala', 'Gestures/GestureSettings.vala', 'Gestures/GestureTracker.vala',