diff --git a/lib/gs-app.c b/lib/gs-app.c index 4be4e3a7a878bb9962c4454268d95a94a71489bd..32b717d1f5ec6cd4846f98229547ccd8472fa114 100644 --- a/lib/gs-app.c +++ b/lib/gs-app.c @@ -44,6 +44,7 @@ #include "gs-app-private.h" #include "gs-desktop-data.h" #include "gs-enums.h" +#include "gs-key-colors.h" #include "gs-os-release.h" #include "gs-plugin.h" #include "gs-utils.h" @@ -75,7 +76,7 @@ typedef struct GsAppQuality description_quality; GPtrArray *screenshots; GPtrArray *categories; - GPtrArray *key_colors; /* (nullable) (element-type GdkRGBA) */ + GArray *key_colors; /* (nullable) (element-type GdkRGBA) */ GHashTable *urls; GHashTable *launchables; gchar *url_missing; @@ -689,7 +690,7 @@ gs_app_to_string_append (GsApp *app, GString *str) gs_app_kv_lpad (str, "category", tmp); } for (i = 0; priv->key_colors != NULL && i < priv->key_colors->len; i++) { - GdkRGBA *color = g_ptr_array_index (priv->key_colors, i); + GdkRGBA *color = &g_array_index (priv->key_colors, GdkRGBA, i); g_autofree gchar *key = NULL; key = g_strdup_printf ("key-color-%02u", i); gs_app_kv_printf (str, key, "%.0f,%.0f,%.0f", @@ -3589,7 +3590,7 @@ gs_app_set_metadata (GsApp *app, const gchar *key, const gchar *value) * Gets some metadata for the application. * Is is expected that plugins namespace any plugin-specific metadata. * - * Returns: a string, or %NULL for unset + * Returns: (transfer none) (nullable): a variant, or %NULL for unset * * Since: 3.26 **/ @@ -4075,140 +4076,59 @@ gs_app_get_is_update_downloaded (GsApp *app) return priv->is_update_downloaded; } -typedef struct { - guint8 R; - guint8 G; - guint8 B; -} CdColorRGB8; - -static guint32 -cd_color_rgb8_to_uint32 (CdColorRGB8 *rgb) -{ - return (guint32) rgb->R | - (guint32) rgb->G << 8 | - (guint32) rgb->B << 16; -} - -typedef struct { - GdkRGBA color; - guint cnt; -} GsColorBin; - -static gint -gs_color_bin_sort_cb (gconstpointer a, gconstpointer b) -{ - GsColorBin *s1 = (GsColorBin *) a; - GsColorBin *s2 = (GsColorBin *) b; - if (s1->cnt < s2->cnt) - return 1; - if (s1->cnt > s2->cnt) - return -1; - return 0; -} - -/* convert range of 0..255 to 0..1 */ -static inline gdouble -_convert_from_rgb8 (guchar val) -{ - return (gdouble) val / 255.f; -} - -static void -key_colors_set_for_pixbuf (GsApp *app, GdkPixbuf *pb, guint number) -{ - gint rowstride, n_channels; - gint x, y, width, height; - guchar *pixels, *p; - guint bin_size = 200; - guint i; - guint number_of_bins; - - /* go through each pixel */ - n_channels = gdk_pixbuf_get_n_channels (pb); - rowstride = gdk_pixbuf_get_rowstride (pb); - pixels = gdk_pixbuf_get_pixels (pb); - width = gdk_pixbuf_get_width (pb); - height = gdk_pixbuf_get_height (pb); - - for (bin_size = 250; bin_size > 0; bin_size -= 2) { - g_autoptr(GHashTable) hash = NULL; - hash = g_hash_table_new_full (g_direct_hash, g_direct_equal, - NULL, g_free); - for (y = 0; y < height; y++) { - for (x = 0; x < width; x++) { - CdColorRGB8 tmp; - GsColorBin *s; - gpointer key; - - /* disregard any with alpha */ - p = pixels + y * rowstride + x * n_channels; - if (p[3] != 255) - continue; - - /* find in cache */ - tmp.R = (guint8) (p[0] / bin_size); - tmp.G = (guint8) (p[1] / bin_size); - tmp.B = (guint8) (p[2] / bin_size); - key = GUINT_TO_POINTER (cd_color_rgb8_to_uint32 (&tmp)); - s = g_hash_table_lookup (hash, key); - if (s != NULL) { - s->color.red += _convert_from_rgb8 (p[0]); - s->color.green += _convert_from_rgb8 (p[1]); - s->color.blue += _convert_from_rgb8 (p[2]); - s->cnt++; - continue; - } - - /* add to hash table */ - s = g_new0 (GsColorBin, 1); - s->color.red = _convert_from_rgb8 (p[0]); - s->color.green = _convert_from_rgb8 (p[1]); - s->color.blue = _convert_from_rgb8 (p[2]); - s->color.alpha = 1.0; - s->cnt = 1; - g_hash_table_insert (hash, key, s); - } - } - - number_of_bins = g_hash_table_size (hash); - if (number_of_bins >= number) { - g_autoptr(GList) values = NULL; - - /* order by most popular */ - values = g_hash_table_get_values (hash); - values = g_list_sort (values, gs_color_bin_sort_cb); - for (GList *l = values; l != NULL; l = l->next) { - GsColorBin *s = l->data; - g_autofree GdkRGBA *color = g_new0 (GdkRGBA, 1); - color->red = s->color.red / s->cnt; - color->green = s->color.green / s->cnt; - color->blue = s->color.blue / s->cnt; - gs_app_add_key_color (app, color); - } - return; - } - } - - /* the algorithm failed, so just return a monochrome ramp */ - for (i = 0; i < 3; i++) { - g_autofree GdkRGBA *color = g_new0 (GdkRGBA, 1); - color->red = (gdouble) i / 3.f; - color->green = color->red; - color->blue = color->red; - color->alpha = 1.0f; - gs_app_add_key_color (app, color); - } -} - static void calculate_key_colors (GsApp *app) { GsAppPrivate *priv = gs_app_get_instance_private (app); g_autoptr(GdkPixbuf) pb_small = NULL; + const gchar *overrides_str; /* Lazily create the array */ if (priv->key_colors == NULL) - priv->key_colors = g_ptr_array_new_with_free_func ((GDestroyNotify) gdk_rgba_free); + priv->key_colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA)); + + /* Look for an override first. Parse and use it if possible. This is + * typically specified in the appdata for an app as: + * |[ + * + * + * [(124, 53, 77), (99, 16, 0)] + * + * + * ]| + */ + overrides_str = gs_app_get_metadata_item (app, "GnomeSoftware::key-colors"); + if (overrides_str != NULL) { + g_autoptr(GVariant) overrides = NULL; + g_autoptr(GError) local_error = NULL; + + overrides = g_variant_parse (G_VARIANT_TYPE ("a(yyy)"), + overrides_str, + NULL, + NULL, + &local_error); + + if (overrides != NULL && g_variant_n_children (overrides) > 0) { + GVariantIter iter; + guint8 red, green, blue; + + g_variant_iter_init (&iter, overrides); + while (g_variant_iter_loop (&iter, "(yyy)", &red, &green, &blue)) { + GdkRGBA rgba; + rgba.red = (gdouble) red / 255.0; + rgba.green = (gdouble) green / 255.0; + rgba.blue = (gdouble) blue / 255.0; + rgba.alpha = 1.0; + g_array_append_val (priv->key_colors, rgba); + } + + return; + } else { + g_warning ("Invalid value for GnomeSoftware::key-colors for %s: %s", + gs_app_get_id (app), local_error->message); + /* fall through */ + } + } /* no pixbuf */ pb_small = gs_app_load_pixbuf (app, 32); @@ -4218,7 +4138,8 @@ calculate_key_colors (GsApp *app) } /* get a list of key colors */ - key_colors_set_for_pixbuf (app, pb_small, 10); + g_clear_pointer (&priv->key_colors, g_array_unref); + priv->key_colors = gs_calculate_key_colors (pb_small); } /** @@ -4229,9 +4150,9 @@ calculate_key_colors (GsApp *app) * * Returns: (element-type GdkRGBA) (transfer none): a list * - * Since: 3.22 + * Since: 40 **/ -GPtrArray * +GArray * gs_app_get_key_colors (GsApp *app) { GsAppPrivate *priv = gs_app_get_instance_private (app); @@ -4250,17 +4171,17 @@ gs_app_get_key_colors (GsApp *app) * * Sets the key colors used in the application icon. * - * Since: 3.22 + * Since: 40 **/ void -gs_app_set_key_colors (GsApp *app, GPtrArray *key_colors) +gs_app_set_key_colors (GsApp *app, GArray *key_colors) { GsAppPrivate *priv = gs_app_get_instance_private (app); g_autoptr(GMutexLocker) locker = NULL; g_return_if_fail (GS_IS_APP (app)); g_return_if_fail (key_colors != NULL); locker = g_mutex_locker_new (&priv->mutex); - if (_g_set_ptr_array (&priv->key_colors, key_colors)) + if (_g_set_array (&priv->key_colors, key_colors)) gs_app_queue_notify (app, obj_props[PROP_KEY_COLORS]); } @@ -4282,9 +4203,9 @@ gs_app_add_key_color (GsApp *app, GdkRGBA *key_color) /* Lazily create the array */ if (priv->key_colors == NULL) - priv->key_colors = g_ptr_array_new_with_free_func ((GDestroyNotify) gdk_rgba_free); + priv->key_colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA)); - g_ptr_array_add (priv->key_colors, gdk_rgba_copy (key_color)); + g_array_append_val (priv->key_colors, *key_color); gs_app_queue_notify (app, obj_props[PROP_KEY_COLORS]); } @@ -4871,7 +4792,7 @@ gs_app_finalize (GObject *object) g_free (priv->management_plugin); g_hash_table_unref (priv->metadata); g_ptr_array_unref (priv->categories); - g_clear_pointer (&priv->key_colors, g_ptr_array_unref); + g_clear_pointer (&priv->key_colors, g_array_unref); g_clear_object (&priv->cancellable); if (priv->local_file != NULL) g_object_unref (priv->local_file); @@ -5022,7 +4943,7 @@ gs_app_class_init (GsAppClass *klass) * GsApp:key-colors: */ obj_props[PROP_KEY_COLORS] = g_param_spec_boxed ("key-colors", NULL, NULL, - G_TYPE_PTR_ARRAY, G_PARAM_READWRITE); + G_TYPE_ARRAY, G_PARAM_READWRITE); /** * GsApp:is-update-downloaded: diff --git a/lib/gs-app.h b/lib/gs-app.h index acace9ca0b12be28777ba69ea1bf87daee02157e..582140ea264a5d5387cceec7bae52f0f816dccf1 100644 --- a/lib/gs-app.h +++ b/lib/gs-app.h @@ -419,9 +419,9 @@ void gs_app_set_release_date (GsApp *app, GPtrArray *gs_app_get_categories (GsApp *app); void gs_app_set_categories (GsApp *app, GPtrArray *categories); -GPtrArray *gs_app_get_key_colors (GsApp *app); +GArray *gs_app_get_key_colors (GsApp *app); void gs_app_set_key_colors (GsApp *app, - GPtrArray *key_colors); + GArray *key_colors); void gs_app_add_key_color (GsApp *app, GdkRGBA *key_color); void gs_app_set_is_update_downloaded (GsApp *app, diff --git a/lib/gs-category-manager.c b/lib/gs-category-manager.c index adf7395ab1bf20c9c3da311d27284853bff6afae..0e372644a79a239a1401bcb993e2bd96bcbf1abc 100644 --- a/lib/gs-category-manager.c +++ b/lib/gs-category-manager.c @@ -1,7 +1,7 @@ /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- * vi:set noexpandtab tabstop=8 shiftwidth=8: * - * Copyright (C) 2021 Endless OS Foundation, Inc + * Copyright (C) 2021 Endless OS Foundation LLC * * Author: Philip Withnall * diff --git a/lib/gs-category-manager.h b/lib/gs-category-manager.h index 1b7f6e52d2424d836e8ce045010f30c657d97b5b..8e85fa361d7bef0b819d737d2615c169138ea398 100644 --- a/lib/gs-category-manager.h +++ b/lib/gs-category-manager.h @@ -1,7 +1,7 @@ /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- * vi:set noexpandtab tabstop=8 shiftwidth=8: * - * Copyright (C) 2021 Endless OS Foundation, Inc + * Copyright (C) 2021 Endless OS Foundation LLC * * Author: Philip Withnall * diff --git a/lib/gs-key-colors.c b/lib/gs-key-colors.c new file mode 100644 index 0000000000000000000000000000000000000000..95cf53b151df727494651d1ca82b8c1420ea284a --- /dev/null +++ b/lib/gs-key-colors.c @@ -0,0 +1,307 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes + * Copyright (C) 2013 Matthias Clasen + * Copyright (C) 2014-2018 Kalev Lember + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Authors: + * - Richard Hughes + * - Kalev Lember + * - Philip Withnall + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-key-colors + * @short_description: Helper functions for calculating key colors + * + * Key colors are RGB colors which represent an app, and they are derived from + * the app’s icon, or manually specified as an override. + * + * Use gs_calculate_key_colors() to calculate the key colors from an app’s icon. + * + * Since: 40 + */ + +#include "config.h" + +#include +#include +#include + +#include "gs-key-colors.h" + +/* Hard-code the number of clusters to split the icon color space into. This + * gives the maximum number of key colors returned for an icon. This number has + * been chosen by examining 1000 icons to subjectively see how many key colors + * each has. The number of key colors ranged from 1 to 6, but the mode was + * definitely 3. */ +const guint n_clusters = 3; + +/* Discard pixels with less than this level of alpha. Almost all icons have a + * transparent background/border at 100% transparency, and a blending fringe + * with some intermediate level of transparency which should be ignored for + * choosing key colors. A number of icons have partially-transparent colored + * sections in the main body of the icon, which should be used if they’re above + * this threshold. About 1% of icons have no completely opaque pixels, so we + * can’t discard non-opaque pixels entirely. */ +const guint minimum_alpha = 0.5 * 255; + +typedef struct { + guint8 red; + guint8 green; + guint8 blue; +} Pixel8; + +typedef struct { + Pixel8 color; + union { + guint8 alpha; + guint8 cluster; + }; +} ClusterPixel8; + +typedef struct { + guint red; + guint green; + guint blue; + guint n_members; +} CentroidAccumulator; + +static inline ClusterPixel8 * +get_pixel (guint8 *pixels, + guint x, + guint y, + guint rowstride, + guint n_channels) +{ + return (ClusterPixel8 *) (pixels + y * rowstride + x * n_channels); +} + +static inline guint +color_distance (const Pixel8 *a, + const Pixel8 *b) +{ + /* Take the absolute value rather than the square root to save some + * time, as the caller is comparing distances. + * + * The arithmetic here can’t overflow, as the R/G/B components have a + * maximum value of 255 but the arithmetic is done in (at least) 32-bit + * variables.*/ + gint dr = b->red - a->red; + gint dg = b->green - a->green; + gint db = b->blue - a->blue; + + return abs (dr * dr + dg * dg + db * db); +} + +/* NOTE: This has to return stable results when more than one cluster is + * equidistant from the @pixel, or the k_means() function may not terminate. */ +static inline gsize +nearest_cluster (const Pixel8 *pixel, + const Pixel8 *cluster_centres, + gsize n_cluster_centres) +{ + gsize nearest_cluster = 0; + guint nearest_cluster_distance = color_distance (&cluster_centres[0], pixel); + + for (gsize i = 1; i < n_cluster_centres; i++) { + guint distance = color_distance (&cluster_centres[i], pixel); + if (distance < nearest_cluster_distance) { + nearest_cluster = i; + nearest_cluster_distance = distance; + } + } + + return nearest_cluster; +} + +/* Extract the key colors from @pb by clustering the pixels in RGB space. + * Clustering is done using k-means, with initialisation using a + * Random Partition. + * + * This approach can be thought of as plotting every pixel in @pb in a + * three-dimensional color space, with red, green and blue axes (alpha is + * clipped to 0 (pixel is ignored) or 1 (pixel is used)). The key colors for + * the image are the ones where a large number of pixels are plotted in a group + * in the color space — either a lot of pixels with an identical color + * (repeated use of exactly the same color in the image) or a lot of pixels in + * a rough group (use of a lot of similar shades of the same color in the + * image). + * + * By transforming to a color space, information about the X and Y positions of + * each color is ignored, so a thin outline in the image of a single color + * will appear in the color space as a cluster, just as a contiguous block of + * one color would. + * + * The k-means clustering algorithm is then used to find these clusters. k-means + * is used, rather than (say) principal component analysis, because it + * inherently calculates the centroid for each cluster. In a color space, the + * centroid is itself a color, which can then be used as the key color to + * return. + * + * The number of clusters is limited to @n_clusters, as a subjective survey of + * 1000 icons found that they commonly used this number of key colors. + * + * Various other shortcuts have been taken which make this approach quite + * specific to key color extraction from icons, with the aim of making it + * faster. That’s fine — it doesn’t matter if the results this function produces + * are optimal, only that they’re good enough. */ +static void +k_means (GArray *colors, + GdkPixbuf *pb) +{ + gint rowstride, n_channels; + gint width, height; + guint8 *raw_pixels; + ClusterPixel8 *pixels; + const ClusterPixel8 *pixels_end; + Pixel8 cluster_centres[n_clusters]; + CentroidAccumulator cluster_accumulators[n_clusters]; + guint n_assignments_changed; + guint n_iterations = 0; + guint assignments_termination_limit; + + n_channels = gdk_pixbuf_get_n_channels (pb); + rowstride = gdk_pixbuf_get_rowstride (pb); + raw_pixels = gdk_pixbuf_get_pixels (pb); + width = gdk_pixbuf_get_width (pb); + height = gdk_pixbuf_get_height (pb); + + /* The pointer arithmetic over pixels can be simplified if we can assume + * there are no gaps in the @raw_pixels data. Since the caller is + * downsizing the #GdkPixbuf, this is a reasonable assumption. */ + g_assert (rowstride == width * n_channels); + g_assert (n_channels == 4); + + pixels = (ClusterPixel8 *) raw_pixels; + pixels_end = &pixels[height * width]; + + /* Initialise the clusters using the Random Partition method: randomly + * assign a starting cluster to each pixel. + * + * The Forgy method (choosing random pixels as the starting cluster + * centroids) is not appropriate as the checks required to make sure + * they aren’t transparent or duplicated colors mean that the + * initialisation step may never complete. Consider the case of an icon + * which is a block of solid color. */ + for (ClusterPixel8 *p = pixels; p < pixels_end; p++) { + if (p->alpha < minimum_alpha) + p->cluster = G_N_ELEMENTS (cluster_centres); + else + p->cluster = g_random_int_range (0, G_N_ELEMENTS (cluster_centres)); + } + + /* Iterate until every cluster is relatively settled. This is determined + * by the number of pixels whose assignment to a cluster changes in + * each iteration — if the number of pixels is less than 1% of the image + * then subsequent iterations are not going to significantly affect the + * results. + * + * As we’re choosing key colors, finding the optimal result is not + * needed. We just need to find one which is good enough, quickly. + * + * A second termination condition is set on the number of iterations, to + * avoid a potential infinite loop. This termination condition is never + * normally expected to be hit — typically an icon will require 5–10 + * iterations to terminate based on @n_assignments_changed. */ + assignments_termination_limit = width * height * 0.01; + n_iterations = 0; + do { + /* Update step. Re-calculate the centroid of each cluster from + * the colors which are in it. */ + memset (cluster_accumulators, 0, sizeof (cluster_accumulators)); + + for (const ClusterPixel8 *p = pixels; p < pixels_end; p++) { + if (p->cluster >= G_N_ELEMENTS (cluster_centres)) + continue; + + cluster_accumulators[p->cluster].red += p->color.red; + cluster_accumulators[p->cluster].green += p->color.green; + cluster_accumulators[p->cluster].blue += p->color.blue; + cluster_accumulators[p->cluster].n_members++; + } + + for (gsize i = 0; i < G_N_ELEMENTS (cluster_centres); i++) { + if (cluster_accumulators[i].n_members == 0) + continue; + + cluster_centres[i].red = cluster_accumulators[i].red / cluster_accumulators[i].n_members; + cluster_centres[i].green = cluster_accumulators[i].green / cluster_accumulators[i].n_members; + cluster_centres[i].blue = cluster_accumulators[i].blue / cluster_accumulators[i].n_members; + } + + /* Update assignments of colors to clusters. */ + n_assignments_changed = 0; + for (ClusterPixel8 *p = pixels; p < pixels_end; p++) { + gsize new_cluster; + + if (p->cluster >= G_N_ELEMENTS (cluster_centres)) + continue; + + new_cluster = nearest_cluster (&p->color, cluster_centres, G_N_ELEMENTS (cluster_centres)); + if (new_cluster != p->cluster) + n_assignments_changed++; + p->cluster = new_cluster; + } + + n_iterations++; + } while (n_assignments_changed > assignments_termination_limit && n_iterations < 50); + + /* Output the cluster centres: these are the icon’s key colors. */ + for (gsize i = 0; i < G_N_ELEMENTS (cluster_centres); i++) { + GdkRGBA color; + + if (cluster_accumulators[i].n_members == 0) + continue; + + color.red = (gdouble) cluster_centres[i].red / 255.0; + color.green = (gdouble) cluster_centres[i].green / 255.0; + color.blue = (gdouble) cluster_centres[i].blue / 255.0; + color.alpha = 1.0; + g_array_append_val (colors, color); + } +} + +/** + * gs_calculate_key_colors: + * @pixbuf: an app icon to calculate key colors from + * + * Calculate the set of key colors present in @pixbuf. These are the colors + * which stand out the most, and they are subjective. This function does not + * guarantee to return perfect results, but should return workable results for + * most icons. + * + * @pixbuf will be scaled down to 32×32 pixels, so if it can be provided at + * that resolution by the caller, this function will return faster. + * + * Returns: (transfer full) (element-type GdkRGBA): key colors for @pixbuf + * Since: 40 + */ +GArray * +gs_calculate_key_colors (GdkPixbuf *pixbuf) +{ + g_autoptr(GdkPixbuf) pb_small = NULL; + g_autoptr(GArray) colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA)); + + /* people almost always use BILINEAR scaling with pixbufs, but we can + * use NEAREST here since we only care about the rough colour data, not + * whether the edges in the image are smooth and visually appealing; + * NEAREST is twice as fast as BILINEAR */ + pb_small = gdk_pixbuf_scale_simple (pixbuf, 32, 32, GDK_INTERP_NEAREST); + + /* require an alpha channel for storing temporary values; most images + * have one already, about 2% don’t */ + if (gdk_pixbuf_get_n_channels (pixbuf) != 4) { + g_autoptr(GdkPixbuf) temp = g_steal_pointer (&pb_small); + pb_small = gdk_pixbuf_add_alpha (temp, FALSE, 0, 0, 0); + } + + /* get a list of key colors */ + k_means (colors, pb_small); + + return g_steal_pointer (&colors); +} diff --git a/lib/gs-key-colors.h b/lib/gs-key-colors.h new file mode 100644 index 0000000000000000000000000000000000000000..de1baeec20b9b222a0b71ee99e763c6c03b73beb --- /dev/null +++ b/lib/gs-key-colors.h @@ -0,0 +1,27 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes + * Copyright (C) 2013 Matthias Clasen + * Copyright (C) 2014-2018 Kalev Lember + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Authors: + * - Richard Hughes + * - Kalev Lember + * - Philip Withnall + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include +#include +#include + +G_BEGIN_DECLS + +GArray *gs_calculate_key_colors (GdkPixbuf *pixbuf); + +G_END_DECLS diff --git a/lib/meson.build b/lib/meson.build index 2c318dfbf7acf3c170cb3ef346594bb9931d9e86..44b41c272577d8800f90237e17e734572ff972c3 100644 --- a/lib/meson.build +++ b/lib/meson.build @@ -11,6 +11,7 @@ libgnomesoftware_public_headers = [ 'gs-category-manager.h', 'gs-desktop-data.h', 'gs-ioprio.h', + 'gs-key-colors.h', 'gs-metered.h', 'gs-os-release.h', 'gs-plugin.h', @@ -70,6 +71,7 @@ libgnomesoftware = static_library( 'gs-desktop-data.c', 'gs-ioprio.c', 'gs-ioprio.h', + 'gs-key-colors.c', 'gs-metered.c', 'gs-os-release.c', 'gs-plugin.c', @@ -155,3 +157,5 @@ if get_option('tests') ) test('gs-self-test-lib', e, suite: ['lib'], env: test_env, timeout : 120) endif + +subdir('tools') diff --git a/lib/tools/meson.build b/lib/tools/meson.build new file mode 100644 index 0000000000000000000000000000000000000000..0f19c10c157a713bbdefd59b9e3e6418198edbf2 --- /dev/null +++ b/lib/tools/meson.build @@ -0,0 +1,24 @@ +# Test program to profile performance of the key-colors functions +executable( + 'profile-key-colors', + sources : [ + 'profile-key-colors.c', + '../gs-key-colors.c', + '../gs-key-colors.h', + ], + include_directories : [ + include_directories('..'), + include_directories('../..'), + ], + dependencies : [ + glib, + dependency('gdk-3.0'), + gdk_pixbuf, + libm, + ], + c_args : [ + '-Wall', + '-Wextra', + ], + install: false, +) diff --git a/lib/tools/profile-key-colors.c b/lib/tools/profile-key-colors.c new file mode 100644 index 0000000000000000000000000000000000000000..f8bab229ab3cac96243a967fb1a2813d559f6bf1 --- /dev/null +++ b/lib/tools/profile-key-colors.c @@ -0,0 +1,172 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Authors: + * - Philip Withnall + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include +#include +#include +#include +#include + +#include "gs-key-colors.h" + +/* Test program which can be used to check the output and performance of the + * gs_calculate_key_colors() function. It is linked against libgnomesoftware, so + * will use the function implementation from there. It outputs a HTML page which + * lists each icon from the flathub appstream data in your home directory, along + * with its extracted key colors and how long extraction took. */ + +static void +print_colours (GString *html_output, + GArray *colours) +{ + g_string_append_printf (html_output, ""); + for (guint i = 0; i < colours->len; i++) { + GdkRGBA *rgba = &g_array_index (colours, GdkRGBA, i); + + g_string_append_printf (html_output, + "", + (guint) (rgba->red * 255), + (guint) (rgba->green * 255), + (guint) (rgba->blue * 255)); + + if (i % 3 == 2) + g_string_append (html_output, ""); + } + g_string_append_printf (html_output, "
"); +} + +static void +print_summary_statistics (GString *html_output, + GArray *durations /* (element-type gint64) */) +{ + gint64 sum = 0, min = G_MAXINT64, max = G_MININT64; + guint n_measurements = durations->len; + gint64 mean, stddev; + gint64 sum_of_square_deviations = 0; + + for (guint i = 0; i < durations->len; i++) { + gint64 duration = g_array_index (durations, gint64, i); + sum += duration; + min = MIN (min, duration); + max = MAX (max, duration); + } + + mean = sum / n_measurements; + + for (guint i = 0; i < durations->len; i++) { + gint64 duration = g_array_index (durations, gint64, i); + gint64 diff = duration - mean; + sum_of_square_deviations += diff * diff; + } + + stddev = sqrt (sum_of_square_deviations / n_measurements); + + g_string_append_printf (html_output, + "[%" G_GINT64_FORMAT ", %" G_GINT64_FORMAT "]μs, mean %" G_GINT64_FORMAT "±%" G_GINT64_FORMAT "μs, n = %u", + min, max, mean, stddev, n_measurements); +} + +int +main (void) +{ + const gchar *icons_subdir = ".local/share/flatpak/appstream/flathub/x86_64/active/icons/128x128"; + g_autofree gchar *icons_dir = g_build_filename (g_get_home_dir (), icons_subdir, NULL); + g_autoptr(GDir) dir = NULL; + const gchar *entry; + g_autoptr(GPtrArray) filenames = g_ptr_array_new_with_free_func (g_free); + g_autoptr(GPtrArray) pixbufs = g_ptr_array_new_with_free_func (g_object_unref); + g_autoptr(GString) html_output = g_string_new (""); + g_autoptr(GArray) durations = g_array_new (FALSE, FALSE, sizeof (gint64)); + + setlocale (LC_ALL, ""); + + /* Load pixbufs from the icons directory. */ + dir = g_dir_open (icons_dir, 0, NULL); + if (dir == NULL) + return 1; + + while ((entry = g_dir_read_name (dir)) != NULL) { + g_autofree gchar *filename = g_build_filename (icons_dir, entry, NULL); + g_autoptr(GdkPixbuf) pixbuf = gdk_pixbuf_new_from_file (filename, NULL); + + if (pixbuf == NULL) + continue; + + g_ptr_array_add (filenames, g_steal_pointer (&filename)); + g_ptr_array_add (pixbufs, g_steal_pointer (&pixbuf)); + } + + /* Set up an output page */ + g_string_append (html_output, + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n"); + + /* For each pixbuf, run both algorithms. */ + for (guint i = 0; i < pixbufs->len; i++) { + GdkPixbuf *pixbuf = pixbufs->pdata[i]; + const gchar *filename = filenames->pdata[i]; + g_autofree gchar *basename = g_path_get_basename (filename); + g_autoptr(GArray) colours = NULL; + gint64 start_time, duration; + + g_message ("Processing %u of %u, %s", i + 1, pixbufs->len, filename); + + start_time = g_get_real_time (); + colours = gs_calculate_key_colors (pixbuf); + duration = g_get_real_time () - start_time; + + g_string_append_printf (html_output, + "\n" + "\n" + "\n" + "\n" + "\n" + "\n"); + + g_array_append_val (durations, duration); + } + + /* Summary statistics for the timings. */ + g_string_append (html_output, ""); + + g_string_append (html_output, "
FilenameIconCode duration (μs)Code colours
%s%" G_GINT64_FORMAT "", + basename, filename, duration); + print_colours (html_output, colours); + g_string_append (html_output, + "
"); + print_summary_statistics (html_output, durations); + g_string_append (html_output, "
"); + + g_print ("%s\n", html_output->str); + + return 0; +} diff --git a/meson.build b/meson.build index 2ff2f25758e10bc851e5246cc85a81b15bea48d8..cc9f337c3c00ba192e9c15da247850f855817222 100644 --- a/meson.build +++ b/meson.build @@ -12,7 +12,7 @@ conf.set_quoted('PACKAGE_VERSION', meson.project_version()) # this refers to the gnome-software plugin API version # this is not in any way related to a package or soname version -gs_plugin_api_version = '15' +gs_plugin_api_version = '16' conf.set_quoted('GS_PLUGIN_API_VERSION', gs_plugin_api_version) # install docs diff --git a/plugins/dummy/gs-self-test.c b/plugins/dummy/gs-self-test.c index cdadfc6da1605ea89588765cb7570a4898b2c24b..79199759092f3de9a67b4ea1c749cfbd0da4a5c5 100644 --- a/plugins/dummy/gs-self-test.c +++ b/plugins/dummy/gs-self-test.c @@ -211,7 +211,7 @@ gs_plugins_dummy_metadata_quirks (GsPluginLoader *plugin_loader) static void gs_plugins_dummy_key_colors_func (GsPluginLoader *plugin_loader) { - GPtrArray *array; + GArray *array; gboolean ret; guint i; g_autoptr(GsApp) app = NULL; @@ -233,7 +233,7 @@ gs_plugins_dummy_key_colors_func (GsPluginLoader *plugin_loader) /* check values are in range */ for (i = 0; i < array->len; i++) { - GdkRGBA *kc = g_ptr_array_index (array, i); + const GdkRGBA *kc = &g_array_index (array, GdkRGBA, i); g_assert_cmpfloat (kc->red, >=, 0.f); g_assert_cmpfloat (kc->red, <=, 1.f); g_assert_cmpfloat (kc->green, >=, 0.f); diff --git a/src/gs-common.c b/src/gs-common.c index 7545a37164a38edf304247833f32d80898a0ca0d..eea0045735bd1d21ebc3e53d3e8f0f17a5243efc 100644 --- a/src/gs-common.c +++ b/src/gs-common.c @@ -368,7 +368,7 @@ gchar * gs_utils_set_key_colors_in_css (const gchar *css, GsApp *app) { - GPtrArray *key_colors; + GArray *key_colors; g_autoptr(GString) css_new = NULL; if (css == NULL) @@ -384,7 +384,7 @@ gs_utils_set_key_colors_in_css (const gchar *css, /* replace key color values */ css_new = g_string_new (css); for (guint j = 0; j < key_colors->len; j++) { - GdkRGBA *color = g_ptr_array_index (key_colors, j); + const GdkRGBA *color = &g_array_index (key_colors, GdkRGBA, j); g_autofree gchar *key = NULL; g_autofree gchar *value = NULL; key = g_strdup_printf ("@keycolor-%02u@", j); diff --git a/src/gs-feature-tile.c b/src/gs-feature-tile.c index a5a9750832e6cfcb1a11fd1c1485c9bbcc96652b..8c137c266f775ee3abe2c34a3f9cbadd4ec3eef3 100644 --- a/src/gs-feature-tile.c +++ b/src/gs-feature-tile.c @@ -27,10 +27,22 @@ struct _GsFeatureTile GtkCssProvider *tile_provider; /* (owned) (nullable) */ GtkCssProvider *title_provider; /* (owned) (nullable) */ GtkCssProvider *subtitle_provider; /* (owned) (nullable) */ - GPtrArray *key_colors_cache; /* (unowned) (nullable) */ + GArray *key_colors_cache; /* (unowned) (nullable) */ gboolean narrow_mode; }; +/* 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 +{ + gdouble hue; /* [0.0, 1.0] */ + gdouble saturation; /* [0.0, 1.0] */ + gdouble brightness; /* [0.0, 1.0]; also known as lightness (HSL) or value (HSV) */ + gdouble contrast; /* [-1.0, ∞], may actually be `INF` */ +} GsHSBC; + G_DEFINE_TYPE (GsFeatureTile, gs_feature_tile, GS_TYPE_APP_TILE) static void @@ -45,6 +57,121 @@ gs_feature_tile_dispose (GObject *object) G_OBJECT_CLASS (gs_feature_tile_parent_class)->dispose (object); } +/* These are subjectively chosen. See below. */ +static const gdouble min_valid_saturation = 0.5; +static const gdouble max_valid_saturation = 0.85; + +/* Subjectively chosen as the minimum absolute contrast ratio between the + * foreground and background colours. + * + * Note that contrast is in the range [-1.0, ∞], so @min_abs_contrast always has + * to be handled with positive and negative branches. + */ +static const gdouble min_abs_contrast = 0.78; + +/* 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, + gdouble *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; + gdouble 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); +} + +/* Calculate the weber contrast between @foreground and @background. This is + * only valid if the area covered by @foreground is significantly smaller than + * that covered by @background. + * + * See https://en.wikipedia.org/wiki/Contrast_(vision)#Weber_contrast + * + * The return value is in the range [-1.0, ∞], and may actually be `INF`. + */ +static gdouble +weber_contrast (const GsHSBC *foreground, + const GsHSBC *background) +{ + /* Note that this may divide by zero, and that’s fine. However, in + * IEEE 754, dividing ±0.0 by ±0.0 results in NAN, so avoid that. */ + if (foreground->brightness == background->brightness) + return 0.0; + + return (foreground->brightness - background->brightness) / background->brightness; +} + +/* Inverse of the Weber contrast function which finds a brightness (luminance) + * level for the background which gives an absolute contrast of at least + * @desired_abs_contrast against @foreground. The same validity restrictions + * apply as for weber_contrast(). + * + * The return value is in the range [0.0, 1.0]. + */ +static gdouble +weber_contrast_find_brightness (const GsHSBC *foreground, + gdouble desired_abs_contrast) +{ + g_assert (desired_abs_contrast >= 0.0); + + /* There are two solutions to solving + * |(I - I_B) / I_B| ≥ C + * in the general case, although given that I (`foreground->brightness`) + * and I_B (the return value) are only valid in the range [0.0, 1.0], + * there are many cases where only one solution is valid. + * + * Solutions are: + * I_B ≤ I / (1 + C) + * I_B ≥ I / (1 - C) + * + * When given a choice, prefer the solution which gives a higher + * brightness. + * + * In the case I == 0.0, and value of I_B is valid (as per the second + * solution), so arbitrarily choose 0.5 as a solution. + */ + if (foreground->brightness == 0.0) + return 0.5; + else if (foreground->brightness <= 1.0 - desired_abs_contrast && + desired_abs_contrast < 1.0) + return foreground->brightness / (1.0 - desired_abs_contrast); + else + return foreground->brightness / (1.0 + desired_abs_contrast); +} + static void gs_feature_tile_refresh (GsAppTile *self) { @@ -110,27 +237,115 @@ gs_feature_tile_refresh (GsAppTile *self) gs_css_get_markup_for_id (css, "summary")); tile->markup_cache = markup; } else if (markup == NULL) { - GPtrArray *key_colors = gs_app_get_key_colors (app); + 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) { - /* If there is no override CSS for the app, default to a solid - * background colour based on the app’s key colors. + g_autoptr(GArray) colors = NULL; + GdkRGBA fg_rgba; + gboolean fg_rgba_valid; + GsHSBC fg_hsbc; + + /* 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. * - * Choose an arbitrary key color from the app’s key colors, and - * hope 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 + * 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 && key_colors->len > 0) { - GdkRGBA *color = key_colors->pdata[key_colors->len - 1]; - - css = g_strdup_printf ( - "background-color: rgb(%.0f,%.0f,%.0f);", - color->red * 255.f, - color->green * 255.f, - color->blue * 255.f); + 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 = weber_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); + + /* Take the top colour. If it’s not good enough, modify + * its brightness to improve the contrast, and clamp its + * saturation to the valid range. */ + if (colors != NULL && colors->len > 0) { + const GsHSBC *chosen_hsbc = &g_array_index (colors, GsHSBC, 0); + GdkRGBA chosen_rgba; + gdouble modified_saturation, modified_brightness; + + 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) + modified_brightness = chosen_hsbc->brightness; + else + modified_brightness = weber_contrast_find_brightness (&fg_hsbc, min_abs_contrast); + + gtk_hsv_to_rgb (chosen_hsbc->hue, + modified_saturation, + 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), + (modified_saturation == chosen_hsbc->saturation) ? "not modified" : "modified", + (modified_brightness == chosen_hsbc->brightness) ? "not modified" : "modified", + chosen_rgba.red, chosen_rgba.green, chosen_rgba.blue, + chosen_hsbc->hue, modified_saturation, 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); @@ -173,6 +388,19 @@ gs_feature_tile_direction_changed (GtkWidget *widget, GtkTextDirection previous_ gs_feature_tile_refresh (GS_APP_TILE (tile)); } +static void +gs_feature_tile_style_updated (GtkWidget *widget) +{ + 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)); +} + static void gs_feature_tile_size_allocate (GtkWidget *widget, GtkAllocation *allocation) @@ -209,6 +437,7 @@ gs_feature_tile_class_init (GsFeatureTileClass *klass) object_class->dispose = gs_feature_tile_dispose; widget_class->direction_changed = gs_feature_tile_direction_changed; + widget_class->style_updated = gs_feature_tile_style_updated; widget_class->size_allocate = gs_feature_tile_size_allocate; app_tile_class->refresh = gs_feature_tile_refresh; diff --git a/src/gs-featured-carousel.c b/src/gs-featured-carousel.c index 3148c9659553ec8840f3459939e69e82ec3dab8d..cfb3724dd86063914f6f494691cc4ae408883885 100644 --- a/src/gs-featured-carousel.c +++ b/src/gs-featured-carousel.c @@ -1,7 +1,7 @@ /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- * vi:set noexpandtab tabstop=8 shiftwidth=8: * - * Copyright (C) 2021 Endless OS Foundation, Inc + * Copyright (C) 2021 Endless OS Foundation LLC * * Author: Philip Withnall * diff --git a/src/gs-featured-carousel.h b/src/gs-featured-carousel.h index aa031e8f3effe2412c0571f93c82b36f2f5ca7fe..9b24f26fbed7f6d801d1b29f8e7b9cf2db3f261e 100644 --- a/src/gs-featured-carousel.h +++ b/src/gs-featured-carousel.h @@ -1,7 +1,7 @@ /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- * vi:set noexpandtab tabstop=8 shiftwidth=8: * - * Copyright (C) 2021 Endless OS Foundation, Inc + * Copyright (C) 2021 Endless OS Foundation LLC * * Author: Philip Withnall * diff --git a/src/gs-rounded-bin.c b/src/gs-rounded-bin.c index 220525f8ea5649d71aa3448208a4ef831ac3860f..5e02785fb628995ebceaa63ee415d17b8af5100b 100644 --- a/src/gs-rounded-bin.c +++ b/src/gs-rounded-bin.c @@ -2,7 +2,7 @@ * vi:set noexpandtab tabstop=8 shiftwidth=8: * * Copyright (C) 2020 Alexander Mikhaylenko - * Copyright (C) 2021 Endless OS Foundation, Inc + * Copyright (C) 2021 Endless OS Foundation LLC * * Authors: * - Alexander Mikhaylenko diff --git a/src/gs-rounded-bin.h b/src/gs-rounded-bin.h index 5e173f1d65c8b6e9fe27a484b63254df04c27630..c8799b438e4088893ffeaffab6d407ca33141197 100644 --- a/src/gs-rounded-bin.h +++ b/src/gs-rounded-bin.h @@ -1,7 +1,7 @@ /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- * vi:set noexpandtab tabstop=8 shiftwidth=8: * - * Copyright (C) 2021 Endless OS Foundation, Inc + * Copyright (C) 2021 Endless OS Foundation LLC * * Author: Philip Withnall *