From 6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:57:27 +0200 Subject: Adding upstream version 43.5. Signed-off-by: Daniel Baumann --- src/gs-feature-tile.c | 621 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 src/gs-feature-tile.c (limited to 'src/gs-feature-tile.c') diff --git a/src/gs-feature-tile.c b/src/gs-feature-tile.c new file mode 100644 index 0000000..bb8bed4 --- /dev/null +++ b/src/gs-feature-tile.c @@ -0,0 +1,621 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen + * Copyright (C) 2019 Richard Hughes + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include +#include + +#include "gs-feature-tile.h" +#include "gs-layout-manager.h" +#include "gs-common.h" +#include "gs-css.h" + +#define GS_TYPE_FEATURE_TILE_LAYOUT (gs_feature_tile_layout_get_type ()) +G_DECLARE_FINAL_TYPE (GsFeatureTileLayout, gs_feature_tile_layout, GS, FEATURE_TILE_LAYOUT, GsLayoutManager) + +struct _GsFeatureTileLayout +{ + GsLayoutManager parent_instance; + + gboolean narrow_mode; +}; + +G_DEFINE_TYPE (GsFeatureTileLayout, gs_feature_tile_layout, GS_TYPE_LAYOUT_MANAGER) + +enum { + SIGNAL_NARROW_MODE_CHANGED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +gs_feature_tile_layout_allocate (GtkLayoutManager *layout_manager, + GtkWidget *widget, + gint width, + gint height, + gint baseline) +{ + GsFeatureTileLayout *self = GS_FEATURE_TILE_LAYOUT (layout_manager); + gboolean narrow_mode; + + GTK_LAYOUT_MANAGER_CLASS (gs_feature_tile_layout_parent_class)->allocate (layout_manager, + widget, width, height, baseline); + + /* Engage ‘narrow mode’ if the allocation becomes too narrow. The exact + * choice of width is arbitrary here. */ + narrow_mode = (width < 600); + if (self->narrow_mode != narrow_mode) { + self->narrow_mode = narrow_mode; + g_signal_emit (self, signals[SIGNAL_NARROW_MODE_CHANGED], 0, self->narrow_mode); + } +} + +static void +gs_feature_tile_layout_class_init (GsFeatureTileLayoutClass *klass) +{ + GtkLayoutManagerClass *layout_manager_class = GTK_LAYOUT_MANAGER_CLASS (klass); + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + layout_manager_class->allocate = gs_feature_tile_layout_allocate; + + signals [SIGNAL_NARROW_MODE_CHANGED] = + g_signal_new ("narrow-mode-changed", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, + NULL, NULL, g_cclosure_marshal_generic, + G_TYPE_NONE, 1, G_TYPE_BOOLEAN); +} + +static void +gs_feature_tile_layout_init (GsFeatureTileLayout *self) +{ +} + +/* ********************************************************************* */ + +struct _GsFeatureTile +{ + GsAppTile parent_instance; + GtkWidget *stack; + GtkWidget *image; + GtkWidget *title; + GtkWidget *subtitle; + const gchar *markup_cache; /* (unowned) (nullable) */ + GtkCssProvider *tile_provider; /* (owned) (nullable) */ + GtkCssProvider *title_provider; /* (owned) (nullable) */ + GtkCssProvider *subtitle_provider; /* (owned) (nullable) */ + GArray *key_colors_cache; /* (unowned) (nullable) */ + gboolean narrow_mode; + guint refresh_id; +}; + +static void gs_feature_tile_refresh (GsAppTile *self); + +static gboolean +gs_feature_tile_refresh_idle_cb (gpointer user_data) +{ + GsFeatureTile *tile = user_data; + + tile->refresh_id = 0; + + gs_feature_tile_refresh (GS_APP_TILE (tile)); + + return G_SOURCE_REMOVE; +} + +static void +gs_feature_tile_layout_narrow_mode_changed_cb (GtkLayoutManager *layout_manager, + gboolean narrow_mode, + gpointer user_data) +{ + GsFeatureTile *self = GS_FEATURE_TILE (user_data); + + if (self->narrow_mode != narrow_mode && !self->refresh_id) { + self->narrow_mode = narrow_mode; + self->refresh_id = g_idle_add (gs_feature_tile_refresh_idle_cb, self); + } +} + +/* A colour represented in hue, saturation, brightness form; with an additional + * field for its contrast calculated with respect to some external colour. + * + * See https://en.wikipedia.org/wiki/HSL_and_HSV */ +typedef struct +{ + gfloat hue; /* [0.0, 1.0] */ + gfloat saturation; /* [0.0, 1.0] */ + gfloat brightness; /* [0.0, 1.0]; also known as lightness (HSL) or value (HSV) */ + gfloat contrast; /* (0.047, 21] */ +} GsHSBC; + +G_DEFINE_TYPE (GsFeatureTile, gs_feature_tile, GS_TYPE_APP_TILE) + +static void +gs_feature_tile_dispose (GObject *object) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (object); + + if (tile->refresh_id) { + g_source_remove (tile->refresh_id); + tile->refresh_id = 0; + } + + g_clear_object (&tile->tile_provider); + g_clear_object (&tile->title_provider); + g_clear_object (&tile->subtitle_provider); + + G_OBJECT_CLASS (gs_feature_tile_parent_class)->dispose (object); +} + +/* These are subjectively chosen. See below. */ +static const gfloat min_valid_saturation = 0.5; +static const gfloat max_valid_saturation = 0.85; + +/* The minimum absolute contrast ratio between the foreground and background + * colours, from WCAG: + * https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html */ +static const gfloat min_abs_contrast = 4.5; + +/* Sort two candidate background colours for the feature tile, ranking them by + * suitability for being chosen as the background colour, with the most suitable + * first. + * + * There are several criteria being used here: + * 1. First, colours are sorted by whether their saturation is in the range + * [0.5, 0.85], which is a subjectively-chosen range of ‘light, but not too + * saturated’ colours. + * 2. Colours with saturation in that valid range are then sorted by contrast, + * with higher contrast being preferred. The contrast is calculated against + * an external colour by the caller. + * 3. Colours with saturation outside that valid range are sorted by their + * absolute distance from the range, so that colours which are nearer to + * having a valid saturation are preferred. This is useful in the case where + * none of the key colours in this array have valid saturations; the caller + * will want the one which is closest to being valid. + */ +static gboolean +saturation_is_valid (const GsHSBC *hsbc, + gfloat *distance_from_valid_range) +{ + *distance_from_valid_range = (hsbc->saturation > max_valid_saturation) ? hsbc->saturation - max_valid_saturation : min_valid_saturation - hsbc->saturation; + return (hsbc->saturation >= min_valid_saturation && hsbc->saturation <= max_valid_saturation); +} + +static gint +colors_sort_cb (gconstpointer a, + gconstpointer b) +{ + const GsHSBC *hsbc_a = a; + const GsHSBC *hsbc_b = b; + gfloat hsbc_a_distance_from_range, hsbc_b_distance_from_range; + gboolean hsbc_a_saturation_in_range = saturation_is_valid (hsbc_a, &hsbc_a_distance_from_range); + gboolean hsbc_b_saturation_in_range = saturation_is_valid (hsbc_b, &hsbc_b_distance_from_range); + + if (hsbc_a_saturation_in_range && !hsbc_b_saturation_in_range) + return -1; + else if (!hsbc_a_saturation_in_range && hsbc_b_saturation_in_range) + return 1; + else if (!hsbc_a_saturation_in_range && !hsbc_b_saturation_in_range) + return hsbc_a_distance_from_range - hsbc_b_distance_from_range; + else + return ABS (hsbc_b->contrast) - ABS (hsbc_a->contrast); +} + +static gint +colors_sort_contrast_cb (gconstpointer a, + gconstpointer b) +{ + const GsHSBC *hsbc_a = a; + const GsHSBC *hsbc_b = b; + + return hsbc_b->contrast - hsbc_a->contrast; +} + +/* Calculate the relative luminance of @colour. This is [0.0, 1.0], where 0.0 is + * the darkest black, and 1.0 is the lightest white. + * + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef */ +static gfloat +relative_luminance (const GsHSBC *colour) +{ + gfloat red, green, blue; + gfloat r, g, b; + gfloat luminance; + + /* Convert to sRGB */ + gtk_hsv_to_rgb (colour->hue, colour->saturation, colour->brightness, + &red, &green, &blue); + + r = (red <= 0.03928) ? red / 12.92 : pow ((red + 0.055) / 1.055, 2.4); + g = (green <= 0.03928) ? green / 12.92 : pow ((green + 0.055) / 1.055, 2.4); + b = (blue <= 0.03928) ? blue / 12.92 : pow ((blue + 0.055) / 1.055, 2.4); + + luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + g_assert (luminance >= 0.0 && luminance <= 1.0); + return luminance; +} + +/* Calculate the WCAG contrast ratio between the two colours. The returned ratio + * is in the range (0.047, 21]. + * + * https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef */ +static gfloat +wcag_contrast (const GsHSBC *foreground, + const GsHSBC *background) +{ + const GsHSBC *lighter, *darker; + gfloat ratio; + + if (foreground->brightness >= background->brightness) { + lighter = foreground; + darker = background; + } else { + lighter = background; + darker = foreground; + } + + ratio = (relative_luminance (lighter) + 0.05) / (relative_luminance (darker) + 0.05); + g_assert (ratio > 0.047 && ratio <= 21); + return ratio; +} + +/* Calculate a new brightness value for @background which improves its contrast + * (as calculated using wcag_contrast()) with @foreground to at least + * @desired_contrast. + * + * The return value is in the range [0.0, 1.0]. + */ +static gfloat +wcag_contrast_find_brightness (const GsHSBC *foreground, + const GsHSBC *background, + gfloat desired_contrast) +{ + GsHSBC modified_background; + + g_assert (desired_contrast > 0.047 && desired_contrast <= 21); + + /* This is an optimisation problem of modifying @background until + * the WCAG contrast is at least @desired_contrast. There might be a + * closed-form solution to this but I can’t be bothered to work it out + * right now. An optimisation loop should work. + * + * wcag_contrast() compares the lightest and darkest of the two colours, + * so ensure the background brightness is modified in the correct + * direction (increased or decreased) depending on whether the + * foreground colour is originally the brighter. This gives the largest + * search space for the background colour brightness, and ensures the + * optimisation works with dark and light themes. */ + for (modified_background = *background; + modified_background.brightness >= 0.0 && + modified_background.brightness <= 1.0 && + wcag_contrast (foreground, &modified_background) < desired_contrast; + modified_background.brightness += ((foreground->brightness > 0.5) ? -0.1 : 0.1)) { + /* Nothing to do here */ + } + + return CLAMP (modified_background.brightness, 0.0, 1.0); +} + +static void +gs_feature_tile_refresh (GsAppTile *self) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (self); + GsApp *app = gs_app_tile_get_app (self); + const gchar *markup = NULL; + g_autofree gchar *name = NULL; + GtkStyleContext *context; + g_autoptr(GIcon) icon = NULL; + guint icon_size; + + if (app == NULL) + return; + + gtk_stack_set_visible_child_name (GTK_STACK (tile->stack), "content"); + + /* Set the narrow mode. */ + context = gtk_widget_get_style_context (GTK_WIDGET (self)); + if (tile->narrow_mode) + gtk_style_context_add_class (context, "narrow"); + else + gtk_style_context_remove_class (context, "narrow"); + + /* Update the icon. Try a 160px version if not in narrow mode, and it’s + * available; otherwise use 128px. */ + if (!tile->narrow_mode) { + icon = gs_app_get_icon_for_size (app, + 160, + gtk_widget_get_scale_factor (tile->image), + NULL); + icon_size = 160; + } + if (icon == NULL) { + icon = gs_app_get_icon_for_size (app, + 128, + gtk_widget_get_scale_factor (tile->image), + NULL); + icon_size = 128; + } + + if (icon != NULL) { + gtk_image_set_from_gicon (GTK_IMAGE (tile->image), icon); + gtk_image_set_pixel_size (GTK_IMAGE (tile->image), icon_size); + gtk_widget_show (tile->image); + } else { + gtk_widget_hide (tile->image); + } + + /* Update text and let it wrap if the widget is narrow. */ + gtk_label_set_label (GTK_LABEL (tile->title), gs_app_get_name (app)); + gtk_label_set_label (GTK_LABEL (tile->subtitle), gs_app_get_summary (app)); + + gtk_label_set_wrap (GTK_LABEL (tile->subtitle), tile->narrow_mode); + gtk_label_set_lines (GTK_LABEL (tile->subtitle), tile->narrow_mode ? 2 : 1); + + /* perhaps set custom css; cache it so that images don’t get reloaded + * unnecessarily. The custom CSS is direction-dependent, and will be + * reloaded when the direction changes. If RTL CSS isn’t set, fall back + * to the LTR CSS. */ + if (gtk_widget_get_direction (GTK_WIDGET (self)) == GTK_TEXT_DIR_RTL) + markup = gs_app_get_metadata_item (app, "GnomeSoftware::FeatureTile-css-rtl"); + if (markup == NULL) + markup = gs_app_get_metadata_item (app, "GnomeSoftware::FeatureTile-css"); + + if (tile->markup_cache != markup && markup != NULL) { + g_autoptr(GsCss) css = gs_css_new (); + g_autofree gchar *modified_markup = gs_utils_set_key_colors_in_css (markup, app); + if (modified_markup != NULL) + gs_css_parse (css, modified_markup, NULL); + gs_utils_widget_set_css (GTK_WIDGET (tile), &tile->tile_provider, "feature-tile", + gs_css_get_markup_for_id (css, "tile")); + gs_utils_widget_set_css (tile->title, &tile->title_provider, "feature-tile-name", + gs_css_get_markup_for_id (css, "name")); + gs_utils_widget_set_css (tile->subtitle, &tile->subtitle_provider, "feature-tile-subtitle", + gs_css_get_markup_for_id (css, "summary")); + tile->markup_cache = markup; + } else if (markup == NULL) { + GArray *key_colors = gs_app_get_key_colors (app); + g_autofree gchar *css = NULL; + + /* If there is no override CSS for the app, default to a solid + * background colour based on the app’s key colors. + * + * Choose an arbitrary key color from the app’s key colors, and + * ensure that it’s: + * - a light, not too saturated version of the dominant color + * of the icon + * - always light enough that grey text is visible on it + * + * Cache the result until the app’s key colours change, as the + * amount of calculation going on here is not entirely trivial. + */ + if (key_colors != tile->key_colors_cache) { + g_autoptr(GArray) colors = NULL; + GdkRGBA fg_rgba; + gboolean fg_rgba_valid; + GsHSBC fg_hsbc; + const GsHSBC *chosen_hsbc; + GsHSBC chosen_hsbc_modified; + gboolean use_chosen_hsbc = FALSE; + + /* Look up the foreground colour for the feature tile, + * which is the colour of the text. This should always + * be provided as a named colour by the theme. + * + * Knowing the foreground colour allows calculation of + * the contrast between candidate background colours and + * the foreground which will be rendered on top of them. + * + * We want to choose a background colour with at least + * @min_abs_contrast contrast with the foreground, so + * that the text is legible. + */ + fg_rgba_valid = gtk_style_context_lookup_color (context, "theme_fg_color", &fg_rgba); + g_assert (fg_rgba_valid); + + gtk_rgb_to_hsv (fg_rgba.red, fg_rgba.green, fg_rgba.blue, + &fg_hsbc.hue, &fg_hsbc.saturation, &fg_hsbc.brightness); + + g_debug ("FG color: RGB: (%f, %f, %f), HSB: (%f, %f, %f)", + fg_rgba.red, fg_rgba.green, fg_rgba.blue, + fg_hsbc.hue, fg_hsbc.saturation, fg_hsbc.brightness); + + /* Convert all the RGBA key colours to HSB, and + * calculate their contrast against the foreground + * colour. + * + * The contrast is calculated as the Weber contrast, + * which is valid for small amounts of foreground colour + * (i.e. text) against larger background areas. Contrast + * is strictly calculated using luminance, but it’s OK + * to subjectively calculate it using brightness, as + * brightness is the subjective impression of luminance. + */ + if (key_colors != NULL) + colors = g_array_sized_new (FALSE, FALSE, sizeof (GsHSBC), key_colors->len); + + g_debug ("Candidate background colors for %s:", gs_app_get_id (app)); + for (guint i = 0; key_colors != NULL && i < key_colors->len; i++) { + const GdkRGBA *rgba = &g_array_index (key_colors, GdkRGBA, i); + GsHSBC hsbc; + + gtk_rgb_to_hsv (rgba->red, rgba->green, rgba->blue, + &hsbc.hue, &hsbc.saturation, &hsbc.brightness); + hsbc.contrast = wcag_contrast (&fg_hsbc, &hsbc); + g_array_append_val (colors, hsbc); + + g_debug (" • RGB: (%f, %f, %f), HSB: (%f, %f, %f), contrast: %f", + rgba->red, rgba->green, rgba->blue, + hsbc.hue, hsbc.saturation, hsbc.brightness, + hsbc.contrast); + } + + /* Sort the candidate background colours to find the + * most appropriate one. */ + g_array_sort (colors, colors_sort_cb); + + /* If the developer/distro has provided override colours, + * use them. If there’s more than one override colour, + * use the one with the highest contrast with the + * foreground colour, unmodified. If there’s only one, + * modify it as below. + * + * If there are no override colours, take the top colour + * after sorting above. If it’s not good enough, modify + * its brightness to improve the contrast, and clamp its + * saturation to the valid range. + * + * If there are no colours, fall through and leave @css + * as %NULL. */ + if (gs_app_get_user_key_colors (app) && + colors != NULL && + colors->len > 1) { + g_array_sort (colors, colors_sort_contrast_cb); + + chosen_hsbc = &g_array_index (colors, GsHSBC, 0); + chosen_hsbc_modified = *chosen_hsbc; + + use_chosen_hsbc = TRUE; + } else if (colors != NULL && colors->len > 0) { + chosen_hsbc = &g_array_index (colors, GsHSBC, 0); + chosen_hsbc_modified = *chosen_hsbc; + + chosen_hsbc_modified.saturation = CLAMP (chosen_hsbc->saturation, min_valid_saturation, max_valid_saturation); + + if (chosen_hsbc->contrast >= -min_abs_contrast && + chosen_hsbc->contrast <= min_abs_contrast) + chosen_hsbc_modified.brightness = wcag_contrast_find_brightness (&fg_hsbc, &chosen_hsbc_modified, min_abs_contrast); + + use_chosen_hsbc = TRUE; + } + + if (use_chosen_hsbc) { + GdkRGBA chosen_rgba; + + gtk_hsv_to_rgb (chosen_hsbc_modified.hue, + chosen_hsbc_modified.saturation, + chosen_hsbc_modified.brightness, + &chosen_rgba.red, &chosen_rgba.green, &chosen_rgba.blue); + + g_debug ("Chosen background colour for %s (saturation %s, brightness %s): RGB: (%f, %f, %f), HSB: (%f, %f, %f)", + gs_app_get_id (app), + (chosen_hsbc_modified.saturation == chosen_hsbc->saturation) ? "not modified" : "modified", + (chosen_hsbc_modified.brightness == chosen_hsbc->brightness) ? "not modified" : "modified", + chosen_rgba.red, chosen_rgba.green, chosen_rgba.blue, + chosen_hsbc_modified.hue, chosen_hsbc_modified.saturation, chosen_hsbc_modified.brightness); + + css = g_strdup_printf ("background-color: rgb(%.0f,%.0f,%.0f);", + chosen_rgba.red * 255.f, + chosen_rgba.green * 255.f, + chosen_rgba.blue * 255.f); + } + + gs_utils_widget_set_css (GTK_WIDGET (tile), &tile->tile_provider, "feature-tile", css); + gs_utils_widget_set_css (tile->title, &tile->title_provider, "feature-tile-name", NULL); + gs_utils_widget_set_css (tile->subtitle, &tile->subtitle_provider, "feature-tile-subtitle", NULL); + + tile->key_colors_cache = key_colors; + } + } + + switch (gs_app_get_state (app)) { + case GS_APP_STATE_INSTALLED: + case GS_APP_STATE_REMOVING: + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + name = g_strdup_printf ("%s (%s)", + gs_app_get_name (app), + C_("Single app", "Installed")); + break; + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_INSTALLING: + default: + name = g_strdup (gs_app_get_name (app)); + break; + } + + if (name != NULL) { + gtk_accessible_update_property (GTK_ACCESSIBLE (tile), + GTK_ACCESSIBLE_PROPERTY_LABEL, name, + GTK_ACCESSIBLE_PROPERTY_DESCRIPTION, gs_app_get_summary (app), + -1); + } +} + +static void +gs_feature_tile_direction_changed (GtkWidget *widget, GtkTextDirection previous_direction) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (widget); + + gs_feature_tile_refresh (GS_APP_TILE (tile)); +} + +static void +gs_feature_tile_css_changed (GtkWidget *widget, + GtkCssStyleChange *css_change) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (widget); + + /* Clear the key colours cache, as the tile background colour will + * potentially need recalculating if the widget’s foreground colour has + * changed. */ + tile->key_colors_cache = NULL; + + gs_feature_tile_refresh (GS_APP_TILE (tile)); + + GTK_WIDGET_CLASS (gs_feature_tile_parent_class)->css_changed (widget, css_change); +} + +static void +gs_feature_tile_init (GsFeatureTile *tile) +{ + GtkLayoutManager *layout_manager; + + gtk_widget_init_template (GTK_WIDGET (tile)); + + layout_manager = gtk_widget_get_layout_manager (GTK_WIDGET (tile)); + g_warn_if_fail (layout_manager != NULL); + g_signal_connect_object (layout_manager, "narrow-mode-changed", + G_CALLBACK (gs_feature_tile_layout_narrow_mode_changed_cb), tile, 0); +} + +static void +gs_feature_tile_class_init (GsFeatureTileClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + GsAppTileClass *app_tile_class = GS_APP_TILE_CLASS (klass); + + object_class->dispose = gs_feature_tile_dispose; + + widget_class->css_changed = gs_feature_tile_css_changed; + widget_class->direction_changed = gs_feature_tile_direction_changed; + + app_tile_class->refresh = gs_feature_tile_refresh; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-feature-tile.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, stack); + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, image); + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, title); + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, subtitle); + + gtk_widget_class_set_css_name (widget_class, "feature-tile"); + gtk_widget_class_set_layout_manager_type (widget_class, GS_TYPE_FEATURE_TILE_LAYOUT); +} + +GtkWidget * +gs_feature_tile_new (GsApp *app) +{ + return g_object_new (GS_TYPE_FEATURE_TILE, + "vexpand", FALSE, + "app", app, + NULL); +} -- cgit v1.2.3