CSS vs Snapshot API in GTK4
A snapshot API hands-on guide
Whenever I have to write a custom widget for GTK, I usually try to avoid writing too much boilerplate by abusing the CSS system. But that shouldn't be the case. GTK4's snapshot API is actually really fun and easy to use and so are libadwaita's animation APIs; allowing you to draw complex visuals really fast.
When is CSS a better choice?
GTK's CSS is very powerful and familiar to newcomers from web dev background. It cannot only do widget styling but it also supports complex child selectors, animations, CSS variables, some pseudo classes (hover, focus etc), gradients, images and theme hot reloading between light, dark and high contrast modes.
Most of these (not everything) can be done without CSS but it's usually much more verbose and requires more effort. For example, something as simple as the :hover
pseudo class, requires you to listen to Gtk.Widget#state_flags_changed
, get the current flags with Gtk.Widget#get_state_flags
and then check if they include Gtk.StateFlags.PRELIGHT
. Sometimes, CSS is the only way to style nested widgets of sealed classes.
One of the most important advantages of CSS in my opinion, apart from how powerful it is, is that it allows anyone to contribute. Designers don't need to be familiar with the codebase or even how to compile the app, they can just try their CSS changes in the GTK Inspector. Newcomers don't need to learn the snapshot API.
Additionally, it improves maintainability somewhat as CSS variables can be controlled by the platform (libadwaita) and will follow future design changes automatically.
With that said, let's get started on custom widgets and snapshotting!
Double progress bar with animations
We are going to build a progress bar similar to Gtk.ProgressBar
but with two stacked bars. The first step to writing custom widgets is planning:
API
The API will be much more limited than Gtk.ProgressBar
. We only need to display the two bars without any extra features like pulsing, text, steps etc. So the API should just be two float (double) properties one named primary
and one named secondary
that represent the bars and accept values of 0.0
to 1.0
.
1 public class DoubleProgress : Gtk.Widget, Gtk.Accessible {
2 private double _primary = 0;
3 public double primary {
4 get { return _primary; }
5 set {
6 var new_value = value.clamp (0.0, 1.0);
7 // only update the value and notify
8 // that it changed, when it actually
9 // did. This way we will avoid drawing
10 // for no reason.
11 if (_primary != new_value) {
12 _primary = new_value;
13 this.notify_property ("primary");
14 }
15 }
16 }
17
18 private double _secondary = 0;
19 public double secondary {
20 get { return _secondary; }
21 set {
22 var new_value = value.clamp (0.0, 1.0);
23 if (_secondary != new_value) {
24 _secondary = new_value;
25 this.notify_property ("secondary");
26 }
27 }
28 }
29 }
Drawing
Drawing works by overriding the widget's snapshot
function. With Gtk.Snapshot
you can programmatically draw shapes, stoke, apply blur, fading, draw bitmaps, cairo, rotate, transform and a lot more. Think of it as using a basic image manipulation and vector drawing program. You can use layers, draw shapes, import images, apply effects etc.
Let's break our widget down into shapes. We want a rectangle with one color drawn on top of another rectangle with another color. Each rectangle's width will be equal to the widget's multiplied by their progress property. We also need to make their corners rounded so they better match our design.
1 public override void snapshot (Gtk.Snapshot snapshot) {
2 // let's get the widget's dimensions
3 int width = this.get_width ();
4 int height = this.get_height ();
5
6 // We want to draw the secondary behind
7 // the primary, so we need to draw it first
8 Graphene.Rect secondary_bar = Graphene.Rect () {
9 // start location
10 // we want it to start at the top left
11 // so it's 0,0
12 origin = Graphene.Point () {
13 x = 0.0f,
14 y = 0.0f
15 },
16 // rectangle size
17 // height should fill the widget
18 // width should equal to the widget's
19 // multiplied by the progress
20 size = Graphene.Size () {
21 height = (float) height,
22 width = (float) width * (float) this.secondary
23 }
24 };
25
26 // same thing for the primary one
27 Graphene.Rect primary_bar = Graphene.Rect () {
28 origin = Graphene.Point () {
29 x = 0.0f,
30 y = 0.0f
31 },
32 size = Graphene.Size () {
33 height = (float) height,
34 width = (float) width * (float) this.primary
35 }
36 };
37
38 // corners for rounding
39 Graphene.Size non_rounded_corner = Graphene.Size () {
40 height = 0f,
41 width = 0f
42 };
43
44 Graphene.Size rounded_corner = Graphene.Size () {
45 height = 9999f,
46 width = 9999f
47 };
48
49 // let's push a rounded clip of the secondary_bar
50 // where the right corners are rounded
51 // think of this as making a layer or a mask
52 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (secondary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
53
54 // append the secondary layer, colored Red
55 snapshot.append_color (Gdk.RGBA () {
56 red = 1.0f,
57 green = 0.0f,
58 blue = 0.0f,
59 alpha = 1.0f
60 }, secondary_bar);
61
62 // now let's 'exit' or 'close' the rounded layer
63 snapshot.pop ();
64
65 // same for the primary
66 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (primary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
67 snapshot.append_color (Gdk.RGBA () {
68 red = 0.0f,
69 green = 1.0f,
70 blue = 0.0f,
71 alpha = 1.0f
72 }, primary_bar);
73 snapshot.pop ();
74
75 base.snapshot (snapshot);
76 }
It should now look like this:
Let's optimize it now. We have some static values and we know that we don't have to draw the secondary bar at all if its progress is less than the primary's as it will be hidden behind it:
1 Graphene.Point point = Graphene.Point () {
2 x = 0.0f,
3 y = 0.0f
4 };
5
6 Graphene.Size non_rounded_corner = Graphene.Size () {
7 height = 0f,
8 width = 0f
9 };
10
11 Graphene.Size rounded_corner = Graphene.Size () {
12 height = 9999f,
13 width = 9999f
14 };
15
16 Gdk.RGBA secondary_color = Gdk.RGBA () {
17 red = 1.0f,
18 green = 0.0f,
19 blue = 0.0f,
20 alpha = 1.0f
21 };
22
23 Gdk.RGBA primary_color = Gdk.RGBA () {
24 red = 0.0f,
25 green = 1.0f,
26 blue = 0.0f,
27 alpha = 1.0f
28 };
29
30 public override void snapshot (Gtk.Snapshot snapshot) {
31 int width = this.get_width ();
32 int height = this.get_height ();
33
34 if (secondary > primary) {
35 Graphene.Rect secondary_bar = Graphene.Rect () {
36 origin = point,
37 size = Graphene.Size () {
38 height = (float) height,
39 width = (float) width * (float) this.secondary
40 }
41 };
42
43 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (secondary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
44 snapshot.append_color (secondary_color, secondary_bar);
45 snapshot.pop ();
46 }
47
48 Graphene.Rect primary_bar = Graphene.Rect () {
49 origin = point,
50 size = Graphene.Size () {
51 height = (float) height,
52 width = (float) width * (float) this.primary
53 }
54 };
55
56 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (primary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
57 snapshot.append_color (primary_color, primary_bar);
58 snapshot.pop ();
59
60 base.snapshot (snapshot);
61 }
Last but not least, we have to update the properties so they re-draw when they change:
1 private double _primary = 0;
2 public double primary {
3 get { return _primary; }
4 set {
5 new_value = value.clamp (0.0, 1.0);
6 if (_primary != new_value) {
7 _primary = new_value;
8 this.notify_property ("primary");
9 this.queue_draw ();
10 }
11 }
12 }
13
14 private double _secondary = 0;
15 public double secondary {
16 get { return _secondary; }
17 set {
18 new_value = value.clamp (0.0, 1.0);
19 if (_secondary != new_value) {
20 _secondary = new_value;
21 this.notify_property ("secondary");
22 this.queue_draw ();
23 }
24 }
25 }
Accent color
We can't exactly access CSS variables easily and listen to changes from outside CSS, so we are going to use libadwaita's style manager and listen to changes to its accent-color property. We are also going to replace the local variable primary_color
with a property, so we re-draw the widget when the color changes:
1 // Default blue accent color
2 private Gdk.RGBA _primary_color = {
3 120 / 255.0f,
4 174 / 255.0f,
5 237 / 255.0f,
6 1f
7 };
8 private Gdk.RGBA primary_color {
9 get { return _primary_color; }
10 set {
11 if (value != _primary_color) {
12 _primary_color = value;
13 // redraw the widget
14 this.queue_draw ();
15 }
16 }
17 }
18
19 construct {
20 var default_sm = Adw.StyleManager.get_default ();
21 // if it supports accent colors
22 if (default_sm.system_supports_accent_colors) {
23 // listen to accent color changes
24 default_sm.notify["accent-color-rgba"].connect (update_accent_color);
25 // and update the private variable initially
26 // so it doesn't call queue_draw for no reason yet
27 _primary_color = default_sm.get_accent_color_rgba ();
28 }
29 }
30
31 private void update_accent_color () {
32 primary_color = Adw.StyleManager.get_default ().get_accent_color_rgba ();
33 }
Accessibility
This is a tricky one. At first thought, this should have the progress-bar
ARIA ROLE, but it can't really be described by that. The progress-bar
role can only announce one value. Considering it's used for presentation only, the role of the same name could do along with a label describing its values but for the sake of this example let's treat it as a label. We will introduce two additional properties, primary_title
and secondary_title
, used exclusively for the tooltips / aria label. We also need to update the aria label every time the progress does as well:
1 private double _primary = 0;
2 public double primary {
3 get { return _primary; }
4 set {
5 var new_value = value.clamp (0.0, 1.0);
6 if (_primary != new_value) {
7 _primary = new_value;
8 this.notify_property ("primary");
9 this.queue_draw ();
10 }
11
12 update_aria ();
13 }
14 }
15
16 private double _secondary = 0;
17 public double secondary {
18 get { return _secondary; }
19 set {
20 var new_value = value.clamp (0.0, 1.0);
21 if (_secondary != new_value) {
22 _secondary = new_value;
23 this.notify_property ("secondary");
24 this.queue_draw ();
25 }
26
27 update_aria ();
28 }
29 }
30
31 private string _primary_title = _("Primary");
32 public string primary_title {
33 get { return _primary_title; }
34 set {
35 if (value != _primary_title) {
36 _primary_title = value;
37 update_aria ();
38 }
39 }
40 }
41
42 private string _secondary_title = _("Secondary");
43 public string secondary_title {
44 get { return _secondary_title; }
45 set {
46 if (value != _secondary_title) {
47 _secondary_title = value;
48 update_aria ();
49 }
50 }
51 }
52
53 private void update_aria () {
54 // double => percent
55 string aria_string = _("%d%% %s. %d%% %s.").printf (
56 ((int) (this.primary * 100)).clamp (0, 100),
57 primary_title,
58 ((int) (this.secondary * 100)).clamp (0, 100),
59 secondary_title
60 );
61
62 this.tooltip_text = aria_string;
63 this.update_property (Gtk.AccessibleProperty.LABEL, aria_string, -1);
64 }
65
66 static construct {
67 set_accessible_role (Gtk.AccessibleRole.LABEL);
68 }
69
70 construct {
71 var default_sm = Adw.StyleManager.get_default ();
72 if (default_sm.system_supports_accent_colors) {
73 default_sm.notify["accent-color-rgba"].connect (update_accent_color);
74 _primary_color = default_sm.get_accent_color_rgba ();
75 }
76
77 // update it initially so it gets set
78 update_aria ();
79 }
Animation
We want an ease-in-out animation when either of the progress bar changes. For that we are going to use Adw.TimedAnimation
. The API is similar to our progress properties, so we are going to replace them with the animation's internally. We are also going to use two different Adw.TimedAnimation
, one for each bar, since they are not synced.
1 // animation duration in ms
2 const uint ANIMATION_DURATION = 500;
3 Adw.TimedAnimation secondary_animation;
4 Adw.TimedAnimation primary_animation;
5
6 private double _primary = 0;
7 public double primary {
8 get { return _primary; }
9 set {
10 var new_value = value.clamp (0.0, 1.0);
11 if (_primary != new_value) {
12 this.notify_property ("primary");
13
14 // The animation starts from the _primary value
15 // and stops at the new_value
16 primary_animation.value_from = _primary;
17 primary_animation.value_to = new_value;
18 primary_animation.play ();
19
20 // we no longer need to queue_draw here
21 // as the animation callback will do so
22 _primary = value;
23 }
24
25 update_aria ();
26 }
27 }
28
29 private double _secondary = 0;
30 public double secondary {
31 get { return _secondary; }
32 set {
33 var new_value = value.clamp (0.0, 1.0);
34 if (_secondary != new_value) {
35 this.notify_property ("secondary");
36
37 secondary_animation.value_from = _secondary;
38 secondary_animation.value_to = new_value;
39 secondary_animation.play ();
40
41 _secondary = new_value;
42 }
43
44 update_aria ();
45 }
46 }
47
48 // This is what gets animated.
49 // Since we draw the animation, we only need to
50 // call queue_draw
51 private void primary_animation_target_cb (double value) {
52 this.queue_draw ();
53 }
54
55 // Optimization by redrawing for the secondary
56 // bar animation, only when the secondary bar changes,
57 // we skip re-drawing the primary one for no reason
58 private void secondary_animation_target_cb (double value) {
59 if (this.secondary_animation.value > this.primary_animation.value) return;
60 this.queue_draw ();
61 }
62
63 construct {
64 var default_sm = Adw.StyleManager.get_default ();
65 if (default_sm.system_supports_accent_colors) {
66 default_sm.notify["accent-color-rgba"].connect (update_accent_color);
67 _primary_color = default_sm.get_accent_color_rgba ();
68 }
69
70 // Animations will range from 0.0 to 1.0, matching our progress bars'
71 // values.
72 var target = new Adw.CallbackAnimationTarget (secondary_animation_target_cb);
73 secondary_animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, target) {
74 easing = Adw.Easing.EASE_IN_OUT_QUART
75 };
76
77 var target_primary = new Adw.CallbackAnimationTarget (primary_animation_target_cb);
78 primary_animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, target_primary) {
79 easing = Adw.Easing.EASE_IN_OUT_QUART
80 };
81
82 update_aria ();
83 }
84
85 public override void snapshot (Gtk.Snapshot snapshot) {
86 int width = this.get_width ();
87 int height = this.get_height ();
88
89 // same as before, but this time we are going to use the
90 // animation values instead of our properties
91 if (this.secondary_animation.value > this.primary_animation.value) {
92 Graphene.Rect secondary_bar = Graphene.Rect () {
93 origin = point,
94 size = Graphene.Size () {
95 height = (float) height,
96 width = (float) width * (float) this.secondary_animation.value
97 }
98 };
99
100 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (secondary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
101 snapshot.append_color (secondary_color, secondary_bar);
102 snapshot.pop ();
103 }
104
105 Graphene.Rect primary_bar = Graphene.Rect () {
106 origin = point,
107 size = Graphene.Size () {
108 height = (float) height,
109 // here too
110 width = (float) width * (float) this.primary_animation.value
111 }
112 };
113
114 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (primary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
115 snapshot.append_color (primary_color, primary_bar);
116 snapshot.pop ();
117
118 base.snapshot (snapshot);
119 }
Closing Notes
With the above, we went from 0 to a custom animated accessible widget that follows the system accent colors. This wasn't meant to be a step-by-step guide and assumes some knowledge but it's aimed for people who already have some experience with GTK and are getting into the snapshot API.
Here's the full code:
1 public class DoubleProgress : Gtk.Widget, Gtk.Accessible {
2 const uint ANIMATION_DURATION = 500;
3 Adw.TimedAnimation secondary_animation;
4 Adw.TimedAnimation primary_animation;
5
6 private double _primary = 0;
7 public double primary {
8 get { return _primary; }
9 set {
10 var new_value = value.clamp (0.0, 1.0);
11 if (_primary != new_value) {
12 this.notify_property ("primary");
13
14 primary_animation.value_from = _primary;
15 primary_animation.value_to = new_value;
16 primary_animation.play ();
17
18 _primary = value;
19 }
20
21 update_aria ();
22 }
23 }
24
25 private double _secondary = 0;
26 public double secondary {
27 get { return _secondary; }
28 set {
29 var new_value = value.clamp (0.0, 1.0);
30 if (_secondary != new_value) {
31 this.notify_property ("secondary");
32
33 secondary_animation.value_from = _secondary;
34 secondary_animation.value_to = new_value;
35 secondary_animation.play ();
36
37 _secondary = new_value;
38 }
39
40 update_aria ();
41 }
42 }
43
44 private Gdk.RGBA _primary_color = {
45 120 / 255.0f,
46 174 / 255.0f,
47 237 / 255.0f,
48 1f
49 };
50 private Gdk.RGBA primary_color {
51 get { return _primary_color; }
52 set {
53 if (value != _primary_color) {
54 _primary_color = value;
55 this.queue_draw ();
56 }
57 }
58 }
59
60 private string _primary_title = "Primary";
61 public string primary_title {
62 get { return _primary_title; }
63 set {
64 if (value != _primary_title) {
65 _primary_title = value;
66 update_aria ();
67 }
68 }
69 }
70
71 private string _secondary_title = "Secondary";
72 public string secondary_title {
73 get { return _secondary_title; }
74 set {
75 if (value != _secondary_title) {
76 _secondary_title = value;
77 update_aria ();
78 }
79 }
80 }
81
82 private void update_aria () {
83 string aria_string = "%d%% %s. %d%% %s.".printf (
84 ((int) (this.primary * 100)).clamp (0, 100),
85 primary_title,
86 ((int) (this.secondary * 100)).clamp (0, 100),
87 secondary_title
88 );
89
90 this.tooltip_text = aria_string;
91 this.update_property (Gtk.AccessibleProperty.LABEL, aria_string, -1);
92 }
93
94 static construct {
95 set_accessible_role (Gtk.AccessibleRole.LABEL);
96 }
97
98 private void primary_animation_target_cb (double value) {
99 this.queue_draw ();
100 }
101
102 private void secondary_animation_target_cb (double value) {
103 if (this.secondary_animation.value > this.primary_animation.value) return;
104 this.queue_draw ();
105 }
106
107 construct {
108 var default_sm = Adw.StyleManager.get_default ();
109 if (default_sm.system_supports_accent_colors) {
110 default_sm.notify["accent-color-rgba"].connect (update_accent_color);
111 _primary_color = default_sm.get_accent_color_rgba ();
112 }
113
114 var target = new Adw.CallbackAnimationTarget (secondary_animation_target_cb);
115 secondary_animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, target) {
116 easing = Adw.Easing.EASE_IN_OUT_QUART
117 };
118
119 var target_primary = new Adw.CallbackAnimationTarget (primary_animation_target_cb);
120 primary_animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, target_primary) {
121 easing = Adw.Easing.EASE_IN_OUT_QUART
122 };
123
124 update_aria ();
125 }
126
127 private void update_accent_color () {
128 primary_color = Adw.StyleManager.get_default ().get_accent_color_rgba ();
129 }
130
131 Graphene.Point point = Graphene.Point () {
132 x = 0.0f,
133 y = 0.0f
134 };
135
136 Graphene.Size non_rounded_corner = Graphene.Size () {
137 height = 0f,
138 width = 0f
139 };
140
141 Graphene.Size rounded_corner = Graphene.Size () {
142 height = 9999f,
143 width = 9999f
144 };
145
146 Gdk.RGBA secondary_color = Gdk.RGBA () {
147 red = 1.0f,
148 green = 0.0f,
149 blue = 0.0f,
150 alpha = 1.0f
151 };
152
153 public override void snapshot (Gtk.Snapshot snapshot) {
154 int width = this.get_width ();
155 int height = this.get_height ();
156
157 if (this.secondary_animation.value > this.primary_animation.value) {
158 Graphene.Rect secondary_bar = Graphene.Rect () {
159 origin = point,
160 size = Graphene.Size () {
161 height = (float) height,
162 width = (float) width * (float) this.secondary_animation.value
163 }
164 };
165
166 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (secondary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
167 snapshot.append_color (secondary_color, secondary_bar);
168 snapshot.pop ();
169 }
170
171 Graphene.Rect primary_bar = Graphene.Rect () {
172 origin = point,
173 size = Graphene.Size () {
174 height = (float) height,
175 width = (float) width * (float) this.primary_animation.value
176 }
177 };
178
179 snapshot.push_rounded_clip (Gsk.RoundedRect ().init (primary_bar, non_rounded_corner, rounded_corner, rounded_corner, non_rounded_corner));
180 snapshot.append_color (primary_color, primary_bar);
181 snapshot.pop ();
182
183 base.snapshot (snapshot);
184 }
185 }