[gnome-software/1131-featured-carousel: 2/21] gs-featured-carousel: Add new carousel widget for featured apps




commit 94ff47c1bac6b99d17848a3e36b80f14b1f671f4
Author: Philip Withnall <pwithnall endlessos org>
Date:   Thu Feb 11 23:09:50 2021 +0000

    gs-featured-carousel: Add new carousel widget for featured apps
    
    This is a new design for showing featured apps, using `HdyCarousel`.
    Eventually, it will prioritise an app-specific background colour, the
    app’s hi-res icon, and some text; instead of the current approach of
    supporting custom per-app CSS. The CSS was hard to implement, the
    background images took up a lot of memory and disk space, and it
    required a lot of curation time which it turned out we just didn’t have.
    
    Signed-off-by: Philip Withnall <pwithnall endlessos org>
    
    Helps: #1131

 contrib/gnome-software.spec.in                     |   2 +
 data/icons/hicolor/meson.build                     |   4 +
 .../scalable/carousel-arrow-next-symbolic.svg      |  36 ++
 .../scalable/carousel-arrow-previous-symbolic.svg  |  36 ++
 po/POTFILES.in                                     |   1 +
 src/gnome-software.gresource.xml                   |   1 +
 src/gs-featured-carousel.c                         | 451 +++++++++++++++++++++
 src/gs-featured-carousel.h                         |  31 ++
 src/gs-featured-carousel.ui                        | 105 +++++
 src/gtk-style.css                                  |  30 +-
 src/meson.build                                    |   1 +
 11 files changed, 697 insertions(+), 1 deletion(-)
---
diff --git a/contrib/gnome-software.spec.in b/contrib/gnome-software.spec.in
index 0d0d6c74b..97d8e9282 100644
--- a/contrib/gnome-software.spec.in
+++ b/contrib/gnome-software.spec.in
@@ -141,6 +141,8 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/*.desktop
 %{_mandir}/man1/gnome-software.1.gz
 %{_datadir}/icons/hicolor/*/apps/org.gnome.Software.svg
 %{_datadir}/icons/hicolor/symbolic/apps/org.gnome.Software-symbolic.svg
+%{_datadir}/icons/hicolor/scalable/actions/carousel-arrow-next-symbolic.svg
+%{_datadir}/icons/hicolor/scalable/actions/carousel-arrow-previous-symbolic.svg
 %{_datadir}/icons/hicolor/scalable/status/software-installed-symbolic.svg
 %{_datadir}/gnome-software/featured-*.svg
 %{_datadir}/gnome-software/featured-*.jpg
diff --git a/data/icons/hicolor/meson.build b/data/icons/hicolor/meson.build
index 8e232b8a9..933ae5ad8 100644
--- a/data/icons/hicolor/meson.build
+++ b/data/icons/hicolor/meson.build
@@ -1,5 +1,9 @@
 install_data('scalable/org.gnome.Software.svg',
              install_dir : 'share/icons/hicolor/scalable/apps')
+install_data('scalable/carousel-arrow-next-symbolic.svg',
+             install_dir : 'share/icons/hicolor/scalable/actions')
+install_data('scalable/carousel-arrow-previous-symbolic.svg',
+             install_dir : 'share/icons/hicolor/scalable/actions')
 install_data('symbolic/org.gnome.Software-symbolic.svg',
              install_dir : 'share/icons/hicolor/symbolic/apps')
 install_data('scalable/software-installed-symbolic.svg',
diff --git a/data/icons/hicolor/scalable/carousel-arrow-next-symbolic.svg 
b/data/icons/hicolor/scalable/carousel-arrow-next-symbolic.svg
new file mode 100644
index 000000000..7d6356f23
--- /dev/null
+++ b/data/icons/hicolor/scalable/carousel-arrow-next-symbolic.svg
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/";
+   xmlns:cc="http://creativecommons.org/ns#";
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+   xmlns:svg="http://www.w3.org/2000/svg";
+   xmlns="http://www.w3.org/2000/svg";
+   width="24"
+   height="24"
+   viewBox="0 0 24 24.000001"
+   version="1.1"
+   id="svg19258">
+  <defs
+     id="defs19252" />
+  <metadata
+     id="metadata19255">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage"; />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="g872"
+     transform="matrix(0,-1,-1,0,742.51668,224.9988)"
+     style="fill:#241f31">
+    <path
+       
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#241f31;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;
 
stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="m 213.0007,724.40348 -10.3711,7.3945 v 0 a 1.5,1.5 0 0 0 -0.6308,1.2187 v 1.5 h 1.5 a 1.5,1.5 0 0 
0 0.8711,-0.2793 l 8.6289,-6.1523 8.6289,6.1523 a 1.5,1.5 0 0 0 0.8711,0.2793 h 1.5 v -1.5 a 1.5,1.5 0 0 0 
-0.6309,-1.2187 v 0 z"
+       id="path870" />
+  </g>
+</svg>
diff --git a/data/icons/hicolor/scalable/carousel-arrow-previous-symbolic.svg 
b/data/icons/hicolor/scalable/carousel-arrow-previous-symbolic.svg
new file mode 100644
index 000000000..984893092
--- /dev/null
+++ b/data/icons/hicolor/scalable/carousel-arrow-previous-symbolic.svg
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/";
+   xmlns:cc="http://creativecommons.org/ns#";
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+   xmlns:svg="http://www.w3.org/2000/svg";
+   xmlns="http://www.w3.org/2000/svg";
+   width="24"
+   height="24"
+   viewBox="0 0 24 24.000001"
+   version="1.1"
+   id="svg19258">
+  <defs
+     id="defs19252" />
+  <metadata
+     id="metadata19255">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage"; />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="g834"
+     transform="rotate(-90,-246.75894,471.75774)"
+     style="fill:#241f31">
+    <path
+       
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#241f31;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;
 
stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="m 213.0007,724.40348 -10.3711,7.3945 v 0 a 1.5,1.5 0 0 0 -0.6308,1.2187 v 1.5 h 1.5 a 1.5,1.5 0 0 
0 0.8711,-0.2793 l 8.6289,-6.1523 8.6289,6.1523 a 1.5,1.5 0 0 0 0.8711,0.2793 h 1.5 v -1.5 a 1.5,1.5 0 0 0 
-0.6309,-1.2187 v 0 z"
+       id="path832" />
+  </g>
+</svg>
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 00028c0db..ae862e038 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -29,6 +29,7 @@ src/gs-details-page.ui
 src/gs-extras-page.c
 src/gs-extras-page.ui
 src/gs-feature-tile.c
+src/gs-featured-carousel.c
 src/gs-first-run-dialog.ui
 src/gs-hiding-box.c
 src/gs-history-dialog.c
diff --git a/src/gnome-software.gresource.xml b/src/gnome-software.gresource.xml
index d0f7ea95c..26f098d42 100644
--- a/src/gnome-software.gresource.xml
+++ b/src/gnome-software.gresource.xml
@@ -12,6 +12,7 @@
   <file preprocess="xml-stripblanks">gs-details-page.ui</file>
   <file preprocess="xml-stripblanks">gs-extras-page.ui</file>
   <file preprocess="xml-stripblanks">gs-feature-tile.ui</file>
+  <file preprocess="xml-stripblanks">gs-featured-carousel.ui</file>
   <file preprocess="xml-stripblanks">gs-first-run-dialog.ui</file>
   <file preprocess="xml-stripblanks">gs-history-dialog.ui</file>
   <file preprocess="xml-stripblanks">gs-info-bar.ui</file>
diff --git a/src/gs-featured-carousel.c b/src/gs-featured-carousel.c
new file mode 100644
index 000000000..42a399a29
--- /dev/null
+++ b/src/gs-featured-carousel.c
@@ -0,0 +1,451 @@
+/* -*- 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
+ *
+ * Author: Philip Withnall <pwithnall endlessos org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-featured-carousel
+ * @short_description: A carousel widget containing #GsFeatureTile instances
+ *
+ * #GsFeaturedCarousel is a carousel widget which rotates through a set of
+ * #GsFeatureTiles, displaying them to the user to advertise a given set of
+ * featured apps, set with gs_featured_carousel_set_apps().
+ *
+ * The widget has no special appearance if the app list is empty, so callers
+ * will typically want to hide the carousel in that case.
+ *
+ * Since: 40
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+#include <gtk/gtk.h>
+#include <handy.h>
+
+#include "gs-app-list.h"
+#include "gs-common.h"
+#include "gs-feature-tile.h"
+#include "gs-featured-carousel.h"
+
+struct _GsFeaturedCarousel
+{
+       GtkBox           parent_instance;
+
+       GsAppList       *apps;  /* (nullable) (owned) */
+       guint            rotation_timer_id;
+
+       HdyCarousel     *carousel;
+       GtkButton       *next_button;
+       GtkImage        *next_button_image;
+       GtkButton       *previous_button;
+       GtkImage        *previous_button_image;
+};
+
+G_DEFINE_TYPE (GsFeaturedCarousel, gs_featured_carousel, GTK_TYPE_BOX)
+
+typedef enum {
+       PROP_APPS = 1,
+} GsFeaturedCarouselProperty;
+
+static GParamSpec *obj_props[PROP_APPS + 1] = { NULL, };
+
+typedef enum {
+       SIGNAL_APP_CLICKED,
+       SIGNAL_CLICKED,
+} GsFeaturedCarouselSignal;
+
+static guint obj_signals[SIGNAL_CLICKED + 1] = { 0, };
+
+static void
+show_relative_page (GsFeaturedCarousel *self,
+                    gint                delta)
+{
+       gdouble current_page = hdy_carousel_get_position (self->carousel);
+       guint n_pages = hdy_carousel_get_n_pages (self->carousel);
+       gdouble new_page;
+       g_autoptr(GList) children = gtk_container_get_children (GTK_CONTAINER (self->carousel));
+       GtkWidget *new_page_widget;
+       gint64 animation_duration_ms = hdy_carousel_get_animation_duration (self->carousel);
+
+       if (n_pages == 0)
+               return;
+
+       /* FIXME: This would be simpler if HdyCarousel had a way to scroll to
+        * a page by index, rather than by GtkWidget pointer.
+        * See https://gitlab.gnome.org/GNOME/libhandy/-/issues/413 */
+       new_page = ((guint) current_page + delta + n_pages) % n_pages;
+       new_page_widget = g_list_nth_data (children, new_page);
+       g_assert (new_page_widget != NULL);
+
+       /* Don’t animate if we’re wrapping from the last page back to the first,
+        * as it means rapidly spooling through all the pages, which looks
+        * confusing. */
+       if (new_page == 0.0)
+               animation_duration_ms = 0;
+
+       hdy_carousel_scroll_to_full (self->carousel, new_page_widget, animation_duration_ms);
+}
+
+static gboolean
+rotate_cb (gpointer user_data)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data);
+
+       show_relative_page (self, +1);
+
+       return G_SOURCE_CONTINUE;
+}
+
+static void
+start_rotation_timer (GsFeaturedCarousel *self)
+{
+       const guint rotation_period_seconds = 15;
+
+       if (self->rotation_timer_id == 0) {
+               self->rotation_timer_id = g_timeout_add_seconds (rotation_period_seconds,
+                                                                rotate_cb, self);
+       }
+}
+
+static void
+stop_rotation_timer (GsFeaturedCarousel *self)
+{
+       if (self->rotation_timer_id != 0) {
+               g_source_remove (self->rotation_timer_id);
+               self->rotation_timer_id = 0;
+       }
+}
+
+static void
+image_set_icon_for_direction (GtkImage    *image,
+                              const gchar *ltr_icon_name,
+                              const gchar *rtl_icon_name)
+{
+       const gchar *icon_name = (gtk_widget_get_direction (GTK_WIDGET (image)) == GTK_TEXT_DIR_RTL) ? 
rtl_icon_name : ltr_icon_name;
+       gtk_image_set_from_icon_name (image, icon_name, GTK_ICON_SIZE_LARGE_TOOLBAR);
+}
+
+static void
+next_button_clicked_cb (GtkButton *button,
+                        gpointer   user_data)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data);
+
+       show_relative_page (self, +1);
+
+       /* Reset the rotation timer in case it’s about to fire. */
+       stop_rotation_timer (self);
+       start_rotation_timer (self);
+}
+
+static void
+next_button_direction_changed_cb (GtkWidget        *widget,
+                                  GtkTextDirection  previous_direction,
+                                  gpointer          user_data)
+{
+       image_set_icon_for_direction (GTK_IMAGE (widget), "carousel-arrow-next-symbolic", 
"carousel-arrow-previous-symbolic");
+}
+
+static void
+previous_button_clicked_cb (GtkButton *button,
+                            gpointer   user_data)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data);
+
+       show_relative_page (self, -1);
+
+       /* Reset the rotation timer in case it’s about to fire. */
+       stop_rotation_timer (self);
+       start_rotation_timer (self);
+}
+
+static void
+previous_button_direction_changed_cb (GtkWidget        *widget,
+                                      GtkTextDirection  previous_direction,
+                                      gpointer          user_data)
+{
+       image_set_icon_for_direction (GTK_IMAGE (widget), "carousel-arrow-previous-symbolic", 
"carousel-arrow-next-symbolic");
+}
+
+static void
+app_tile_clicked_cb (GsAppTile *app_tile,
+                     gpointer   user_data)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data);
+       GsApp *app;
+
+       app = gs_app_tile_get_app (app_tile);
+       g_signal_emit (self, obj_signals[SIGNAL_APP_CLICKED], 0, app);
+}
+
+static void
+gs_featured_carousel_init (GsFeaturedCarousel *self)
+{
+       gtk_widget_set_has_window (GTK_WIDGET (self), FALSE);
+       gtk_widget_init_template (GTK_WIDGET (self));
+
+       /* Ensure the text directions are up to date */
+       next_button_direction_changed_cb (GTK_WIDGET (self->next_button_image), GTK_TEXT_DIR_NONE, self);
+       previous_button_direction_changed_cb (GTK_WIDGET (self->previous_button_image), GTK_TEXT_DIR_NONE, 
self);
+}
+
+static void
+gs_featured_carousel_get_property (GObject    *object,
+                                   guint       prop_id,
+                                   GValue     *value,
+                                   GParamSpec *pspec)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (object);
+
+       switch ((GsFeaturedCarouselProperty) prop_id) {
+       case PROP_APPS:
+               g_value_set_object (value, gs_featured_carousel_get_apps (self));
+               break;
+       default:
+               G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+               break;
+       }
+}
+
+static void
+gs_featured_carousel_set_property (GObject      *object,
+                                   guint         prop_id,
+                                   const GValue *value,
+                                   GParamSpec   *pspec)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (object);
+
+       switch ((GsFeaturedCarouselProperty) prop_id) {
+       case PROP_APPS:
+               gs_featured_carousel_set_apps (self, g_value_get_object (value));
+               break;
+       default:
+               G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+               break;
+       }
+}
+
+static void
+gs_featured_carousel_dispose (GObject *object)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (object);
+
+       stop_rotation_timer (self);
+       g_clear_object (&self->apps);
+
+       G_OBJECT_CLASS (gs_featured_carousel_parent_class)->dispose (object);
+}
+
+static gboolean
+gs_featured_carousel_key_press_event (GtkWidget   *widget,
+                                      GdkEventKey *event)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (widget);
+
+       if (gtk_widget_is_visible (GTK_WIDGET (self->previous_button)) &&
+           gtk_widget_is_sensitive (GTK_WIDGET (self->previous_button)) &&
+           ((gtk_widget_get_direction (GTK_WIDGET (self->previous_button)) == GTK_TEXT_DIR_LTR && 
event->keyval == GDK_KEY_Left) ||
+            (gtk_widget_get_direction (GTK_WIDGET (self->previous_button)) == GTK_TEXT_DIR_RTL && 
event->keyval == GDK_KEY_Right))) {
+               gtk_widget_activate (GTK_WIDGET (self->previous_button));
+               return GDK_EVENT_STOP;
+       }
+
+       if (gtk_widget_is_visible (GTK_WIDGET (self->next_button)) &&
+           gtk_widget_is_sensitive (GTK_WIDGET (self->next_button)) &&
+           ((gtk_widget_get_direction (GTK_WIDGET (self->next_button)) == GTK_TEXT_DIR_LTR && event->keyval 
== GDK_KEY_Right) ||
+            (gtk_widget_get_direction (GTK_WIDGET (self->next_button)) == GTK_TEXT_DIR_RTL && event->keyval 
== GDK_KEY_Left))) {
+               gtk_widget_activate (GTK_WIDGET (self->next_button));
+               return GDK_EVENT_STOP;
+       }
+
+       return GDK_EVENT_PROPAGATE;
+}
+
+static void
+carousel_clicked_cb (GsFeaturedCarousel *carousel,
+                     gpointer            user_data)
+{
+       GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data);
+       GsAppTile *current_tile;
+       GsApp *app;
+       gdouble current_page;
+       g_autoptr(GList) children = NULL;
+
+       /* Get the currently visible tile. */
+       current_page = hdy_carousel_get_position (self->carousel);
+       children = gtk_container_get_children (GTK_CONTAINER (self->carousel));
+
+       current_tile = g_list_nth_data (children, current_page);
+       if (current_tile == NULL)
+               return;
+
+       app = gs_app_tile_get_app (current_tile);
+       g_signal_emit (self, obj_signals[SIGNAL_APP_CLICKED], 0, app);
+}
+
+static void
+gs_featured_carousel_class_init (GsFeaturedCarouselClass *klass)
+{
+       GObjectClass *object_class = G_OBJECT_CLASS (klass);
+       GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+       object_class->get_property = gs_featured_carousel_get_property;
+       object_class->set_property = gs_featured_carousel_set_property;
+       object_class->dispose = gs_featured_carousel_dispose;
+
+       widget_class->key_press_event = gs_featured_carousel_key_press_event;
+
+       /**
+        * GsFeaturedCarousel:apps: (nullable)
+        *
+        * The list of featured apps to display in the carousel. This should
+        * typically be 4–8 apps. They will be displayed in the order listed,
+        * so the caller may want to randomise that order first, using
+        * gs_app_list_randomize().
+        *
+        * This may be %NULL if no apps have been set. This is equivalent to
+        * an empty #GsAppList.
+        *
+        * Since: 40
+        */
+       obj_props[PROP_APPS] =
+               g_param_spec_object ("apps", NULL, NULL,
+                                    GS_TYPE_APP_LIST,
+                                    G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+       g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+
+       /**
+        * GsFeaturedCarousel::app-clicked:
+        * @app: the #GsApp which was clicked on
+        *
+        * Emitted when one of the app tiles is clicked. Typically the caller
+        * should display the details of the given app in the callback.
+        *
+        * Since: 40
+        */
+       obj_signals[SIGNAL_APP_CLICKED] =
+               g_signal_new ("app-clicked",
+                             G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+                             0, NULL, NULL, g_cclosure_marshal_VOID__OBJECT,
+                             G_TYPE_NONE, 1, GS_TYPE_APP);
+
+       /**
+        * GsFeaturedCarousel::clicked:
+        *
+        * Emitted when the carousel is clicked, and typically emitted shortly
+        * before #GsFeaturedCarousel::app-clicked is emitted. Most callers will
+        * want to connect to #GsFeaturedCarousel::app-clicked instead.
+        *
+        * Since: 40
+        */
+       obj_signals[SIGNAL_CLICKED] =
+               g_signal_new ("clicked",
+                             G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+                             0, NULL, NULL, g_cclosure_marshal_VOID__VOID,
+                             G_TYPE_NONE, 0);
+       widget_class->activate_signal = obj_signals[SIGNAL_CLICKED];
+
+       gtk_widget_class_set_template_from_resource (widget_class, 
"/org/gnome/Software/gs-featured-carousel.ui");
+
+       gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, carousel);
+       gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, next_button);
+       gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, next_button_image);
+       gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, previous_button);
+       gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, previous_button_image);
+       gtk_widget_class_bind_template_callback (widget_class, next_button_clicked_cb);
+       gtk_widget_class_bind_template_callback (widget_class, next_button_direction_changed_cb);
+       gtk_widget_class_bind_template_callback (widget_class, previous_button_clicked_cb);
+       gtk_widget_class_bind_template_callback (widget_class, previous_button_direction_changed_cb);
+       gtk_widget_class_bind_template_callback (widget_class, carousel_clicked_cb);
+}
+
+/**
+ * gs_featured_carousel_new:
+ * @apps: (nullable): a list of apps to display in the carousel, or %NULL
+ *
+ * Create a new #GsFeaturedCarousel and set its initial app list to @apps.
+ *
+ * Returns: (transfer full): a new #GsFeaturedCarousel
+ * Since: 40
+ */
+GtkWidget *
+gs_featured_carousel_new (GsAppList *apps)
+{
+       g_return_val_if_fail (apps == NULL || GS_IS_APP_LIST (apps), NULL);
+
+       return g_object_new (GS_TYPE_FEATURED_CAROUSEL,
+                            "apps", apps,
+                            NULL);
+}
+
+/**
+ * gs_featured_carousel_get_apps:
+ * @self: a #GsFeaturedCarousel
+ *
+ * Gets the value of #GsFeaturedCarousel:apps.
+ *
+ * Returns: (nullable) (transfer none): list of apps in the carousel, or %NULL
+ *     if none are set
+ * Since: 40
+ */
+GsAppList *
+gs_featured_carousel_get_apps (GsFeaturedCarousel *self)
+{
+       g_return_val_if_fail (GS_IS_FEATURED_CAROUSEL (self), NULL);
+
+       return self->apps;
+}
+
+/**
+ * gs_featured_carousel_set_apps:
+ * @self: a #GsFeaturedCarousel
+ * @apps: (nullable) (transfer none): list of apps to display in the carousel,
+ *     or %NULL for none
+ *
+ * Set the value of #GsFeaturedCarousel:apps.
+ *
+ * Since: 40
+ */
+void
+gs_featured_carousel_set_apps (GsFeaturedCarousel *self,
+                               GsAppList          *apps)
+{
+       g_return_if_fail (GS_IS_FEATURED_CAROUSEL (self));
+       g_return_if_fail (apps == NULL || GS_IS_APP_LIST (apps));
+
+       if (apps == self->apps)
+               return;
+
+       stop_rotation_timer (self);
+       gs_container_remove_all (GTK_CONTAINER (self->carousel));
+
+       g_set_object (&self->apps, apps);
+
+       for (guint i = 0; i < gs_app_list_length (apps); i++) {
+               GsApp *app = gs_app_list_index (apps, i);
+               GtkWidget *tile = gs_feature_tile_new (app);
+               gtk_widget_set_hexpand (tile, TRUE);
+               gtk_widget_set_vexpand (tile, TRUE);
+               gtk_widget_set_can_focus (tile, FALSE);
+               g_signal_connect (tile, "clicked",
+                                 G_CALLBACK (app_tile_clicked_cb), self);
+               gtk_container_add (GTK_CONTAINER (self->carousel), tile);
+       }
+
+       gtk_widget_set_visible (GTK_WIDGET (self->next_button), self->apps != NULL && gs_app_list_length 
(self->apps) > 1);
+       gtk_widget_set_visible (GTK_WIDGET (self->previous_button), self->apps != NULL && gs_app_list_length 
(self->apps) > 1);
+
+       if (self->apps != NULL && gs_app_list_length (self->apps) > 0)
+               start_rotation_timer (self);
+
+       g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APPS]);
+}
diff --git a/src/gs-featured-carousel.h b/src/gs-featured-carousel.h
new file mode 100644
index 000000000..aa031e8f3
--- /dev/null
+++ b/src/gs-featured-carousel.h
@@ -0,0 +1,31 @@
+/* -*- 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
+ *
+ * Author: Philip Withnall <pwithnall endlessos org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gtk/gtk.h>
+
+#include "gs-app-list.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_FEATURED_CAROUSEL (gs_featured_carousel_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsFeaturedCarousel, gs_featured_carousel, GS, FEATURED_CAROUSEL, GtkBox)
+
+GtkWidget      *gs_featured_carousel_new       (GsAppList              *apps);
+
+GsAppList      *gs_featured_carousel_get_apps  (GsFeaturedCarousel     *self);
+void            gs_featured_carousel_set_apps  (GsFeaturedCarousel     *self,
+                                                GsAppList              *apps);
+
+G_END_DECLS
diff --git a/src/gs-featured-carousel.ui b/src/gs-featured-carousel.ui
new file mode 100644
index 000000000..1c2f8b790
--- /dev/null
+++ b/src/gs-featured-carousel.ui
@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <requires lib="handy" version="1.0"/>
+  <template class="GsFeaturedCarousel" parent="GtkBox">
+    <property name="halign">fill</property>
+    <property name="orientation">vertical</property>
+    <property name="spacing">12</property>
+    <property name="visible">True</property>
+    <property name="can-focus">True</property>
+    <signal name="clicked" handler="carousel_clicked_cb"/>
+    <style>
+      <class name="featured-carousel"/>
+    </style>
+    <child>
+      <object class="GtkOverlay" id="overlay">
+        <property name="halign">fill</property>
+        <property name="valign">fill</property>
+        <property name="visible">True</property>
+        <child>
+          <object class="GsRoundedBin">
+            <property name="visible">True</property>
+            <child>
+              <object class="HdyCarousel" id="carousel">
+                <property name="visible">True</property>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child type="overlay">
+          <object class="GtkButton" id="previous_button">
+            <property name="visible">True</property>
+            <property name="use-underline">True</property>
+            <property name="can-focus">True</property>
+            <property name="halign">start</property>
+            <property name="valign">center</property>
+            <property name="width-request">56</property>
+            <property name="height-request">56</property>
+            <property name="margin">9</property>
+            <signal name="clicked" handler="previous_button_clicked_cb"/>
+            <child internal-child="accessible">
+              <object class="AtkObject">
+                <property name="accessible-name" translatable="yes">Previous</property>
+              </object>
+            </child>
+            <style>
+              <class name="osd"/>
+              <class name="featured-button-left"/>
+            </style>
+            <child>
+              <object class="GtkImage" id="previous_button_image">
+                <property name="visible">True</property>
+                <property name="icon-name">carousel-arrow-previous-symbolic</property>
+                <property name="icon-size">3</property><!-- GTK_ICON_SIZE_LARGE_TOOLBAR -->
+                <signal name="direction-changed" handler="previous_button_direction_changed_cb"/>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child type="overlay">
+          <object class="GtkButton" id="next_button">
+            <property name="visible">True</property>
+            <property name="use-underline">True</property>
+            <property name="can-focus">True</property>
+            <property name="halign">end</property>
+            <property name="valign">center</property>
+            <property name="width-request">56</property>
+            <property name="height-request">56</property>
+            <property name="margin">9</property>
+            <signal name="clicked" handler="next_button_clicked_cb"/>
+            <child internal-child="accessible">
+              <object class="AtkObject">
+                <property name="accessible-name" translatable="yes">Next</property>
+              </object>
+            </child>
+            <style>
+              <class name="osd"/>
+              <class name="featured-button-right"/>
+            </style>
+            <child>
+              <object class="GtkImage" id="next_button_image">
+                <property name="visible">True</property>
+                <property name="icon_name">carousel-arrow-next-symbolic</property>
+                <property name="icon_size">3</property><!-- GTK_ICON_SIZE_LARGE_TOOLBAR -->
+                <signal name="direction-changed" handler="next_button_direction_changed_cb"/>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </child>
+    <child>
+      <object class="HdyCarouselIndicatorDots" id="dots">
+        <property name="carousel">carousel</property>
+        <property name="visible">True</property>
+      </object>
+    </child>
+    <child internal-child="accessible">
+      <object class="AtkObject">
+        <property name="accessible-name" translatable="yes">Featured Apps List</property>
+        <property name="accessible-role">grouping</property>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/src/gtk-style.css b/src/gtk-style.css
index 557c435f7..413acef95 100644
--- a/src/gtk-style.css
+++ b/src/gtk-style.css
@@ -547,10 +547,38 @@ flowboxchild {
        opacity: 0.5;
 }
 
+.featured-carousel rounded-bin {
+       border-radius: 8px;
+}
+
 .featured-button-left,
 .featured-button-right {
        padding: 2px 5px;
-       margin: 6px;
+       border-radius: 50%;
+       -gtk-outline-radius: 50%;
+       color: @theme_fg_color;
+}
+
+.featured-carousel button.osd {
+       -gtk-icon-shadow: none;
+}
+
+.featured-carousel button.osd:focus {
+       /* this is @theme_fg_color at 10% opacity, but we can’t use the variable
+        * directly as rgba() requires 4 components */
+       background: rgba(46, 52, 54, 0.1);
+}
+
+.featured-carousel button.osd:hover {
+       /* this is @theme_fg_color at 20% opacity, but we can’t use the variable
+        * directly as rgba() requires 4 components */
+       background: rgba(46, 52, 54, 0.2);
+}
+
+.featured-carousel button.osd:active {
+       /* this is @theme_fg_color at 35% opacity, but we can’t use the variable
+        * directly as rgba() requires 4 components */
+       background: rgba(46, 52, 54, 0.35);
 }
 
 .featured-button-left:not(:hover),
diff --git a/src/meson.build b/src/meson.build
index 14c902f58..3a91c8667 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -31,6 +31,7 @@ gnome_software_sources = [
   'gs-details-page.c',
   'gs-extras-page.c',
   'gs-feature-tile.c',
+  'gs-featured-carousel.c',
   'gs-first-run-dialog.c',
   'gs-fixed-size-bin.c',
   'gs-folders.c',


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]