CSS Happenings

This cycle GTK got a lot of updates to its CSS engine.

I started this work as part of the Sovereign Tech Fund initiative, and later on Matthias Clasen joined in and we ended up splitting the work on colors.

Let’s go through the changes, as well as how they affect GTK, libadwaita and apps.

Variables

The most notable addition is CSS variables, more correctly known as custom properties.

Since before GTK switched to CSS for styles, it has had named colors, a non-standard addition providing a similar functionality. You could define a color, then refer to it by name. Unfortunately, they had big limitations. First, they are global. You can only define colors for the whole stylesheet, and if you want to override them for a single widget subtree – you’re out of luck. Second, they were obviously only for colors.

The only option to have them local to a widget was to use gtk_style_context_add_provider() on a GtkStyleContext obtained via gtk_widget_get_style_context. However, it had an even bigger problem: being local to a widget, it didn’t propagate to children, which made it practically useless. Even for widgets that seemingly don’t have children: for example, if you add a provider to a GtkEntry, it won’t affect its text, or the icons, or the progress bar – they are all separate widgets within the entry. So, it shouldn’t be a big surprise that this API is deprecated.

Variables, meanwhile, don’t have any of these limitations. They can be defined anywhere, propagate to children, and they can be anything – colors, numbers, dimensions, etc. For example, we can override them for a specific widget as follows:

:root {
  --some-color: red;
}

my-widget {
  --some-color: green;
}

After defining, they can be accessed with the var() function:

my-widget {
  color: var(--some-color);
}

This function also allows to specify a fallback in case a variable doesn’t exist, as follows:

my-widget {
  color: var(--some-color, var(--some-other-color, red));
}

Here it will try, in order, --some-color, then --some-other-color, and if neither exists, red.

The standard place to declare global variables is the :root selector, and so it’s now supported too. While GTK root widgets usually have the window selector, that’s not always the case. For example, GtkCheckButton within a GtkTreeView are their own toplevels and targeting window would not include them. While tree view is deprecated and hopefully on its way out, we have to support it anyway for now, and who knows what other root widgets appear in future, and having :root solves it all nicely.

Variables can even be animated, though that’s not particularly useful: since they can be anything and you may potentially be animating red into 2px, the only way to interpolate them is to have a jump from the initial value to final value at 50%. Either way, the spec allows that and we implement it. You can still use them within another property’s value and animate that property instead – that will work as expected.

There are some things we don’t support at the moment, like the @property at-rule. It allows to declare variable types, optionally prevent inheriting, and specify the default value. Having a type not only provides type checking (and hence more informative error messages), but also allows to actually interpolate variables in animations. See, if a variable can be anything, we can’t interpolate it other than with a jump in the middle. But if we can guarantee it’s a color, or a dimension, or a number, or whatever, at every keyframe, then we can. But, while that would be neat to have, it’s really niche compared to having variables themselves, and so not very important.

Another thing is that it’s not possible to use variables within named colors, like this:

@define-color my_color var(--something); /* this is an error */

Since named colors will be going away in future, that’s not a big deal, but it is worth mentioning anyway. The other way around does work though:

@define-color something red;

:root {
  --my-color: @something; /* this is perfectly fine */
}

(and that’s fairly important, as it allows us to switch to variables without breaking backwards compatibility)

Colors

Next, colors. A lot of features from CSS Color Module Level 4 and Level 5 were implemented as well.

Modern syntax

In the past, CSS has used the following syntax for defining colors:

rgb(255, 0, 0)
rgba(255, 0, 0, 0.5)
hsl(0, 100%, 50%)
hsla(0, 100%, 50%, 0.5)

That’s still supported, but modern CSS also has a simpler and more flexible syntax:

rgb(255 0 0)
rgb(255 0 0 / 50%)
hsl(0 100 50)
hsl(0 100 50 / 50%)

It allows to freely mix percentages and numbers, and makes alpha an optional parameter separated by solidus instead of having separate functions. And now GTK supports it too.

Modern syntax also supports specifying missing components, for example: hsl(none 100% 50%). In most situations they behave same as 0, but they make a difference for interpolation, so we’ll talk about that in more details later.

Additionally, it supports specifying hue in hsl() as an angle instead of a number (meaning that it’s possible to use units like turn instead of degrees), as well as calc() anywhere within these functions:

hsl(calc(0.5turn - 10deg) 100% 50% / calc(1 / 2))

More color spaces

GTK also supports a bunch more color spaces now, all using the modern syntax:

Color space CSS
Linear sRGB color(srgb-linear 1 0 0)
HWB hwb(0deg 0 0)
Oklab oklab(62.8% 0.22 0.13)
Oklch oklch(62.8% 0.25 29)

    I won’t be describing the color spaces in detail, but that information can be easily found online. For example, Oklab and Oklch are very well described in their creator’s blog post.

    color() also supports sRGB, but it works same as rgb(), except the channels have 0-1 range instead of 0-255.

    color() in the spec supports a lot more color spaces, for example Display P3, but since we don’t have HDR support in GTK just yet, supporting them wouldn’t be of much use at the moment, so that’s omitted. Also omitted are Lab, LCh and various XYZ color spaces for now. Oklab/Oklch work better than Lab/LCh for UI colors anyway, and XYZ is fairly niche and not widely used in this context.

    However, just defining colors in different color spaces isn’t that interesting to me. What’s more interesting is deriving new colors from from existing ones in those color spaces, so let’s look at that.

    Color mixing

    First, we have support for the color-mix() function. While GTK has had a non-standard mix() function for more than a decade, color-mix() is not only standard CSS, but also a whole lot more flexible. Most important is the fact it allows to mix colors in different color spaces instead of just sRGB – all of the ones mentioned above:

    color-mix(in oklch, red, green)
    

    For HSL, HWB and Oklch, it’s possible to specify the hue interpolation mode too – for example, color-mix(in hsl longer hue, red, green).

    color-mix() also allows more sophisticated mixing via missing components. They allow some channels to be taken from one of the colors without taking the other one into account at all. For example, the following mix:

    color-mix(in srgb, rgb(100% none 100%), rgb(none 50% 0))
    

    takes the red channel from the first color, the green channel from the second color, and mixes the blue channel from both colors, resulting in the following color: color(srgb 1 0.5 0.5).

    For comparison, the same mix but with none replaced by 0:

    color-mix(in srgb, rgb(100% 0 100%), rgb(0 50% 0))
    

    mixes every channel and produces color(srgb 0.5 0.25 0.5).

    While mix() specifies a single fraction for the mix, color-mix() specifies two percentages. Usually they are normalized so that they add up to 100%, but if their sum is lower than that, it’s used as an alpha multiplier, allowing to add transparency to the mix:

    color-mix(in srgb, red 30%, blue 20%)
    /* color(srgb 0.6 0 0.4 / 0.5) */
    

    Percentages are optional though. When one is omitted, it’s assumed to be the other one subtracted from 100%. When both are omitted, they are assumed to be 50%/50%.

    Relative colors

    GTK now also supports relative colors. Unlike mixing, these take one color and change its individual channels. For example, we can create a complementary color by inverting the hue:

    hsl(from var(--some-color) calc(h + 0.5turn) s l)
    

    Change a color’s lightness to a given value:

    oklab(from var(--some-color) 0.9 a b)
    

    Or add transparency to a color, like the SASS transparentize() function:

    rgb(from var(--some-color) r g b / calc(alpha - 25%))
    

    Combined with calc() and variables, this is a very flexible system, and I’m really curious to see how it will get used in future.

    Math functions

    There’s also support for a lot of math functions:

    • min(), max(), clamp()
    • round()
    • rem()
    • mod()
    • sin(), cos(), tan(), asin(), acos(), atan(), atan2()
    • pow(), sqrt(), hypot(), log(), exp()
    • abs(), sign()
    • e, pi, infinity, NaN

    They can also be used in calc(), of course. I already found an interesting use case for two of them in libadwaita.

    Misc changes

    The opacity property can now accept percentages in addition to numbers. This wouldn’t matter much, but it means it can accept the same values as color-mix().

    Now let’s look at other changes all of this allowed.

    GTK deprecations

    GTK has a bunch of non-standard additions to CSS, related to colors: most notably, @define-color and named colors, but also the alpha(), mix(), shade(), lighter() and darker() functions. They allow to manipulate colors. Some are more useful than others, but, for example, alpha() is used extensively in libadwaita and modern GNOME apps – especially in combination with currentColor. For example, libadwaita buttons have the following background color: alpha(currentColor, 0.1); and can work with both light and dark background – they just follow the text color.

    As useful as they are, CSS now has standard ways to replace every single one of them, and apps are encouraged to do so.

    Named colors

    @define-color and named colors can be replaced with variables. For example, this snippet:

    @define-color my_color red;
    
    my-widget {
      color: @my_color;
    }
    

    becomes:

    :root {
      --my-color: red;
    }
    
    my-widget {
      color: var(--my-color);
    }
    

    mix()

    This is straightforward. color-mix() works exactly same when using the sRGB color space.

    /* mix(red, blue, .3) */
    color-mix(in srgb, red 30%, blue)
    

    alpha()

    There are multiple ways to replace it. Both color-mix() and relative colors will do the job:

    /* alpha(currentColor, 0.15); */
    color-mix(in srgb, currentColor 15%, transparent)
    rgb(from currentColor r g b / calc(alpha * 0.15))
    

    Note that only the latter works for factors larger than 1.

    alpha() is also often used with hardcoded colors, usually black. In that case just defining the color as is is sufficient:

    /* alpha(black, .8) */
    rgb(0 0 0 / 80%)
    

    shade()

    Shading is less obvious. One might assume that it would work same as mixing with black or white color, but… not quite. It was converting the colors to HSL, multiplying lightness and saturation, and converting back to sRGB. As such, mixing wouldn’t exactly work here, it will produce close but subtly different results, even when done in the HSL color space. For some color/factor combinations it would be same, but other times it will be different.

    Relative colors, meanwhile, allow to manipulate each channel separately, and together with calc() we can do exactly same thing:

    /* shade(red, 1.1) */
    hsl(from red h calc(s * 1.1) calc(l * 1.1))
    

    This is a lot more verbose, but it produces the same result as shade() for any color and factor. Of course, one could go further and use color spaces like Oklch or Oklab instead, and have more consistent results due to their perceptual nature. More on that later.

    lighter() and darker()

    Well, these are simple. They are just shade() with hardcoded factor parameter: 1.3 for lighter() and 0.7 for darker(). As such, they are replaced the same way:

    /* lighter(red) */
    hsl(from red h calc(s * 1.3) calc(l * 1.3))
    
    /* darker(red) */
    hsl(from red h calc(s * 0.7) calc(l * 0.7))
    

    Libadwaita changes

    This has allowed to clean up a lot of things in libadwaita styles too.

    Named colors?

    First of all, all of the existing named colors are available as variables too now. For backwards compatibility reasons their defaut values reference the old colors, for example:

    :root {
      --window-bg-color: @window_bg_color;
      --window-fg-color: @window_fg_color;
    }
    

    They are named the same way as for the old colors, but with dashes in the names instead of underscores. One exception is that @borders became --border-color instead, for consistency with other colors.

    However, being variables they can still be overridden per widget, have fallbacks and so on.

    NOTE: since they reference named colors, using these variables will produce deprecation warnings with GTK_DEBUG=css. This is a known issue, but there isn’t much that can be done here, other than breaking compatibility for all existing apps with custom styles.

    Also, overriding these variables won’t affect styles that use the old named colors directly. This isn’t a big issue if you’re porting your app (because you can replace both definitions and mentions at once), but may be a problem with libraries like libpanel which provide their own styles and use a lot of named colors. Once those libraries are ported though, it will work fine for both apps that have and haven’t been updated.

    All of the available CSS variables are documented on their page, just like the old named colors. If you’re using libadwaita 1.5 or below, the old docs are still where they were.

    It should be noted that compability colors like @theme_bg_color don’t have equivalent variables. Those colors only exist for compatibility, and apps using libadwaita shouldn’t be using them in the first place – specifically that color can be replaced by --window-bg-color, and so on. This can be a problem for libraries like WebKitGTK, however, so maybe there should be an agreed upon set of variables too. For now though, named colors still exist and won’t go away until GTK5, but this is something that needs be figured out at some point.

    New variables

    There are a few additions as well:

    • The opacity used for borders is also available separately now, as --border-opacity.
    • The opacities used for the .dim-label style class and disabled widgets are available as well, as --dim-opacity and --disabled-opacity respectively.
    • --window-radius refers to the window’s current corner radius. Normally it’s 12px, but it becomes 0 when the window is maximized, tiled and so on. It can be used for things like rounding focus rings near window corners only for floating windows, instead of using a long and unwieldy selector like this:

      window:not(.maximized):not(.tiled):not(.tiled-left):not(.tiled-right):not(.tiled-top):not(.tiled-bottom):not(.fullscreen):not(.solid-csd) my-widget {
        /* yikes */
      }
      

    Style changes

    There were a few things that we wanted to do for a while, but that needed variables to be feasible. Now they are feasible, and are implemented.

    Screenshot of a toolbar with .osd style class. It's dark, with light text. It has a GtkScale in it, which is light grey instead of blue.

    For example, the .osd style overrides accent color to be light grey. Previously this was done with separate overrides for every single widget that uses accent, and in some cases blue accents sneaked through anyway. It also didn’t work with custom widgets defined in apps, unless they special cased it themselves. Now it just overrides the accent color variables and is both much simpler and actually consistent.

    Screenshot of a destructive button saying "Destructive Button". It's focused, and the focus ring is red.

    Destructive buttons previously had blue focus rings, and now they are red, matching the buttons themselves. Moreover, apps can easily get custom-colored widgets like this themselves, with matching focus rings.

    Since previously we couldn’t override accent color per-widget, the way to recolor buttons (as well as checks, switches etc) was to just set background-color and color properties manually. It worked, but obviously didn’t affect focus rings, and focus rings are complicated, so doing it manually wouldn’t be feasible. They have a very specific color with a specific opacity, a different opacity for the high contrast mode, and the actual selector changes a lot between widgets. In case of buttons it’s button:focus:focus-visible, for entries it’s entry:focus-within and so on. Not very good, so we didn’t encourage apps to change focus rings, and didn’t change them on destructive buttons either.

    But now that we have variables, people can just override the accent color, and focus rings will follow the suit. For example, to make a green button, an app can just apply .suggested-action and override accent on it to green:

    Screenshot of a green pill button saying "Very Green Button" on top of a light background. It's focused and the focus ring is green.Screenshot of a green pill button saying "Very Green Button" on top of a dark background. It's focused and the focus ring is green.

    So, for example, Calculator can now make its big orange result button orange and have matching focus ring on it without changing accent color in the whole app.

    Because of that, the .opaque style class has been deprecated. Instead, apps are encouraged to use .suggested-action like above.

    Meanwhile, GtkEntry with .error, .warning and .success style classes have had red focus rings, as an exception. Later, AdwEntryRow gained the same styles, but that was messy overall. But now these style classes just set accent color in addition to text color, so these styles aren’t special cases anymore – they will work with any widget.

    Deriving accent colors

    Since 1.0, libadwaita has had two separate accent colors: --accent-bg-color and --accent-color. The former is suitable for use as a background on widgets like buttons, checks and switches, but usually has too low contrast to be used as a text color. The latter has a higher contrast, suitable for use as text, but it doesn’t work well as a background.

    That’s a bit confusing, and I’ve seen a lot of apps misusing them. Some apps set a custom accent color and just set them to the same value, so they don’t have enough contrast. Some apps mix up the colors and use the wrong one. And so on.

    It would be really nice if we could generate one from the other one. Previously this was firmly in the realm of pipe dreams, but now that we have relative colors, it’s a lot more feasible.

    For example, the following functions produce consistently good colors for light and dark styles respectively:

    /* light style */
    --accent-color: oklab(from var(--accent-bg-color) min(l, 0.5) a b);
    
    /* dark style */
    --accent-color: oklab(from var(--accent-bg-color) max(l, 0.85) a b);
    

    Here are some examples, for both light and dark style:

    Screenshot of a window with its left half being light and right half being dark. Each half has 7 rows, each row has a button, a label and an entry. The row colors are as follows, top to bottom: neutral, red, orange, yellow, green, blue, purple

    Unlike the HSL color space, Oklab is perceptual and lightness stays uniform regardless of other channels. So, as simple as it sounds, just limiting the lightness does the job. In fact, we could even do this:

    /* light style */
    --accent-color: oklab(from var(--accent-bg-color) 0.5 a b);
    
    /* dark style */
    --accent-color: oklab(from var(--accent-bg-color) 0.85 a b);
    

    The downside is that --accent-bg-color: black; in light style would produce a dark gray --accent-color instead of black, and --accent-bg-color: white; in dark style would produce a light gray accent instead of white. This may be fine, but the goal here is to ensure minimum contrast, not to prevent too much contrast.

    These functions are also in the docs, but there’s one problem.

    So why not do it automatically?

    Say, we define these variables at :root, as follows:

    :root {
      --accent-bg-color: var(--blue-3);
      --accent-color: oklab(from var(--accent-bg-color) min(l, 0.5) a b);
    }
    

    Then, we override accent color for a specific widget:

    my-green-widget {
      --accent-bg-color: var(--green-3);
    }
    

    But --accent-color would still be blue, so the we would need to re-declare it – not very good.

    There is a way to get around that – use a wildcard:

    * {
      --accent-color: oklab(from var(--accent-bg-color) min(l, 0.5) a b);
    }
    

    But that has its own downsides – it may impact performance and memory use, like wildcards in CSS tend to do. That said, I haven’t profiled it and maybe it’s not that bad.

    Either way, for now we’re keeping --accent-color separate, but apps overriding it are welcome to use the above functions themselves, instead of picking the color by hand. Maybe in future we’ll figure something out.

    Future

    There are a lot more things we could do if we didn’t need to care about backwards compatibility. For example, instead of having colors like --shade-color that are always supposed to be partially-transparent black, we could just provide the opacity as a variable. (or maybe even derive it from background color lightness?) We could specify accent color as a hue and/or chroma in the Oklch color space, while keeping the lightness consistent. And so on.

    Can we drop SCSS entirely?

    A lot of people asked this or assumed that these additions were the few missing pieces for dropping it.

    As much as I’d like to, not yet. While this helps with reducing dependency on it a bit, there are still a lot of things that cannot be replicated, like @mixin and @extend. It also allows us to have private variables and control what gets exposed as API, which is pretty important for a library.

    In fact, there are situations where we had to add more SASS-specific things, tho mostly because we’re using the older (and unmaintained) sassc compiler instead of dart-sass. Being old and unmaintained, sassc doesn’t support the modern rgb() syntax, and errors out. There is a way to placate it, by using RGB() instead, but that’s a hack. It also doesn’t like the slash symbol (well, solidus) and thinks it’s division. This can be worked around with string interpolation, but it’s a hack too.

    So, instead of this:

    rgb(0 0 0 / if($contrast == 'high', 80%, 5%))
    

    we have to do this:

    RGB(0 0 0 / #{if($contrast == 'high', 80%, 5%)})
    

    (and then keep in mind that SASS won’t see this as a color, so you can’t use functions like transparentize() on it, not that we really need to)

    Finally, the opacity() function (used within the filter property) kept getting replaced with alpha(), but only when it contained a variable instead of a literal:

    filter: opacity(55%);
    /* SASS output: filter: opacity(55%); */
    
    filter: opacity(var(--disabled-opacity));
    /* SASS output: filter: alpha(var(--disabled-opacity)); */
    

    I worked around this by once again manipulating case. opacity() was affected, but Opacity() wasn’t.

    All of this could be solved by migrating to dart-sass. This is difficult, however – GNOME SDK doesn’t have any Dart components at the moment, and it would need to build that first. A lot of manifests I’ve seen on Flathub just use pre-built SASS compiler, but I doubt this would be acceptable for the SDK. So, for now we’re stuck with sassc. Help is very much welcome, particularly with the SDK side of things.

    Programmatic API?

    Another question I see fairly often: is it possible to read variables programmatically?

    The short answer is: no.

    The longer answer is: variables can be anything. The only way to read them would be as a string. At that point, you’d need to have a CSS parser in your app to actually parse them into a useful value.

    For example, let’s say we have a variable declaring a color, like red. One might think: so what, just use gdk_rgba_parse(). Now, what about rgb(0 0 0 / 50%), the modern syntax? gdk_rgba_parse() doesn’t support that, but one might argue it should. Alright, what about this:

    color-mix(in oklab, currentColor, var(--something) calc(var(--base-percentage) * 0.5))
    

    This is a potentially valid color, and yet there’s no way gdk_rgba_parse() would be able to parse this – how would it know what currentColor is when you don’t supply it a widget? It would need to resolve other variables somehow. And it would also need a full-blown calc() parser. Yeah, colors get complicated fast. It’s fine when we’re already inside a CSS parser, but gdk_rgba_parse() isn’t one.

    Now, could we have a function to get a variable value specifically as a color? Sure, except variables don’t have to be colors. You might also get 3px, 0 or asjdflaskjd. They don’t even have to be valid values, they can also be parts of the values, or even random gibberish. CSS doesn’t even try to compute variables on their own, only insert them into other values when you reference them with var() and then parse and compute that once every reference is resolved. The following is perfectly valid:

    my-widget {
      --space: srgb-linear;
      --r: 100%;
      --gb: 0 0;
      color: color(var(--space) var(--r) var(--gb) / 50%);
    }
    

    So, with that in mind, would API for fetching them be useful? IMO not really. There are very specific cases where it may work (e.g. if you define a variable containing an integer and then treat it as pixels in your code), but they are far too limited, break easily, and arguably are a misuse of CSS anyway.

    It would be a bit more feasible if we had @property, as then you can at least guarantee the variable type. Even then the type system is very flexible and one can define things like <color># | <integer>#. The valid variables with this type would be comma-separated lists of colors, and comma-separated lists of integers, so good luck with that. And we don’t even need to go that far: take a look at <image>. It can be a gradient (linear (possibly repeating), radial or conic), a URL, image() which produces a solid color image from a given color, or many other things. At best I can see getters being limited to, say, colors, numbers and dimensions.


    Many thanks to:

    • Matthias Clasen for reviews and implementing HWB, Oklch and Oklab color spaces, relative colors, color interpolation and math functions.
    • STF for funding this work.
    • Sonny Piers and Tobias Bernard for organizing everything.

Libadwaita 1.5

Screenshot of Calendar's new event dialog, Fragments's add remote connection dialog and Elastic's about dialog. Calendar and Elasic are narrow, so their dialogs are bottom sheets

Well, another cycle has passed.

This one was fairly slow, but nevertheless has a major new feature.

Adaptive Dialogs

The biggest feature this time is the new dialog widgetry.

Traditionally, dialogs have been separate windows. While this approach generally works, we never figured out how to reasonably support that on mobile. There was a downstream patch for auto-maximizing dialogs, which in turn required them to be resizable, which is not great on desktop, and the patch was hacky and never really supported upstream.

Another problem is close buttons – we want to keep them in dialogs instead of needing to go to overview to close every dialog, and that’s why mobile gnome-shell doesn’t hide close buttons at all atm. Ideally we want to keep them in dialogs, but be able to remove them everywhere else.

While it would be possible to have shell present dialogs differently, another approach is to move them to the client instead. That’s not a new approach, here are some existing examples:

Screenshot of the "Website Exceptions for DNS over HTTPS" dialog from Firefox settings

Screenshot of the "Add New Page" dialog from KDE's System Monitor

This has both upsides and downsides. One upside is that the toolkit/app has much more control over them. For example, it’s very easy to ensure their size doesn’t exceed the parent window. While this is possible with windows (AdwMessageDialog does this), it’s hacky and can still break fairly easily with e.g. maximize – in fact, I’m not confident it works across compositors and in both Wayland and X11.

Having dialogs not exceed the parent’s size means not needing to limit their size quite so aggressively – previously it was needed so that the dialog doesn’t get ridiculously large on top of a small window.

The dimming behind the dialog can also vary between light and dark styles – shell cannot do that because it doesn’t know if this particular window is light or dark, only what the whole system prefers.

In future this should also allow to support per-tab dialogs. For apps like web browsers, a background tab spawning a dialog that takes over the whole window is not great.

Meanwhile the main downside is the same thing as was listed in upsides: these dialogs cannot exceed the parent window’s size. Sometimes it’s still needed, e.g. if the parent window is really small.

Bottom Sheets

Screenshot of libadwaita demo's about dialog on mobile

So, how does that help on mobile? Well, aside from just implementing the existing size constraints on AdwMessageDialog more cleanly, it allows to present these dialogs as bottom sheets on mobile, instead of centered floating sheets.

A previous design has presented dialogs as pages with back buttons, but that had many other problems, especially on small windows on desktop. For example, what happens if you close the window? A dialog and a “regular” subpage would look identical, so you’d probably expect the close button to close the entire window? But if it’s floating above a larger window?

Bottom sheets avoid this issue – you still see the parent window with its own close button, so it’s obvious that they are closed separately – while still being allowed to take full width like a subpage.

They can also be swiped down, though because of GTK limitations this does not work together with scrolling content. It’s still possible to swipe down from header bar or the empty space above the sheet.

And the fact they are attached to the bottom edge makes them easier to reach on huge phones.

Meanwhile, AdwHeaderBar always shows a close button within dialogs, regardless of the system layout. The only hint it takes from the system is whether to display the close button on the right or left side.

API

For the most part they are used similarly to GtkWindow. The main differences are with presenting and closing dialogs.

The :transient-for property has been replaced with a parameter in adw_dialog_present(). It also doesn’t necessarily take a window anymore, but can accept any widget within that window as well. Currently it just fetches the root widget, but once we have per-tab dialogs, that can be controlled with a simple flag instead of needing a new variant of adw_tab_present() that would take a tab page instead of a window.

The ::close-request signal has been replaced as well. Because the dialogs can be swiped down on mobile, we need to know if they can be closed before the gesture starts. So, instead there’s a :can-close property that apps set ahead of time if there’s unsaved data or some other reason to prevent closing.

For close confirmation, there’s a ::close-attempt signal, which will be fired when trying to close a dialog using a close button or a shortcut while :can-close is set to FALSE (or calling adw_dialog_close()). For actual closing, there’s ::closed instead.

Finally, adw_dialog_force_close() closes the dialog while ignoring :can-close. It can be used to close the dialog after confirmation without needing to fiddle with :can-close or repeat ::close-attempt emissions.

If this works well, AdwWindow may have something similar in future.

The rest is fairly straightforward and is modelled after GtkWindow. See AdwDialog docs and migration guide for more details.

Since AdwPreferencesWindow and other widgets can’t be ported to new dialogs without a significant API break, they have been replaced:

For the most part they are identical, with a few differences:

  • AdwPreferencesDialog has search disabled by default, and gets rid of deprecated subpage API
  • AdwAlertDialog can scroll contents, so apps that add their own scrolled windows may want to remove them

Since the new widgets landed right at the end of the cycle, the old widgets are not deprecated yet. However, they will be deprecated next cycle, so it’s recommended to migrate your apps anyway.

Standalone bottom sheets (like in audio players) are not available yet either, but will be in future.

Esc to Close

Traditionally, dialogs have been done via GtkDialog which handled this automatically. But for the last few years, apps have been steadily moving away from GtkDialog and by now it’s deprecated. While that’s not really a problem on its own, one thing that GtkDialog was doing automatically and custom dialogs don’t is closing when pressing Esc. While it’s pretty easy to add that manually, a lot of apps forget to do so.

But since we have dedicated dialog API again, Esc to close is once again automatic.

What about standalone dialogs?

Some dialogs don’t have a parent window. Those are still presented as a window. Note that it still doesn’t work well on mobile: while there will be a close button, the sizing will work just as badly as before, so it’s recommended to avoid them.

Dialogs will also be presented as a window if you try to ad them to a parent that can’t host dialogs (anything that’s not an AdwWindow or AdwApplicationWindow), or the parent is not resizable. The reason for the last one is to accommodate apps like Emblem, which has a small non-resizable window, where dialogs won’t fully fit, and since it’s non-resizable, it doesn’t work on mobile anyway.

What about “Attach Modal Dialogs”

Since we have the window-backed mode, it would be fairly easy to support that preference… except there’s no way to read it from sandboxed apps.

What about portals?

This approach obviously doesn’t work for portals, since they are in a separate process. We do have a plan for them, involving a private protocol in mutter, but it didn’t make it for 46. So, next time.

What about GTK built-in dialogs?

Those will be replaced as well, but it takes time. For now yes, GtkShortcutsWindow etc won’t match other dialogs.

Other Changes

As usual, there are some smaller changes.


As always, thanks to all the contributors who helped to make this release happen.

Libadwaita 1.4

Screenshot showing a few apps using libadwaita 1.4, front to back: Files, Characters, Epiphany
A few apps using libadwaita 1.4

It’s that time of year again, so let’s look at what’s new.

New Adaptive Widgets

I’ve already talked about them in my last blog post, so I won’t go into details this time.

Breakpoints

Libadwaita 1.4 introduces a breakpoint system, allowing to change UI in arbitrary ways depending on the window size. Breakpoints can be used with AdwWindow, AdwApplicationWindow, or with AdwBreakpointBin if you need more control.

Breakpoints can be used in a fully declarative way from UI files, for example:

<object class="AdwBreakpoint">
  <condition>max-width: 500sp</condition>
  <setter object="split-view" property="collapsed">True</property>
</object>

As a tradeoff, you have to manually specify the window’s or bin’s minimum size and ensure its contents actually fit, same as you do on a small screen.

To help with that, GtkButton, GtkMenuButton, AdwSplitButton and AdwButtonContent now all include a :can-shrink property to enable text ellipsizing, while widgets like AdwBanner automatically enable it for their buttons in order to not get uncontrollably wide.

For breakpoint conditions one can use pixels (px), points (pt) or a new sp unit (scalable pixels, name lifted from Android), which is equivalent to pixels with default text scale, but scales with it: 1sp is equivalent to 1.25px with Large Text enabled and so on. To accommodate different text scale factors better, it is recommended to use sp whenever it’s feasible.

Navigation View

Screenshot of a small window with Page 2 in the title, a back button on the left and an "Open Page 3" button in the middle.

AdwNavigationView is an integrated widget implementing the browsing pattern, replacing AdwLeaflet with can-unfold=false. It provides a navigation stack that can be populated statically (e.g. from a UI file) or dynamically, and automatically provides gestures and shortcuts.

It also provides the navigation.push and navigation.pop actions, allowing to push pages directly from a UI file:

<object class="AdwActionRow">
  <property name="title" translatable="yes">_Details</property>
  <property name="use-underline">True</property>
  <property name="activatable">True</property>
  <property name="action-name">navigation.push</property>
  <property name="action-target">"details"</property>
  <child>
    <object class="GtkImage">
      <property name="icon-name">go-next-symbolic</property>
      <property name="accessible-role">presentation</property>
    </object>
  </child>
</object>

To further simplify using it, AdwHeaderBar can automatically show the correct title for each navigation page, as well as a back button to pop the current page when appropriate.

Automatic back buttons also provide a context menu that allows to pop multiple pages at once:

Screenshot of a back button context menu in Settings, opened on the Night Light page and showing Displays and Settings items. Displays is hovered

This still works with nested navigation views, as well with navigation views combined with split views.

Split Views

Screenshot of a window using a split view with prominent "Sidebar" and "Contents" labels in each pane and nothing else (except for a close button in the upper right corner)

While AdwNavigationView replaces the can-unfold=false case of AdwLeaflet, AdwNavigationSplitView replaces the other one.

It has two children: sidebar and content, and it displays them side by side. When the :collapsed property is set to TRUE, it literally turns into an AdwNavigationView. It doesn’t set it automatically though – you are supposed to do it from your breakpoints as needed.

It also provides a more sophisticated sizing for the sidebar, based on the percentage of the split view’s total width.

Meanwhile, AdwOverlaySplitView is similar, but instead of turning into a navigation view when collapsed, it overlays the sidebar over content, not unlike AdwFlap. As such, AdwFlap is what it replaces.

It has a few extra features compared to navigation split view, such as an ability to move the sidebar to the right and show or hide it even when not collapsed, but the two widgets have extremely similar API.

And, like with AdwNavigationView, AdwHeaderBar can integrate with split views: when put inside one, it will automatically hide redundant window buttons, so there’s no need to show or hide them manually like with AdwLeaflet or AdwFlap.

Toolbar View

The new split view styles really need flat header bars to work well. While we’ve had the .flat style class since libadwaita 1.0, in practice it’s quite limited, especially with scrolling content.

As such, there’s a new widget called AdwToolbarView. It contains a content widget and a number of top and bottom bars (for example, AdwHeaderBar, AdwTabBar, GtkSearchBar, GtkActionBar, or GtkBox with the .toolbar style class). Then it will automatically manage the correct styles for the toolbars, for example making them flat and managing undershoot shadows on scrolling content (though this can be changed using the :top-bar-style and :bottom-bar-style properties), as well as collapsing spacing between them:

Screenshot of GNOME Text Editor 45, showing a header bar and a tab bar inside a toolbar view

It’s recommended to always use it instead of GtkBox when you have header bars or other toolbars, regardless of whether you’re using split views.

Deprecations

With breakpoints and the new widgets, a number of older widgets have been deprecated, namely AdwLeaflet, AdwFlap, AdwSqueezer and AdwViewSwitcherTitle, as well as the old subpage API in AdwPreferencesWindow and the .flat style class for header bars. Refer to the migration guide for how exactly to replace them.

List Rows

There has been a number of boxed list additions this cycle.

Switch Row

Screenshot of a switch row in a group with the title "Switch Rows". The row has a title "Switch Row" and the switch is active.

Joshua Lee added AdwSwitchRow – a simple AdwActionRow subclass containing a GtkSwitch. While it’s easy to implement manually, it’s a very common case and so it’s nice to have a shortcut.

Spin Row

Screenshot of a spin row in a group with the title "Spin Rows". The row has a title "Spin Row" and the value is 50. Plus and minus buttons are both sensitive.

Chris added AdwSpinRow – a list row with an embedded GtkSpinButton, similar to AdwEntryRow.

Property Row

Screenshot of a property row in a group with the title "Property Rows". The row has a title "Property Row" and the subtitle "Value".

While it’s not a widget, the new .property style class, also by Chris, can swap styles on AdwActionRow‘s title and subtitle to emphasize the latter. This can be useful when displaying, say, EXIF properties in an image viewer.

Misc Changes

  • Jamie added adw_about_window_new_from_appdata() to simplify creating about windows.
  • AdwClamp can now scale with text scale factor, via the :unit property, incl. defaulting to the sp unit.
  • Yuri Izmer implemented search in AdwComboRow, matching GtkDropDown.

    Screenshot of a combo row search entry. The row value is "unknown" and you can see cropped "gpl-3-0" and "lgpl-2-1" in the list.

  • Maksym Hazevych added AdwPreferencesPage:description property, allowing to show a description at the top of the page.
  • Corey Berla fixed another bunch of drag-n-drop issues to make sure it works as expected in Nautilus.
  • The way AdwTabOverview handles thumbnails has been significantly reworked to make it work better with WebKitWebView.
  • Xenia added the AdwToast:use-markup property to allow disabling markup in toasts (it’s enabled by default).
  • A lot of accessibility issues throughout different widgets have been fixed – special thanks goes to Lukáš Tyrychtr and Maximiliano.
  • Header bars and other toolbars are now white instead of darker grey in the light variant, while the previous grey is now used for sidebars instead. Header bars set as GtkWindow titlebar now also have a shadow, same as when used in a toolbar view with top-bar-style=raised
  • While default GTK dialogs cannot use the new widgets, they have been styled to look similar anyway.

    Screenshot of a GTK file chooser. The sidebar is dark as if it's using a split view. The file chooser is showing libadwaita source directory, with Adw-1.metadata selected


As always, thanks to all the contributors who helped to make this release happen.

Rethinking Adaptivity

TL;DR: The current adaptive widgets have significant problems and have all been replaced and deprecated. You may want to port your apps, the migration guide is here.


Over the past year, I’ve been working on replacing the old adaptive widgets in libadwaita, and as of a few days ago the last pieces have landed.

The Problems

(kgx:356880): Gtk-WARNING **: 16:38:18.039: Allocating size to AdwFlap 0x1e25c50 without calling gtk_widget_measure(). How does the code know the size to allocate?

(epiphany:91138): Gtk-WARNING **: 10:49:35.638: Allocating size to EphyWindow 0x15f8b70 without calling gtk_widget_measure(). How does the code know the size to allocate?

Ever seen an app print a warning like that on resize? If you’re unlucky, also flicker at the same time. This happens when a widget causes a resize during another resize. That’s one of the things that GTK prohibits, and yet almost every adaptive app currently does it, and most of the time it only works by accident.

Take AdwLeaflet, for example. People often use it for managing a sidebar. That’s fine, because leaflet will handle its own resize correctly. However, sidebar also implies other changes, like showing/hiding window controls and a back button. All of these changes cause resizes, and we do them when already inside a leaflet’s resize. Oops.

To an extent libadwaita widgets work around that, e.g. by emitting property notifications when they’re least likely to cause problems, but it’s never perfect. The simple truth is that this system is inherently fragile, depending on a number of things like whether the widget you’re changing is allocated before or after the leaflet (which is an implementation detail).

Flexibility

Another problem with the current approach is that while we provide premade widgets for specific cases, like a sidebar turning into stack navigation, any other kinds of adaptivity are basically unsupported.

For example, maybe your app has a header bar with a lot of widgets, and you want to move some of them into a separate bottom bar on mobile? There’s no premade widget for this, so the only way you can do it is by overriding the size_allocate() virtual function on some widget (typically the window) yourself and doing it there.

This is very finicky and easy to get wrong, and once again you’re setting yourself up for warnings and flicker — the Epiphany warning above is from doing exactly this, for example.

And it can’t even be abstracted as a widget either.

And really… a lot of apps need to do things besides what leaflet and friends provide. Even the libadwaita demo has to (ab)use GtkFlowBox for its style classes demo, while any adaptive app using AdwTabBar has to deal with size_allocate(). And the apps that don’t need tricks like that often have to compromise their design to make sure they don’t use anything unsupported. For example, at one point there was a GNOME Software design using a sidebar at desktop sizes and a view switcher at mobile sizes. That’s not possible with existing widgetry, so it never shipped.

Transition Animations

Widgets like AdwLeaflet provide an animation when switching between modes. This is cool, but also it causes the following bug when starting an app:

Notice the sidebar being briefly visible and then disappearing? This shouldn’t happen. And yet, it does and it’s not fixable.

Even when the transitions work, they are inconsistent. Sure, the leaflet itself animates, but the back button you add? It doesn’t. Even if you use a GtkRevealer to animate the button too, you’ll have to show/hide it at some point to avoid double spacing in the header bar if you have any other buttons next to it. And you can’t use a revealer for window buttons anyway.

Natural width

One thing the current approach (mostly) avoids is apps explicitly defining the size thresholds for layout changes. Instead, all of the existing adaptive widgets use natural widths of child widgets as implicit thresholds. While it avoids magic numbers, it doesn’t work very well in practice:

  • The widths can change on the fly. For example, this often happens in Settings, where you may very well have the sidebar collapse or reappear when you switch between different panels:

    Another example is Fractal, where a similar thing was happening because of long room topics. Because of that, AdwLeaflet actually defaults to using minimum widths as its threshold and not natural widths.

  • Natural widths are hard to control. Apps have no direct control over them, and often have to resort to tricks, like putting labels into GtkScrolledWindow with scrolling disabled to prevent natural width from propagating. (That’s what Fractal did to work around long room topics at first, for example)
  • If your application does more than just show/hide the sidebar — say, toggle the sidebar and also move a view switcher from the top of the window to the bottom, these changes will not happen at the same time. In fact, they may even happen in different order depending on the locale, font, etc., leading to hard to debug issues.
  • Since we’re adding and removing back buttons and window controls, the threshold actually changes between wide and narrow states. If an app does the opposite and removes widgets on narrow sizes, it can even lead to infinite loops:
    • The widgets are too wide, folding the leaflet.
    • Leaflet is now folded, removing buttons.
    • The widgets are narrow enough that they can fit side by side now, unfolding the leaflet.
    • The leaflet is unfolded, adding the buttons back.
    • The widgets are too wide, folding the leaflet.
    • … and so on.
  • Finally, there are cases where we always want narrow layout, even when the widgets fit otherwise. For example, on mobile we always want view switchers to be at the bottom, and yet there are quite a few cases where they would still fit at the top. And so, AdwViewSwitcherTitle has a hack where it monitors the window’s width and forcefully disables its view switcher when it’s below or equal to 360px, on top of artificially limiting its natural size to try and control the threshold.

While fixed thresholds present a different set of problems and are very much a tradeoff, at the end of the day you’re designing your layout to fit into a fixed resolution screen anyway.

All of that is already really bad, but the problems go even further.

API

Leaflet

Let’s look at AdwLeaflet. Most apps use this widget in one of the following two ways:

  1. The leaflet has three children: a sidebar, a separator and content. The separator is marked as non-navigatable. The leaflet has swipe to go back enabled, but not swipe to go forward. Sidebar and content both have header bars, with window buttons hidden in the middle when unfolded, and content has a back button when folded, that goes back to sidebar.
  2. The leaflet has the can-unfold property set to FALSE, and is used basically as a GtkStack with swipe gestures.

However, what is the actual purpose of this widget? Well, it acts as a GtkBox when unfolded and as a GtkStack when folded. As in, it has an unspecified number of children, shows all of them when unfolded and one of them when folded. That’s it.

For the first case we always want a separator. However, AdwLeaflet cannot automatically add it because it doesn’t know if it’s going to be used for a sidebar or not. There’s also no way to know what even is a sidebar: it’s just a leaflet child, so there’s no way to automatically style it. It can’t add a back button for you for the same reason, and so on.

Now let’s look at the second case. It works fine if you have 2 pages and just want to switch between them. You will still have to manage the back button on the second widget manually, but otherwise it’s okay — basically the same as GtkStack.

Now what if we want to have page 1 that leads to page 2 and page 3, both of which lead back to the page 1? Well, now we run into a problem.

If we simply add all three pages into the leaflet, going back from page 3 will lead to page 2 and not page 1.

There are two possible workarounds:

  1. Toggle the page visibility dynamically.
  2. Use a GtkStack with pages 2 and 3, and add that to the leaflet along with page 1.

Neither approach is particularly great.

So, AdwLeaflet is not very well suited for either case. Or more specifically, it tries to be low level and generic like GtkBox and such, while it’s primarily used for much more specific cases.

Flap

AdwFlap is not nearly as bad, as at least it has a fixed number of clearly defined children. But even then it can be used for multiple things that aren’t sidebars, like bottom sheets (even though I’m not aware of any apps actually using it as such now that Console switched to AdwTabOverview) or even for handling the window titlebar in fullscreen.

This means that it’s still too generic to be nice to use in any of these cases:

  • Just as with leaflet, you have to add a separator yourself. Many apps do it wrong and have it on the sidebar instead of a separate child.
  • The sidebar has no background because what if your use case needs it to be transparent?

And so on.

Or to summarize it: just like AdwLeaflet, flap is too generic. It tries to be a box/overlay widget and not, say, a sidebar widget.

Squeezer

AdwSqueezer is mostly used for handling view switchers in header bars. It contains multiple children and shows the largest one that can fit. So other than doing unsupported things for its most common use case, it works fine.

It still has a problem though. Since you put a view switcher and a title inside a squeezer, the header bar will center both at once. If the view switcher is too wide to fit, we show the window title, and since the whole widget is not centered at this point, the title will be off-center:

AdwHeaderBar has a centering-policy property to work around this, but it also introduces another problem:

What we actually want is this though:

But that cannot be accomplished when the view switcher and the title are in a single widget (squeezer’s natural width is that of its largest child — in this case, the view switcher — and if it doesn’t fit, the squeezer is still expanded to fit the remaining space, then the title is centered within that and not within the header bar), but we rely on that to make it adaptive. Not good.

OK, What Now?

So now that we’ve established that the existing widgetry has significant problems, what can we do differently?

Well, lets look at how other platforms handle it.

  • The Web has CSS and media queries. One can define a media query that matches a range of screen sizes (as well as many other things), and CSS rules inside its block would only apply within that range. Since CSS controls layout, people can rearrange their layout using media queries.

    Most web frameworks just use media queries or provide a similar API. This is often referred to as breakpoints.

  • Windows/UWP has AdaptiveTrigger and visual states. One can create a visual state that activates depending on the window size and toggle properties on the widgets in that window using setters.

  • Android has an elaborate resource system that allows for defining different layouts (UI files), dimensions or really any values only for specific screen size buckets or size ranges (as well as many other conditions), and when those conditions change (e.g. when the screen changes orientation), the toolkit just rebuilds the whole UI. Additionally, apps can factor out the common parts of their UI into different fragments, and then the activity (window) layouts can just arrange them in different ways, avoiding duplication.

    (This may be outdated though, I wasn’t able to find any information about how Android does adaptive layouts nowadays and my own knowledge is from Android 4.x, so about a decade old at this point)

  • UIKit uses a constraint layout, and constraints can be marked to only apply depending on screen orientation or size. While it avoids specific size values, instead preferring to target specific devices, iOS has the benefit of only running on a few select devices and can afford that. And before that system was introduced, apps could (and still can) just have different storyboards for different devices or screen sizes.

    Additionally, UIKit provides controllers like UISplitViewController that rearrange their layout automatically.

So… it’s all over the place, but let’s try to come up with something that combines the best parts of the above and hopefully avoid their pitfalls.

Breakpoints

First, we’ll need a way to do arbitrary changes to the UI based on the window size, there’s no way around that.

So let’s implement a window that can do arbitrary layout changes for its contents.

That’s hard, but not impossible. It involves temporarily hiding the contents, applying changes and showing it again on the next frame. To avoid flicker, a snapshot of the child from the last frame can be shown while it’s hidden. It also involves a special case for when mapping the child for the first time.

A hard problem here is sizing. Widgets like leaflet know what their layout will be when it’s folded and unfolded (as long as apps don’t add random back buttons or do other changes, anyway) and can expose correct minimum and natural sizes regardless of their current state. This is important because otherwise you wouldn’t be able to resize them to smaller sizes in the first place.

But we’re talking about arbitrary changes, so there’s no way to predict the size for our window unless it’s already at the smallest possible state. So, the only thing we can do is to disable the minimum size entirely and require apps to specify it, and clip the child if it doesn’t actually fit (or alternatively scale it down).

Another problem is transitions. Since we don’t know what exactly will change between different states, there’s no way to animate it beyond a global crossfade. But considering transitions were already inconsistent, I don’t think it’s a big loss.

And since libadwaita already has AdwWindow and AdwApplicationWindow, we can just reuse them and have all of this logic in there.

Now that we have this functionality we need to figure out good API for control it.

CSS in GTK doesn’t support media queries. And even if it did, they wouldn’t be useful, since CSS in GTK doesn’t control layout beyond widget margins/paddings/borders, minimum widths/heights and sometimes spacing. The overall concept is still attractive though, but we can’t use CSS for this.

However, we can still use UI files, like the UWP system does. What if our widget was defining “visual states” with a size-based condition and property setters? Following the web, let’s call them breakpoints.

GtkBuilder supports custom tags, so we can actually replicate this system in its entirety.

It looks like this:

<object class="AdwWindow">
  <property name="width-request">360</property>
  <property name="height-request">200</property>
  <property name="child">
    <!-- ... -->
  </property>
  <child>
    <object class="AdwBreakpoint">
      <condition>max-width: 500px</condition>
      <setter object="my_object" property="some-property">value</setter>
      <setter object="my_object" property="some-property">value</setter>
      <!-- ... -->
      <setter object="my_object" property="some-property">value</setter>
    </object>
  </child>
</object>

Pretty close, isn’t it? Of course, only being able to change properties is rather limiting, so each breakpoint also has apply and unapply signals.

Now we need to figure out how to deal with child overflow. In an ideal world, apps will just pick the widths where it doesn’t happen, but in practice that won’t work. Even if the widgets fit in English locale, it may not necessarily fit in German. Or with Large Text enabled.

This process isn’t really any different from just adapting an app for small screen sizes. You need it to fit into 360 pixels horizontally, at any cost. So, if you have a long label and you can’t make it shorter or rearrange your UI, you wrap it, ellipsize it or use a smaller font or letter spacing.

One addition on GTK side here is the GtkButton:can-shrink property (as well as GtkMenuButton:can-shrink). Buttons are the main source of labels outside of apps’ control, and now there’s a an easy way to deal with them.

And to deal with different text scales, we can introduce support for units other than pixels to breakpoints.

Currently three units are supported: pixels, points and scale-independent pixels. The last unit is lifted directly from Android and is basically points, but normalized to match pixels at default text scale. See the docs for the exact values.

Finally, in some cases it can be nice to be able to use breakpoints on a more localized area of the UI, for example, a specific page. And so, all of this machinery is also available as a standalone widget: AdwBreakpointBin.

Replacing Squeezer and AdwViewSwitcherTitle

Next, we need to figure out how to adapt existing patterns for this new system.

Some of them, like bottom toolbars or hiding tabs on mobile are doable as is and don’t need any additional support — you can simply toggle their visibility from your breakpoint.

And that includes view switchers: simply add an AdwViewSwitcher into your header bar, a hidden AdwViewSwitcherBar at the bottom of the window, and a breakpoint that removes the view switcher and shows the view switcher bar on narrow sizes.

One problem is that unfortunately AdwViewSwitcher defaults to the narrow policy, while for header bar you really want wide instead, so apps have to change it manually.

The new system also avoids the misaligned title problem, since in narrow state we remove the view switcher entirely and let header bar use its default title, so the view switcher’s width doesnt’t get in the way.

This was the only place libadwaita was using AdwSqueezer for, so we don’t need that widget anymore either.

Replacing Leaflet

AdwLeaflet, on the other hand, cannot be replicated with breakpoints alone and needs a replacement widget. For example, because it provides things like gestures for stack navigation, unlike GtkStack and similar widgets.

Rethinking leaflets has led to a surprising number of other improvements along the way, so let’s run through them.

Toolbar View

Long ago, before libadwaita 1.0, there was this mockup:

Unfortunately, at the time it wasn’t really possible to implement it, not least because of leaflet limitations. However, if we’re redoing it anyway, might as well implement that style, especially when newer apps like Amberol and Emblem use a similar style anyway.

But the flat headers present a problem. What about scrolling content? This has been a long-standing problem with flat header bars, and in fact the .flat style class for header bars has been deprecated along with the old widgets.

Thankfully, GtkScrolledWindow has a mechanism for displaying undershoot indicators, and libadwaita has new style classes for controlling them.

However, manually managing them can be tricky. Sure, it’s easy when you just have a header bar and add the .undershoot-top style class to scrolled window, but what about bottom bar in an app like Contacts? Manually managing .undershoot-bottom in sync with the reveal animation is tricky, and in fact not even possible if you’re using GtkActionBar without wrapping it into another GtkRevealer.

But what if we had a widget for managing it instead? And so that’s what AdwToolbarView does. It replaces GtkBox for cases like this, providing a slot for content, and allowing for adding one or multiple top and bottom bars. Then, it manages the undershoot styles on scrolled windows inside it.

However, having a widget just for that would be silly, so it also does a number of other things:

  • It allows for visually merging multiple toolbars together and remove their border and vertical spacing, making it possible to implement yet another mockup that wasn’t possible before:

  • For the raised style (like on the screenshot above) it makes it possible to have a shadow instead of a border. This is yet another thing that wasn’t possible before, because while GtkWindow’s titlebar is special cased to be drawn before content, that’s not the case in GtkBox, and the shadow would end up behind content. In AdwToolbarView we can make sure the content is drawn last and avoid this problem.
  • It automatically makes all toolbars draggable, including GtkSearchBar etc.
  • It allows extending the content behind top or bottom bars, and to reveal or hide toolbars with an animation. This means that it can be used instead of AdwFlap to manage fullscreen titlebar, and we don’t need to worry about that use case when replacing flap anymore.

Header bars are now also white in light variant instead of darker grey — mostly so that there’s more contrast against their shadow and it looks better with utilty panes using the new styling. The grey color header bars were previously using is now reserved for sidebars and utility panes instead.

Navigation View

The first of the leaflet replacements is AdwNavigationView.

One of the things I wanted to avoid was having a single widget for both sidebars and stack navigation. This is a large part of what made AdwLeaflet so cumbersome, and it was this way to avoid code duplication, as well as due to historical reasons: leaflet first came to exist back when everyone just used GtkStack for this, and provided a version that can also do sidebars, then gradually got improved to be more attractive than GtkStack even for stack navigation.

In fact, libhandy 1.x provides a variant of leaflet that never expands, called HdyDeck. While in theory it was a good idea to separate these two widgets, they had almost identical API, so it still didn’t make any sense. As such, for libadwaita I merged them back and essentially replaced deck with the can-unfold property.

So, as shown earlier, leaflet API is not well suited for this task. So, once again, let’s look at other platforms for inspiration.

Some of them don’t have any nice ways to handle this pattern — for example, in UWP it’s surprisingly involved. Others do, however, such as UIKit and its UINavigationController. In fact, let’s go ahead and use it as inspiration because it’s very well thought out.

A noticeable difference between UINavigationController and AdwLeaflet is that the former has a stack-like API. As in, stack the data structure, not GtkStack (wow, this is confusing, not helped by the fact UIKit calls a stack what GTK calls a box 😵‍💫). So, you have a navigation stack, push pages into it and pop pages off it, and it’s dynamic. For comparison, leaflet has a statically ordered list of children, and you move back and forward in that list.

However, we can’t make AdwNavigationView 100% dynamic: we still need some way to use it from UI files. It took a lot of iteration, but at the end I came up with the following scheme:

  1. Pages can be pushed directly into the navigation stack using push(). Once they are popped, they will be automatically removed.
  2. Pages can be statically added using add() or from UI files. Then, they can be pushed and popped and will stay in the widget and can be reused later. The first added page becomes the root of the navigation stack.
  3. If a page was pushed without having been added and then later add() is called for it, it won’t be autoremoved.
  4. If remove() is called for a page currently in the navigation stack, it won’t be automatically removed and is simply marked for removal, same as if add() had never been called.
  5. The whole navigation stack can be replaced using replace(), including removing any pages currently in the navigation stack if they are marked for autoremoval.

In other words, add() and remove() act a bit like pinning and unpinning the pages. The navigation stack itself is fully dynamic.

Pages also have tags and all of the functions for managing navigation stack have versions that work with tags, and pushing pages this way requires adding them first.

Tags also allow for another thing: GActions for managing the navigation stack. AdwNavigationView exposes two actions: navigation.push and navigation.pop. The first one takes a page tag as parameter, and this allows defining fairly complex structures, such as the one found in AdwAboutWindow entirely from UI files, without any code.

Unlike AdwLeafletPage, navigation view children — AdwNavigationPage — are widgets instead of auxiliary objects. Not only that, they are also derivable, so it’s possible to use templates with them. That’s why the tags are called tags and not names, otherwise there’s a name clash with GtkWidget:name.

Historically, GNOME apps have managed their back buttons themselves, no matter whether they are using GtkStack, HdyDeck or AdwLeaflet. But isn’t that silly? AdwLeaflet manages shortcuts and gestures for going back to the previous page, but not back buttons? Meanwhile UINavigationController automatically provides back buttons, so why can’t we?

Well, no reason really. While our situation is a bit different since header bars are just a widget instead of a part of the navigation controller, header bars already automatically show window controls for the window they are in. And so… AdwHeaderBar can now also automatically show a back button if it’s inside an AdwNavigationView. This is opt-out, same as for window buttons.

AdwHeaderBar has also learned to pull titles out of navigation pages same as how it does with window titles, since changing titles on header bars is a bit painful in GTK4, even with AdwWindowTitle. And since we have titles, they also double as accessible labels, as well as tooltips for back buttons. In fact, if we wanted to add labels to back buttons, like in iOS or elementary OS, we have means to do so now as well.

AdwNavigationView also supports a way to go forward, unlike UINavigationController — using the get-next-page signal. Meanwhile, AdwNavigationPage has signals for tracking its visibility, which can be used to create and destroy its contents on demand. With this apps like Nautilus can hopefully use this widget for content navigation, while with leaflet it just wasn’t feasible.

Finally, while AdwNavigationView hopefully reduces the number of situations where people have to nest them, it still works with nesting, including actions and back button integration. This will come in handy for the next widget.

Navigation Split View

And finally, now that the navigation case is well covered and flat header bars are doable in a robust way, we can reimplement sidebars with a widget called AdwNavigationSplitView.

Compared to AdwNavigationView, this widget is very simple. It has two children: sidebar and content, both of which are required to be AdwNavigationPage. By default, it shows them side by side. When the collapsed property is set to TRUE, it just reparents both into a navigation view. And that’s pretty much it… Add a breakpoint to your window, toggle the collapsed property from it and you have an adaptive sidebar.

Well OK, it does a few more things, but this is still a stark contrast with how complex AdwLeaflet was.

For example, AdwHeaderBar gains yet another feature. When used inside AdwNavigationSplitView, it automatically hides redundant window buttons, hopefully getting rid of bugs like this for good:

Actually, two features. The other one is a property for hiding the title — it’s common to have header bars without titles for split layouts, and having to put an invisible widget into the header bar just to get rid of its title is not ideal.

AdwNavigationSplitView also provides its own navigation.push and navigation.pop actions, so that they still work when not collapsed. They work exactly same as in AdwNavigationView.

Instead of inheriting the push/pop API of AdwNavigationView, split view provides a single show-content boolean property.

Since the sidebar child is now clearly defined, we can style it and implement that mockup from long ago, and if people use toolbar views, they get automatic undershoot shadows.

Moreover, there’s a special styling for triple pane layouts:

This can be achieved just by nesting two split views together, see the docs for more details.

Finally, it implements dynamic sizing for sidebars. This is probably the most complex part of this widget: instead of using the sidebar’s natural width it attempts to give the sidebar a specified fraction of the total width, which is additionally limited by a minimum and a maximum value (as well as the minimum width of the sidebar child). On top of that, the minimum and maximum widths can use the same units as breakpoints, and in fact AdwNavigationSplitView defaults to using the sp unit, so that sidebars scale with Large Text.

Replacing Flap

Unlike leaflet, AdwFlap doesn’t need a ground-up overhaul. We’ve already covered the fullscreen toolbar use case with AdwToolbarView, and flap is basically never used for bottom sheets — and even if it were, we’re going to have a specialized bottom sheet widget later anyway. So, the only case we need to care about is overlay sidebars and utility panes (which are basically the same thing as sidebars, but with a single header bar, so the widget doesn’t need to care about this distinction).

So, AdwOverlaySplitView is actually heavily based on AdwFlap, trimming it down and simplifying it API to match AdwNavigationSplitView. It also enjoys the same styling, dynamic sizing and AdwHeaderBar window button integration:

Yes, it looks exactly same as AdwNavigationSplitView when it’s not collapsed. What did you expect? Unlike that widget though, it does allow moving the sidebar to the right, as well as showing and hiding it, same as flap. (AdwNavigationSplitView doesn’t allow either because it would break the navigation model)

Unlike AdwNavigationSplitView, it doesn’t require children to be AdwNavigationPage, though it doesn’t prevent it either — it will work fine and you may still want to do that because of header bar title integration.

It also removes touchpad swipes. While it still has swipes on touchscreen, combining edge swipes on touchscreen with swipes anywhere on touchpad has never worked well and it has caused no end of problems for apps like Loupe. With touchpad swipes being partially broken in GTK4, it’s just not worth it. AdwNavigationView still supports touchpad swipes, but this widget does not.

Migrating

The old widgets have been deprecated and apps using libadwaita from main are encouraged to migrate to the new widgetry. Libadwaita also provides a migration guide for doing so.

The adaptive layouts page also uses the new widgets now.

Future

Dynamic Layouts

Even with all of that, there’s always room for improvement.

For example, while breakpoints allow rearranging the UI by setting properties, it’s still fairly limited and one needs widgets like split views for more complex layout changes. And while anything is possible with enough work, doing complex layout changes is rather painful (though less so than with the old widgets) and it has to be done programmatically.

However, we can take a bit of inspiration from Android, with its resource system and rebuilding the UI to change screen orientation etc.

As such, another thing I’ve been prototyping is a yet-to-be-named widget that does a similar thing to what AdwNavigationSplitView is doing, but generically:

  • You provide it multiple different UI files, each of them contains tagged slots.
  • You also provide it children with matching tags.
  • It creates UI from one of the UI files and inserts the children into those slots.
  • Which layout it uses is controlled by a property.

When you change its value, it unparents the tagged children, destroys the rest of the layout, creates the other layout and reparents the children there.

To better explain it, let’s take a look at this mockup of Loupe:

Here properties are presented in a sidebar at desktop sizes, and in a bottom sheet at mobile sizes. We obviously don’t have a widget that transforms between a sidebar and a bottom sheet (nor do we have a bottom sheet widget, but we will in future, so let’s assume we do), so there are two ways of implementing it:

  • Have both a sidebar and a bottom sheet, each containing a copy of the properties view, and only have one of them visible at a time. In case of Loupe, it will work fine, since the properties view is reasonably small, but it doesn’t scale in the general case.
  • Reparent them manually. This works, but is pretty fiddly and can’t be done declaratively.

With this new widget though? Define two layouts: one with a split view with two slots as its content and sidebar children, the other one a bottom sheet view with the same two slots. Add two children: an image view and a properties view, matching those slots. Then toggle the layout from your breakpoint. That’s it.

One downside of how Android does it is that apps have to thoroughly save widget state before rebuilding the UI and restore it afterwards, or it will be lost. But, since we’re reusing both widgets, there’s no need to save state here, it will just work.

We can still go a step further and do even that declaratively for state not covered by the children within the slots: have a list of object/property/tag triplets in each layout, and map each of them to the matching object/property on the other layout.

There are a few obstacles to solve here, but I have a working prototype, and maybe can even finish it this cycle.

Dialogs

Speaking of bottom sheets, one thing that’s still missing from libadwaita is support for adaptive dialogs. That’s coming too, but probably not this cycle. It needs cooperation with the compositor — especially for portals, where the dialog and its parent window are in different processes and can’t communicate. But we have a design and a tentative plan for how to get there.


I’d like to say thanks to:

  • Nahuel for pointing me towards UWP AdaptiveTrigger and answering UWP questions.
  • Chris for porting a lot of apps to try out and test the new API.
  • Tobias Bernard and Sam Hewitt for UI design and answering my countless questions about tiny and unimportant details.

As always, thanks to my employer, Purism, for letting me work on all of this.

Libadwaita 1.3

Another cycle, another release. Let’s take a look at what’s new.

Banners

Screenshot of AdwBanner

AdwBanner is a brand new widget that replaces GtkInfoBar.

Jamie started implementing it before 1.2 was released, but we were already in the API freeze so it was delayed to this cycle instead.

While it looks more or less the same as GtkInfoBar, it’s not a direct replacement. AdwBanner has a title and optionally one button. That’s it. It does not have a close button, it cannot have multiple buttons or arbitrary children. In exchange, it’s easier to use, behaves consistently and has an adaptive layout:

Wide screenshot of AdwBanner. The button is on the right, the title is centered.

Medium-width screenshot of AdwBanner. The button is on the right, the title is left-aligned as it wouldn't fit centered.

Narrow screenshot of AdwBanner. The title is center-aligned, the button is centered below it.

Like GtkInfoBar, AdwBanner has a built-in revealer and can be shown and hidden with an animation.

There are situations where it cannot be used, but in most of those cases GtkInfobar was already the wrong choice and they should be redesigned. For example, Epiphany was using them for the “save password” prompt, and is using a popover for that now.

Tab Overview

A work-in-progress grid-based tab overview A work-in-progress carousel-based tab overview

AdwTabOverview is a new tab overview widget for AdwTabView that finally makes it possible to use tabs on mobile devices without implementing a mobile switcher manually.

Back when I wrote HdyTabView, I mentioned a tab overview widget in the works, and even had demo screenshots. Of course, basically everything from that demo got rewritten since then, and the carousel for narrow mode got scrapped completely, but now we’re getting into The Ship of Theseus territory.

This required a pretty big rework of AdwTabView to allow tabs to have thumbnails when they are not visible, and in particular it does not use a GtkStack internally anymore.

By default the selected tab has a live thumbnail and other thumbnails are static, but apps can opt into using live thumbnails for specific pages. They can also control the thumbnail alignment in case the thumbnail gets clipped. Thumbnails themselves are currently not public, but it might be interesting to use them for e.g. tooltips at some point.

Overview is not currently used very widely – it’s available in Console, and there is a merge request adding it to Epiphany, but I didn’t have the energy to finish the latter this cycle.

Tab Button

Screenshot of AdwTabButton. It's showing two tab buttons. One displays 3 open tabs, the other one 15 and has an attention indicator

AdwTabButton is much less interesting, it’s just a button that shows the number of open tabs in a given AdwTabView. It’s intended to be used as the button that opens the tab overview on mobile devices.

Unlike tab overview, this widget is more or less a direct port of what Epiphany has been using since 3.34. It does have one new feature though – it can display an indicator if a tab needs attention.

Accessibility

You might have noticed that widgets like AdwViewStack, AdwTabView or AdwEntryRow were not accessible in libadwaita 1.2.x. The reason for that is that GTK didn’t provide public API to make that possible. While GtkAccessible existed, it wasn’t possible to implement it outside GTK itself.

This cycle, Lukáš Tyrychtr has implemented the missing pieces, as well as fixed a few other issues. And so libadwaita widgets are now properly accessible.

Animation Additions

One of AdwAnimation features is that it automatically follows the system setting for disabling animations. While this is the expected behavior in most cases, there are a few where it gets in the way instead.

One of these cases is in apps where the animations are the app’s primary content, such as Elastic, and so I added a property that allows a specific animation to ignore the setting.

The animation in the demo is now using it as well, for the same reason as Elastic, and in the future it will allow us to have working spinners while animations are disabled system-wide as well.

A screenshot of an animation graph from Elastic

Elastic also draws an animation graph before running the actual animation, and while for timed animations it’s relatively easy since easing functions are available through the public API, it wasn’t possible for spring animations. It is now, via calculate_value() and calculate_velocity().

All of this uncovered a few bugs with spring animations – for example, the velocity was completely wrong for overdamped springs, and Manuel Genovés fixed them.

Unrelated to the above, a common complaint about AdwPropertyAnimationTarget was that it prints a critical if the object containing the property it’s animating is finalized before the target. While it was easy to avoid in C, it was nearly impossible from bindings. And so we don’t print that critical anymore.

Other Changes

  • Christopher Davis added a way to make AdwActionRow subtitle selectable.
  • Matt Jakeman added title-lines and subtitle-lines properties to AdwExpanderRow, matching AdwActionRow.
  • AdwEntryRow now has a grab_focus_without_selecting() method, matching GtkEntry.
  • The API to set an icon on AdwActionRow and AdwExpanderRow are now deprecated, since they were mostly unused. Apps that need icons can add a GtkImage as a prefix widget instead.
  • AdwMessageDialog now has the async choose() method, matching the new GTK dialogs like GtkAlertDialog. The response signal is still there and is not deprecated, but in some cases the new method may be more convenient, particularly from bindings:

    [GtkCallback]
    private async void clicked_cb () {
        var dialog = new Adw.MessageDialog (
            this,
            "Replace File?",
            "A file named “example.png” already exists. Do you want to replace it?"
        );
    
        dialog.add_response ("cancel", "_Cancel");
        dialog.add_response ("replace", "_Replace");
    
        dialog.set_response_appearance (
            "replace",
            DESTRUCTIVE
        );
    
        var response = yield dialog.choose (null);
    
        if (response == "replace") {
            // handle replacing
        }
    }
    
  • Corey Berla added missing drag-n-drop related API to AdwTabBar to make it work properly in Nautilus.
  • Since GTK now allows to change texture filtering, AdwAvatar properly scales custom images, so they don’t appear pixelated when downscaled or blurry when upscaled. This only works if the custom image is a GdkTexture – if your app is using a different GdkPaintable, you will need to do the equivalent change yourself.
  • Jason Francis implemented dark style and high contrast support when running on Windows.
  • Selected items in lists and grids are now using accent color instead of grey, same as Nautilus in 43. Sidebars and menus still look the same as before.

New Dependencies

The accessibility additions, scaled texture render nodes in AdwAvatar, as well as mask render nodes (that I didn’t mention because it’s an internal change) and deprecation fixes mean that libadwaita 1.3 requires GTK 4.10 instead of 4.6.


As always, thanks to all the contributors, and thanks to my employer, Purism, for letting me work on libadwaita and GTK to make this release happen.

Introducing Elastic

Elastic is a new spring animation editor app.

Screenshot of Elastic

Ever since 1.0, libadwaita has had spring animations. These animations aren’t controlled with a duration and an easing function, but instead with physical properties: damping ratio (or optionally damping), mass, stiffness and initial velocity, as well as epsilon. While this allows for a lot of control over the animation, it can be pretty hard to understand if you’re not familiar with physics involved, and to be truly useful it needs an editor.

So, Elastic is that editor. It provides a way to tweak each parameter, explains what they do, allows to preview the animation in various ways, and generates the code to create that animation.

Screenshot of the animation graph in Elastic

Backstory

This app has a pretty convoluted history.

At one point of time, before 1.0, me and Tobias wanted to rework libadwaita demo and Tobias made new mockups for it. At about the same time, I was experimenting to see how feasible spring animations would be and needed some playground. Since it was understood that this will eventually be in libadwaita, Tobias made this design a part of the new demo:

Spring animation demo mockups

So I implemented it about as closely as I could at the time, but since then a few things happened:

  • We decided that the new demo was too design-focused to work well as, well, a demo (one of the purposes of which is using it as a test case for libadwaita itself). So, instead, we decided to make the new design a separate app called Patterns. That app is stalled for other reasons as it needs features that libadwaita doesn’t provide at the moment. Hopefully, next cycle we should have everything needed to finish it.
  • We decided that the spring animation page was too involved for a demo and was better off separately. So I split that code out and ported it to Vala instead of C.
  • As part of his GSoC project, Manuel Genovés implemented proper spring animations instead of a hacky prototype, and I ported the editor to that.

And then… everyone kinda forgot about it – Patterns was still blocked on other things, Manuel implemented a simpler animation demo for libadwaita and this one was left out.

Fast forward two years, Tobias was reviewing an app in Circle and needed a way to play with spring animations. We realized we never did anything with the editor, so I turned it into a proper app, fixed the remaining bugs and made it generate code instead of just previewing animations. We named it Spring Editor (imaginative, I know) and my impression was that design tools were covered by the new app inclusion process, so I submitted it to incubator.

It turned out more complicated than expected, so I decided to ignore it and try and get it into GNOME Circle instead. So now the Spring Editor name is too generic, so the app got renamed again, this time to Elastic (thanks Christopher Davis for that name) and it’s on Flathub now.


Anyway, the app is finally out, it’s on Flathub, so have fun. :)

Download on Flathub

UPDATE: It’s in Circle now.

Libadwaita 1.2

So, half a year after 1.1, libadwaita 1.2 has been released.

While it doesn’t contain everything I had planned (since I ended up being mostly unavailable for about half of the cycle for reasons outside my control), it still has a bunch of additions, so let’s take a look at the changes.

Entry Rows

First, we have a widget that was planned for 1.0, but didn’t make it because it still needed work. This cycle I took time to finish and land it.

Originally implemented by Maximiliano, AdwEntryRow is a new type of boxed list rows, containing an inline entry. Its title doubles as the entry placeholder.

Entry rows in Libadwaita 1.2 demo

Entry rows can require confirmation via an apply button shown when its contents are edited. Otherwise, it’s similar to GtkEntry API, and can have prefix and suffix widgets like AdwActionRow.

There is also a companion widget AdwPasswordEntryRow, mirroring GtkPasswordEntry.

Message Dialogs

AdwMessageDialog with heading: Save Changes?, body text: Open document contains unsaved changes. Changes which are not saved will be permanently lost., and buttons: Cancel, Discard, Save. The Discard button has destructive appearance, the Save button has suggested appearance

AdwMessageDialog is a new widget that replaces GtkMessageDialog. On the first glance, it looks more or less the same. However, it has a few important differences.

Adaptive Layout

GtkMessageDialog tends to overflow off the screen very easily. AdwMessageDialog doesn’t because it’s fully adaptive: when scaling down the window, it first restricts its own size to the parent’s size:

AdwMessageDialog with heading: Save Changes?, body text: Open document contains unsaved changes. Changes which are not saved will be permanently lost., and buttons: Cancel, Discard, Save. The Discard button has destructive appearance, the Save button has suggested appearance. When the window is shrunk down to mobile size, it becomes narrower

And if that’s not enough to fit, it arranges its buttons vertically:

AdwMessageDialog with heading: Save Changes?, body text: Open document contains unsaved changes. Changes which are not saved will be permanently lost., and buttons: Cancel, Close without Saving, Save As…. The Close without Saving button has destructive appearance, the Save As… button has suggested appearance. When the window is shrunk down to mobile size, it rearranges buttons vertically

Additionally, it always uses the vertical arrangement if the dialog would end up ridiculously wide otherwise, even when there is enough space to display it like that.

API

The second important thing is that it’s not a GtkDialog subclass and, as such, has completely new API.

  • GtkMessageDialog inherits the response API from GtkDialog. It relies on integer IDs that are provided by applications, except when they aren’t – the GtkResponseType enum provides a few predefined responses. This is a very C-centric API (as it assumes enum values being interchangeable with integers), and it’s not overly convenient to use. AdwMessageDialog replaces it with simple string IDs that are always provided by applications.

    This also allowed the response signal to be detailed, using the response ID as the detail.

    In addition, AdwMessageDialog is automatically closed on response, and doesn’t need to be closed manually. As such, common code like this:

    static void
    dialog_response_cb (GtkDialog       *dialog,
                        GtkResponseType  response,
                        MyObject        *self)
    {
      if (response == GTK_RESPONSE_ACCEPT)
        my_object_foo (self);
    
      gtk_window_destroy (GTK_WINDOW (dialog);
    }
    
    ...
    
    g_signal_connect (dialog, "response",
                      G_CALLBACK (dialog_response_cb), self);
    

    can be replaced with just:

    g_signal_connect_swapped (dialog, "response::accept",
                              G_CALLBACK (my_object_foo), self);
    
  • Just as AdwMessageDialog doesn’t have predefined responses and leaves that to apps, it doesn’t use GtkButtonsType. It seems counterproductive to have API to easily create buttons like “OK”, “Cancel”, “Yes” and “No” when we’ve been discouraging labels like this for just about two decades now, doesn’t it?
  • No direct widget access. GtkMessageDialog required apps to poke into its widgetry to pack additional widgets, add style classes to its buttons and so on. AdwMessageDialog can have a single extra child, displayed below the body text and managed with the extra-child property, and natively supports changing button appearance instead.

    AdwMessageDialog from GNOME Text Editor, with heading: Save Changes?, body text: Open document contains unsaved changes. Changes which are not saved will be permanently lost., and buttons: Cancel, Discard, Save. The Discard button has destructive appearance, the Save button has suggested appearance. It has an additional child under the body text - a boxed list with one unsaved file called "123"

  • While the intention of GtkMessageDialog is to use its primary and secondary text, it also displays the window title, and many applications get it wrong, ending up with three labels or wrong styling:

    GtkMessageDialog from LibreOffice Writer. It has three labels: title: Save Document? primary text: Save changes to document "Untitled 1" before closing? secondary text: Your changes will be lost if you don't save them.

    GtkMessageDialog from Rnote. It has title: Quit Application, and primary text: Any unsaved changes will be lost. Do you want to quit anyways?, but no secondary text, so both labels appear wrong.

    Primary text also changes its styles to look like a heading or body text, depending on whether secondary text is set.

    AdwMessageDialog does not do any of that. It just has heading and body, that’s it.

  • Finally, it’s derivable. While making GtkMessageDialog final in GTK4 was consistent with most of the other GTK widgets, it has generally resulted in vastly more convoluted code for apps with complex dialogs – for example, the save dialog in GNOME Text Editor with its list of open files.

So now app developers have no excuses for shipping unpolished message dialogs anymore. 😉️

About Window

Another widget originally planned for 1.0 that didn’t make it was Adrien’s AdwAboutWindow, replacing GtkAboutDialog. This cycle I finished it up and merged it.

The new window is adaptive and natively provides a lot of details people were doing hacks with GtkAboutDialog for before:

  • Legal information for dependencies and other components;

    A screenshot of legal information from GNOME Maps, listing information for map data provider, map tile provider and search provider

  • A list of acknowledgements in addition to credits — for example, for crowdfunding backers;
  • A place to list recent changes in the application;
  • Links to the application’s website, issue tracker, support forum, as well as any additional links, as opposed to a single link in GtkAboutDialog;
  • Debugging information, along with easy ways to save or copy it.

    Screenshot of debugging information from GNOME Text Editor's AdwAboutWindow

Christopher Davis has ported core apps using libadwaita to AdwAboutWindow, so it’s also pretty consistently used by now.

Tab View and Tab Bar Additions

AdwTabView and AdwTabBar have seen quite a few changes this cycle.

The most important change is with shortcut handling. AdwTabView provides a lot of shortcuts for switching and reordering tabs, for example, Ctrl+Tab to switch to the next tab, or Alt+1Alt+0 to switch to the first 10 tabs.

In Libhandy, these shortcuts were added via the shortcut-widget property, with the idea that you set it to your toplevel window unless you have multiple tab views.

In Libadwaita 1.0, that property was removed, and instead the shortcuts were switched to the GTK4 shortcut engine, using global scope (really MANAGED scope, but doesn’t matter). This means that they were available for the whole window automatically.

However, this also brought issues. For example, the Ctrl+Tab shortcut stopped working, because GTK defines that shortcut itself, for changing widget focus. Similarly, GTK4 port of the VTE library handles all input on the CAPTURE propagation phase, meaning none of the tab view shortcuts could work at all in the GTK4 port of GNOME Console.

Meanwhile in both libhandy and libadwaita 1.0.x-1.1.x, apps that want to disable some of the shortcuts had to do pretty terrible hacks to achieve it, for example, registering the same shortcuts on the toplevel window and redirecting input to the tab contents or whatever widget needs to handle them.

So, to fix that, AdwTabView has switched its shortcuts to the CAPTURE phase as well, and added an easy way to enable or disable shortcuts.

Other than that, AdwTabPage now has a property to set the tooltip for its indicator icon, and AdwTabBar tabs similarly has a tooltip on the tab close buttons – not customizable this time.

Toast Additions

  • Kévin Commaille added a way to have custom widgets as toast titles. This allows, for example, to have avatar+name pills inside toasts in Fractal.
  • Emmanuele Bassi added a helper function for creating a toast with formatted title.
  • Jamie added AdwToast::button-clicked signal to make it possible to have toast buttons without actions.
  • Jonathan Blandford made it possible to add or dismiss toasts multiple times to make them easier to use. Dismissing toasts that have already been dismissed does nothing now, while adding toasts that have already been added resets the timeout for toasts that are currently shown, or bumps them forward in the queue otherwise. Previously doing that resulted in criticals being printed.

Other Additions

  • AdwPropertyAnimationTarget has been added to simplify animating object properties.
  • AdwAnimation can now change targets on the fly.
  • Sophie Herold added a property to allow disabling Pango markup in boxed list row titles (as well as subtitles in case of AdwActionRow etc).
  • AdwSplitButton can now have a separate tooltip for its dropdown button.
  • AdwViewStack has gained a helper function to add a child along with a title and an icon.
  • JCWasmx86 added plumbing to AdwStyleManager to allow GNOME Builder to launch apps with light/default/dark color scheme, as well as in high contrast. This has also been backported to Libhandy, and is available in 1.8.0 (which doesn’t have a blog post as it’s the only noteworthy change there).

Styles

AdwTabBar in Libadwaita demo 1.2

A few styles have been tweaked. In particular, AdwTabBar has seen big changes, mainly to improve contrast in dark variant and make it clearer which tab is selected.

A comparison between AdwTabBar in Libadwaita 1.1 and 1.2, in dark style

The lack of full height separators has also allowed to remove the distracting light borders from header bars and search bars in favor of dark borders using @headerbar_shade_color and add a proper backdrop style consistent with header bars.

In addition, GtkActionbar and AdwViewSwitcherBar now have the same style as header bars, search bars and tab bars, while boxed lists use dark separators.

Alarms page from GNOME Clocks with Libadwaita 1.2

The .large-title style class is now documented as deprecated and apps should use .title-1 instead.

There are also a few smaller changes, for example:

  • Toasts without buttons are smaller now, instead of taking a fixed width.
  • AdwActionRow child spacing is now smaller, matching toolbars and the new AdwEntryRow.
  • AdwExpanderRow arrows have changed orientation to look less like rows that open subpages on click.
  • Lots of fixes, some of them have been backported to libadwaita 1.1.x as well.

As it happens, quite a lot didn’t make it because it needed more work, or the timing just didn’t work out. This also means that there’s a lot to look forward to in the next release though, so stay tuned.

Thanks to all the contributors, and thanks to my employer, Purism, for letting me work on Libadwaita and GTK to make this release happen.

Libadwaita 1.1, Libhandy 1.6

Libadwaita 1.1 and Libhandy 1.6 are now released to match the upcoming GNOME 42.

Libadwaita 1.1

Since Libadwaita 1.0 was released just a few months ago, 1.1 doesn’t contain a lot of features, but still has a few.

Header Suffixes

Christopher Davis implemented header suffixes for AdwPreferencesGroup, allowing to put a widget next to the group’s title and subtitle:

Settings 42, Appearance panel, a screenshot of a preference group title and suffix widget. The title says "Bakground", the suffix widget says "Add Picture…" and has a "+" icon.
The “Add Picture…” button is a header suffix

Selectable Titles

Niels De Graef has added API to make the title of an AdwActionRow selectable.

An action row in Contacts, showing a personal email "totally_real@ema.il", a part of the address is selected

Better Cross-platform Support

Chun-wei Fan made Libadwaita build with the MSVC compiler, meanwhile Christian Hergert implemented support for the system dark mode when running on macOS.

Misc Changes

Libadwaita 1.1 also includes a bunch of bug fixes, though most of them are also in the 1.0.3 point release.

Libhandy 1.6

UPDATE: there’s 1.6.1 now, fixing a bug with HdyStyleManager.

Since Libhandy hasn’t had a release along with Libadwaita 1.0, the 1.6 release can be summarized as bringing it up to par with Libadwaita. As such, the two big new features in Libhandy 1.6 are:

New Docs

Maximiliano ported the documentation to gi-docgen and significantly cleaned it up. The new documentation can be found here.

A screenshot of the libhandy docs in a GTK4 Epiphany build

Style Manager

Libhandy 1.6 contains a backport of the AdwStyleManager class, allowing GTK3 applications to use the dark style preference in GNOME 42.

Unlike in Libadwaita, HdyStyleManager is not initialized unless the application explicitly uses it, and defaults to the FORCE_LIGHT color scheme instead of PREFER_LIGHT. This means that applications have to actively opt-in to support the preference, and nothing changes for existing applications.

File Roller and Files following the dark preference
File Roller and Files following the dark preference

Initializing HdyStyleManager also changes how the high contrast mode is handled to make it consistent with the regular style as well as Libadwaita: it does not make dark applications unconditionally light. Instead, it follows the application and system color schemes the same way as it does for the regular style. This way applications can be both dark and high contrast at the same time, i.e. the preferences can work together. Even though it’s not exposed in Settings, the prefer-light color scheme can be used to ask apps to still be light, regardless of whether high contrast is enabled.

Eye of GNOME and Boxes with the light color scheme
Eye of GNOME and Boxes with the light color scheme
Eye of GNOME and Boxes with the light color scheme and high contrast enabled
Eye of GNOME and Boxes with the light color scheme and high contrast enabled
Eye of GNOME and Boxes with the default color scheme and high contrast enabled
Eye of GNOME and Boxes with the default color scheme and high contrast enabled

Misc Changes

It also backports a lot of bug fixes from Libadwaita, and features a much cleaner and significantly faster CI pipeline. The development branch has been renamed to main.

Libadwaita 1.4 and 1.6 CI pipelines.  The 1.2 one took an hour and 26 minutes, the 1.4 one took 10 minutes


Overall neither release is very exciting, but they provide a closure to this cycle. Thanks to all the contributors!

Libadwaita 1.0

Libadwaita 1.0 Demo

Libadwaita 1.0 has been released, just at the end of the year.

Libadwaita is a GTK 4 library implementing the GNOME HIG, complementing GTK. For GTK 3 this role has increasingly been played by Libhandy, and so Libadwaita is a direct Libhandy successor.

You can read more in Adrien’s announcement.

What’s New

Since Libadwaita is a Libhandy successor, it includes most features from it in one form or another, so the changes are presented compared to it.

If you’ve been following This Week in GNOME, you may have already seen a large part of changes.

Updated Stylesheet

Probably the most noticeable change is the reworked stylesheet.

For the past 7 years, the Adwaita style has been a part of GTK. Now it’s a part of Libadwaita instead, while the GTK style has been renamed to Default.

Since we have this opportunity, the stylesheet has been completely redesigned with several goals in mind:

Modernizing the style

GNOME designers have long wanted to do this, and the GTK 4 Default style contains a few changes in that direction compared to the GTK 3 version of Adwaita, and GNOME Shell has been using a similar style as well. Libadwaita takes it much further. You can read more about it Allan Day’s blog post.

The changes are not fully compatible with GTK Default and may require changes on the application side when porting. I’ve also blogged in detail about the biggest breaking change: the updated header bar style.

Runtime recoloring

Ever since Adwaita started using SCSS, it couldn’t really be recolored at all without recompiling it. This created big problems for applications that wanted to do that.

For example, GNOME Web makes its header bar blue in incognito mode. This may sound simple, but involves copy-pasting large chunks of Adwaita into the app itself and making many small changes everywhere to adjust it, as well as using SCSS for it because the original style is SCSS. More recently, GNOME Console and Apostrophe started doing the same thing – copy-pasted from Web, as a matter of fact. This approach means the style is messy and extremely hard to keep up to date with Adwaita changes – I have updated this style for the 3.32 style refresh and never want to do this again.

Another approach applications like Contrast are using (were using with GTK 3, anyway), is copying the whole stylesheet from GTK, and using libsass to recompile it in runtime. This worked – it’s much more maintainable than the first approach, but fell apart when libsass got deprecated.

Meanwhile, the elementary OS stylesheet has been doing recoloring just fine with nothing but @define-color – and so Libadwaita does exactly that, it exposes all of the colors it uses (31 as of the moment of writing) as named colors. The new colors are also documented and will be treated as a proper API.

It also drops all of the formerly used PNG assets, so the colors can affect the elements that used them.

It also reworks the high contrast variant to use the same colors when possible to make sure that changing color for the regular style also works with high contrast.

Another thing the new stylesheet does is simplifying how it handles colors in general. The new simplified style comes in very handy here.

For example, many parts of the UI are now derived from the text color and change with it automatically, and widgets that don’t absolutely need to define their own text color don’t do that anymore, so it can propagate. Where possible, transparency is used instead of mixing or hardcoding colors.

All in all it means that simple custom styles like this one:

/* Solarized popovers */
popover > arrow,
popover > contents {
  background-color: #fdf6e3;
  color: #586e75;
}

actually work correctly and with no glitches, in both light and dark variants, as well as high contrast style. Try doing that with GTK 4 Default and compare the results:

Dark Variant Contrast

The dark variant of Adwaita has historically been intended to be used as a lights-out, low distraction appearance for media apps – video players, image viewers, games – and not as a general purpose dark style. As such, it has pretty low contrast and can be hard to see at times.

A primary example is the accent color – historically, Adwaita has never really had a proper accent color as a named color – and many applications have been using @theme_selected_bg_color – a background color used for selected text and list items – as an accent color for text and icons. Not only does it not have a good enough contrast to be used as text color, the dark variant dims it even further, to make this background color less distracting – so while it’s not too bad in the light variant, it falls apart with dark.

Libadwaita fixes that – it makes the accent brighter (made possible by not using it in contexts where it can be distracting), and introduces a second color to be used for cases like this. This second color does vary between the light and dark variants, and this allows it to be much brighter in dark variant, and darker in light variant, so it’s suitable for text.

It also changes many other things. The window background is now darker, while elements like buttons and boxed lists are lighter, GtkSwitch and GtkScale sliders are light, etc.

Style Classes

Button styles: regular (no style), flat, suggested action, destructive action, a few custom colored buttons, circular, pill, osd
Various button styles

The updated stylesheet includes many new style classes for app developers to use, in a lot of cases codifying existing patterns that applications have been using via custom styles, but also adding new things.

Some highlights:

  • .pill makes a button large and rounded – in other words, makes it a pill button
  • .flat can now be used with GtkHeaderBar
  • .accent colors a label into the accent color (using the correct color as per the above section)
  • .numeric makes a label use tabular figures
  • .card makes a widget have the same background and shadow as a boxed list.

And speaking of boxed lists, the old .content style class from Libhandy has finally been renamed to .boxed-list, matching the HIG name.

The available style classes (both existing and new) are now documented, and Libadwaita demo now includes a sample of each of them.

Refactoring and cleanups

Adwaita has historically been a big SCSS file containing most styles, another file containing complex mixins for drawing buttons, entries and other widgets, and a few more files for colors.

Libadwaita splits all of that into small manageable files. It removes the complicated mixins, because the new style is simple enough that they aren’t needed. It removes tons of unused and redundant styles, some of which were leftovers from early GTK 3 days, and so on. And, of course, the new style itself allows making styles significantly simpler.

The end result is a much more maintainable and less arcane stylesheet.

Dark Preference

Settings, light
A work in progress dark preference in Settings. Dark version

I’ve blogged about this in much more detail a few months ago, but in short, Libadwaita includes API to support the new cross-desktop dark style preference, as well as streamline the high contrast mode handling.

This has also been backported to Libhandy and will be available in the next release.

While the Libhandy version is strictly opt-in, Libadwaita flips the switch and follows the preference by default, unless the application opts out. This means that any new applications will support the preference by default – and that supporting it is an expected step when porting an application from GTK3 and Libhandy. The documentation now also includes a guide on how to handle application styles.

Many third party applications have already adopted it by now, and there has been good progress on supporting it in the core GNOME applications – though at the moment it’s unlikely that all of the core applications will support it in GNOME 42. If you maintain a core app, it’s a perfect time to start supporting it in order to avoid that ?️.

Libadwaita GtkInspector page

A new GtkInspector page is also available to help testing the style and high contrast preferences.

Documentation

Like GTK 4 itself, Libadwaita features new documentation using the awesome gi-docgen generator by Emmanuele Bassi.

The docs themselves have been reworked and expanded, and feature new generated screenshots, which all come in light and dark versions to match the documentation pages:

Toasts

Toast saying "'Lorem Ipsum' Deleted", with an Undo button

While in-app notifications aren’t a new pattern by any means, we’ve never really had a ready to use widget. Sure, GdNotification exists, but it leaves a lot of decisions to applications, e.g how to deal with multiple notifications at once, or even what notifications should contain – essentially it only provides the notification style, a close button and a timeout.

A big feature that made GdNotification attractive was the ability to animate its visibility before GTK had a widget for that purpose. Now GtkRevealer exists (which our new widget ironically doesn’t use), and most apps currently use that to re-implement in-app notifications from scratch. This has lead to major inconsistencies between apps, and situations like this:

GNOME Boxes, two undo notifications, awkwardly stacked
GNOME Boxes, two undo notifications

To help fixing this, Maximiliano has implemented a new widget to replace them. Its API is very streamlined, and is modeled after notifications. The widget part is not a notification, but rather a notification area that toasts (which are just generic objects and not widgets) are added into. If multiple toasts are added in a quick succession, they are queued based on their priority.

A big difference from GNotification though is that toasts are mutable – and can be changed after they have been shown. This is useful when using toasts as undo bars, for example.

Animations

Libadwaita animation demo

Manuel Genovés has implemented an animation API as part of his GSoC project. Unfortunately not everything that was planned has been implemented, but we have basic timed animations and spring animations.

Timed animations provide simple transitions from one value to another one in a given time and with a given curve. They can repeat, reverse their direction, and alternate with each iteration.

Spring animations don’t have a fixed duration, and instead use physical properties to describe their curve: damping ratio (or optionally just damping), mass, stiffness, an initial velocity and an epsilon to determine when to stop it. The fact they have a variable initial velocity makes them perfect to animate deceleration after performing a gesture:

AdwLeaflet, AdwFlap and AdwCarousel all use spring animations now, and AdwSwipeTracker provides the final velocity after a swipe is finished, instead of pre-calculated duration.

Unfortunately, due to time constraints, none of the above widgets support overshoot when animating. Since they use a critically damped spring by default (meaning it takes the shortest possible time to reach the end and doesn’t overshoot unless the velocity is very high), it’s not really visible unless you swipe really hard, and it can be fixed after the initial release without any API changes.

Unread Badges

An example of an unread badge

AdwViewSwitcher and related widgets now can display unread badges and not just needs-attention dots. This means they don’t use GtkStack anymore, but a new widget called AdwViewStack. For the most part, it’s a drop-in replacement, although it does trim down the API not necessary for this use case.

Thanks to Frederick Schenk for implementing this!

Application

Nahuel has implemented AdwApplication – a GtkApplication subclass that automatically initializes Libadwaita when used. It also automatically loads styles from GResource relative to the application base path. For example, if your application has org.example.App application ID, it will automatically load /org/example/App/style.css. It also loads style-dark.css, style-hc.css, and style-hc-dark.css, allowing to add styles for dark or high contrast styles only.

Helper Widgets

Libadwaita provides a few widgets to simplify common tasks:

  • GtkHeaderBar in GTK4 does not provide a direct way to set a title and a subtitle, and just shows the window title by default. If you want to have a subtitle or to simply display a title that’s different from the window title – for example, for split header bars – the recommended way to do that is to construct two labels manually. That can be tedious and easy to get wrong. AdwWindowTitle aims to help with that. It can be used as follows:

    <object class="GtkHeaderBar">
      <property name="title-widget">
        <object class="AdwWindowTitle">
          <property name="title">Title</property>
          <property name="subtitle">Subtitle</property>
        </object>
      </property>
    </object>>
    
  • AdwBin is a widget that uses GtkBinLayout, has one child, provides API to manage it, implements GtkBuildable accordingly, implements GtkWidget.compute_expand(), and unparents the child in GObject.dispose(). Applications can subclass it instead of GtkWidget without worrying about those things. It can also be used directly without subclassing it.
  • AdwSplitButton provides an easy way to create a, well, split button that will use the correct appearance in a header bar or a toolbar.
  • AdwButtonContent can be used to create a button with an icon and a label without needing to manually set up the button mnemonic:

    <object class="GtkButton">
      <property name="child">
        <object class="AdwButtonContent">
          <property name="label">_Open</property>
          <property name="icon-name">document-open-symbolic</property>
          <property name="use-underline">True</property>
        </object>
      </property>
    </object>
    

API Cleanups

Large parts of the API have been streamlined. Check out the migration guide for more details.

Some highlights:

  • AdwHeaderBar provides separate properties for controlling window buttons at the 2 ends of the header bar, instead of one controlling both sides. This can be used to implement split header bar layouts and removes the need for HdyHeaderGroup.
  • Ever since HdyWindow we’ve had an easy way to support leaflet swipes spanning both the window and the titlebar without HdyHeaderGroup. All of the features HdyWindow and HdyWindowHandle provided have been added into GTK itself, and have been available since 4.0. In acknowledgement of that, Libadwaita doesn’t include a HdySwipeGroup equivalent.

    It still includes AdwWindow and AdwApplicationWindow as helpers, but it’s very easy to achieve the same result with a GtkWindow.

    <object class="GtkWindow">
      <property name="titlebar">
        <object class="GtkBox">
          <property name="visible">False</property>
        </object>
      </property>
      <property name="child">
        <!-- ... -->
      </property>
    </object>
    
  • AdwComboRow has been completely overhauled and is now basically a GtkDropDown clone. This means it uses the same list item factories, allows setting the models from UI files, etc. One thing it doesn’t provide is binding enums – to replace that, Libadwaita includes AdwEnumListModel.
  • AdwAvatar removes the old complicated image loading API and instead just allows setting a GdkPaintable as a custom image.
  • AdwLeaflet now supports the back/forward mouse buttons and keyboard keys, as well as Alt+arrow shortcuts, in addition to swipe gestures, and the properties controlling that have been renamed to reflect the addition.

What hasn’t made it

About Window

Work-in-progress about window

Even though it was featured in Allan Day’s blog post a few months ago, the new About window Adrien has been working on hasn’t made it into Libadwaita 1.0. There were still unresolved design and API questions, and we decided to wait until the next release to have time to polish it instead of rushing it.

Color API

While overriding colors via @define-color is far simpler than it was before (essentially, copying the entire style of the widgets you want to change, with different colors), it’s still not as easy as it could be.

For example, if an application wants to override its accent color, it needs to override 3 colors. One of them (@accent_color) exists pretty much only for contrast, and also differs between light and dark variants. In an ideal world, this color would be calculated automatically based on @accent_bg_color.

Chris has been working on a programmatic API to manage colors, and that should improve this situation a lot. As with the about window, though, it wasn’t ready in time for 1.0.

Touchpad Swipes

A known regression compared to Libhandy is that swipes in widgets such as AdwLeaflet only work on touchscreen, but not touchpad, if the pointer is above a scrolling view. This is something I really hoped to fix before 1.0, but it’s a surprisingly complex issue. It needs extensive GTK changes in order to be fixable, involving essentially replacing and deprecating the existing API for dealing with scrolling, and it’s not something that should be rushed.

Thanks To

  • Adrien Plazas, Christopher Davis, Frederick Schenk, Manuel Genovés, Maximiliano
  • GNOME design team
  • GTK developers
  • Frederik Feichtmeier, nana-4 and other members of the community

I also want to thank my employer, Purism, for letting me and others work on Libadwaita and GTK to make this happen.

Happy holidays and happy hacking!

Dark Style Preference

TL;DR GNOME will have a system-wide dark style preference in 42. Please update your apps to support it, see the instructions.


Dark style preference in GNOME
Dark style preference in GNOME 42. Separate dark and light screenshots

Lately, I’ve been working on having a proper dark style preference in GNOME. It’s a frequently requested feature, but also hard to get right. elementary UX architect Cassidy James Blaede did a good write-up about this, please read it if you haven’t yet (or watch his GUADEC talk if you prefer a video).

That was more than two years ago. Since then, elementary OS has started shipping an elementary-specific implementation designed in a way that it could be standardized later without many changes. While I could introduce another preference in GNOME, it was a good excuse to standardize it instead.

How does it work?

The idea is more or less exactly same as what elementary OS is doing: there’s a system preference, a tri-state with values: no-preference, prefer-dark, prefer-light. All 3 are just hints — apps are free to follow or not follow them as appropriate. The no-preference and prefer-dark values are exposed in settings, while the prefer-light value is reserved for future use.

Style: default; dark. Preferred visual style for system components. Apps may also choose to follow this preference. Schedule: Disabled; Sunset to Sunrise; Manual. From: 20:00, To: 06:00
Style preferences in elementary OS 6.0

The new preference is defined in the settings portal as the org.freedesktop.appearance.color-scheme key.

xdg-desktop-portal-gnome already implements this key (in the main branch, not in 41), and there are work-in-progress elementary and KDE implementations.

This way it’s not tied to any particular desktop or toolkit — any application can access the settings portal via DBus and read the preference — so applications like Firefox have a canonical location to access the preference from instead of trying to guess it from GTK theme name. Being in the portal also means it’s accessible to Flatpak apps without opening any sandbox holes.

API

Manually accessing the portal is a bit tedious, and so we wrap it into an easy to use API. Here’s where things differ more from the original elementary preference.

elementary exposes the preference in Granite (right now it only works with the elementary preference, there’s a pull request to make it use the portal), and leaves everything else to apps, so using the API looks like this:

var gtk_settings = Gtk.Settings.get_default ();
var granite_settings = Granite.Settings.get_default ();

gtk_settings.gtk_application_prefer_dark_theme = (
    granite_settings.prefers_color_scheme == DARK
);

granite_settings.notify["prefers-color-scheme"].connect (() => {
    gtk_settings.gtk_application_prefer_dark_theme = (
        granite_settings.prefers_color_scheme == DARK
    );
});

It works, but that’s quite verbose, and most apps do either that or prefers_color_scheme != LIGHT, and maybe vary other parts of the UI such as GtkSourceView color schemes.

For GNOME the API is in libadwaita and libhandy, and is vaguely inspired by CSS. The equivalent Vala code using libhandy looks like this:

Hdy.StyleManager.get_default ().color_scheme = PREFER_LIGHT;

The other color schemes are FORCE_LIGHT, PREFER_DARK and FORCE_DARK, as follows:

system \ app force-light prefer-light prefer-dark force-dark
prefer-light light light light dark
default light light dark dark
prefer-dark light dark dark dark

There’s also a read-only boolean property dark that corresponds to whether the UI currently appears dark.

There are also properties to check if the system has a system-wide color scheme preference, to allow apps to keep their dark style switches when running on GNOME 41, but drop them for 42; and, somewhat unrelated, a property to check if the system is currently using high contrast mode instead of it being a theme.

Libadwaita also provides a GtkInspector page to test all of this without changing the system preferences, though there’s no libhandy equivalent.

Libadwaita inspector page

The libadwaita API is already available in alpha 3, the libhandy one is not released yet.

Another difference is — the dark style preference is supported by default if you’re using libadwaita. While in libhandy the default is keeping the previous behavior — apps that were always light remain always light – libadwaita goes ahead and makes following the preference the default. Since it’s not API-stable yet, it’s an acceptable behavior break, same way as macOS and iOS support it automatically when building against new enough platform libraries, but don’t do it otherwise in order to keep existing apps working.

When porting from GTK3 and libhandy to GTK4 and libadwaita, apps are expected to start supporting this or otherwise opt out. When already using older versions of libadwaita, apps are expected to start supporting this when updating to alpha 3. When using libhandy, apps don’t get the support by default, but can explicitly opt in.

Transitions

Another thing libhandy and libadwaita do when switching appearance is they try very hard to block the CSS transitions that would usually occur. These transitions can take a long time and are inconsistent between widgets. For widgets with custom drawn content such as WebKitWebView this can’t work at all, so no point in trying.

An approach that yields much better results is doing the transition on the compositor side — then it works for any content automatically:

It’s still not perfect: GTK3 apps can take a pretty long time when doing this, and it will be noticeable: for example the GTK4 Patterns window on the video changes its appearance immediately while Settings and Web lag behind. The video was recorded in a VM though, and the transition should be smoother on bare metal.

When is it coming?

Most likely GNOME 42 – most of the plumbing has already landed, the main remaining things are:

  • The actual switch in Settings — I made a very simple UI for testing, shown in the video above; but it’s obviously not good enough to be used as is;
  • A switch in gnome-shell, similarly to how Android and iOS have one in Quick Settings or Control Center respectively;
  • The aforementioned compositor-side transition; I have a mostly working implementation shown in the video, but it still needs some work;
  • Day/night scheduling;
  • Synchronizing wallpaper with the preference when possible – for example, forcing the night version for the default timed wallpaper.
  • Supporting it from the applications.

How can you help?

The main area that needs work is the last item.

For core apps, we have an initiative, the goal is to port as many of them before 42 as possible.

The instructions from the initiative also apply to third party apps, although they can update at their own pace.

This applies to non-GTK apps as well — the instructions have an example of accessing the portal manually and following the preference from an SDL app.

Finally, if you maintain a GNOME-adjacent website, it would be nice if it followed the system preference. Some of the webpages do that, e.g. the GNOME 41 release notes or GTK documentation, but most don’t. Incl. this blog, yes.


So, let’s make it happen. For now, happy hacking.

I want to thank elementary folks for kick-starting all of this — it’s unlikely it would have happened otherwise.