Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
CSS vs Snapshot API in GTK4 - GeopJr

CSS vs Snapshot API in GTK4

A snapshot API hands-on guide

GTKVala
Posted on:

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:

Screenshot of the custom widget we just made. It's a bar-sized window showing 2 progressbars stacked on top of each other. The one in the foreground is 1/5th full and its color is neon green. The one in the background is 1/2 full and its color is neon red.

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  }

Screenrecording of the previous window and custom widget and GNOME settings. The user cycles through the accent colors in the settings apps and showcases that the bar in the foreground changes color to match the currently selected accent color.

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  }

Screenshot of the custom widget with the mouse hovering it. The following tooltip shows up "20% Primary. 50% Secondary."

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  }

Screenrecording of the final version of the widget. The bars change values every 500ms by 30%. When they reach one of the range limits they go the other way. The screenrecording showcases that the bars are animated and go from the previous value to the next one using ease-in-out.

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  }