🏡 index : quine-global-labs/bazaar.git

/* bz-appstream-description-render.c
 *
 * Copyright 2025 Adam Masciola
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

#define G_LOG_DOMAIN "BAZAAR::APPSTREAM-DESCRIPTION-RENDER"

#include "config.h"

#include <xmlb.h>

#include "bz-appstream-description-render.h"

enum
{
  NO_ELEMENT,
  PARAGRAPH,
  ORDERED_LIST,
  UNORDERED_LIST,
  LIST_ITEM,
  CODE,
  EMPHASIS,
};

struct _BzAppstreamDescriptionRender
{
  AdwBin parent_instance;

  char *appstream_description;

  /* Template widgets */
  GtkTextView *text_view;
};

G_DEFINE_FINAL_TYPE (BzAppstreamDescriptionRender, bz_appstream_description_render, ADW_TYPE_BIN);

enum
{
  PROP_0,

  PROP_APPSTREAM_DESCRIPTION,

  LAST_PROP
};

static GParamSpec *props[LAST_PROP] = { 0 };

static void
setup_text_tags (GtkTextBuffer *buffer);

static void
regenerate (BzAppstreamDescriptionRender *self);

static void
insert (BzAppstreamDescriptionRender *self,
        GtkTextBuffer                *buffer,
        GtkTextIter                  *iter,
        const char                   *text);

static void
compile (BzAppstreamDescriptionRender *self,
         XbNode                       *node,
         GtkTextBuffer                *buffer,
         GtkTextIter                  *iter,
         int                           parent_kind,
         int                           idx,
         gboolean                      is_last_sibling,
         gboolean                     *in_space,
         gboolean                     *at_start);

static char *
normalize_whitespace (const char *text,
                      gboolean   *in_space,
                      gboolean   *at_start);

static void
on_map (GtkWidget                    *widget,
        BzAppstreamDescriptionRender *self);

static const char *
get_link_at_coords (GtkTextView *text_view,
                    double       x,
                    double       y);

static void
on_click_released (GtkGestureClick *gesture,
                   int              n_press,
                   double           x,
                   double           y,
                   GtkTextView     *text_view);

static void
on_motion (GtkEventControllerMotion *controller,
           double                    x,
           double                    y,
           GtkTextView              *text_view);

static void
text_focus_cb (GtkTextView                  *text_view,
               GParamSpec                   *pspec,
               BzAppstreamDescriptionRender *self);
static void
bz_appstream_description_render_dispose (GObject *object)
{
  BzAppstreamDescriptionRender *self = BZ_APPSTREAM_DESCRIPTION_RENDER (object);

  g_clear_pointer (&self->appstream_description, g_free);

  G_OBJECT_CLASS (bz_appstream_description_render_parent_class)->dispose (object);
}

static void
bz_appstream_description_render_get_property (GObject    *object,
                                              guint       prop_id,
                                              GValue     *value,
                                              GParamSpec *pspec)
{
  BzAppstreamDescriptionRender *self = BZ_APPSTREAM_DESCRIPTION_RENDER (object);

  switch (prop_id)
    {
    case PROP_APPSTREAM_DESCRIPTION:
      g_value_set_string (value, bz_appstream_description_render_get_appstream_description (self));
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
bz_appstream_description_render_set_property (GObject      *object,
                                              guint         prop_id,
                                              const GValue *value,
                                              GParamSpec   *pspec)
{
  BzAppstreamDescriptionRender *self = BZ_APPSTREAM_DESCRIPTION_RENDER (object);

  switch (prop_id)
    {
    case PROP_APPSTREAM_DESCRIPTION:
      bz_appstream_description_render_set_appstream_description (self, g_value_get_string (value));
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
bz_appstream_description_render_class_init (BzAppstreamDescriptionRenderClass *klass)
{
  GObjectClass   *object_class = G_OBJECT_CLASS (klass);
  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);

  object_class->set_property = bz_appstream_description_render_set_property;
  object_class->get_property = bz_appstream_description_render_get_property;
  object_class->dispose      = bz_appstream_description_render_dispose;

  props[PROP_APPSTREAM_DESCRIPTION] =
      g_param_spec_string (
          "appstream-description",
          NULL, NULL, NULL,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  g_object_class_install_properties (object_class, LAST_PROP, props);

  gtk_widget_class_set_template_from_resource (widget_class, "/io/github/kolunmi/Bazaar/bz-appstream-description-render.ui");
  gtk_widget_class_bind_template_child (widget_class, BzAppstreamDescriptionRender, text_view);
  gtk_widget_class_bind_template_callback (widget_class, text_focus_cb);
}

static void
setup_text_tags (GtkTextBuffer *buffer)
{
  gtk_text_buffer_create_tag (buffer, "code",
                              "family", "monospace",
                              NULL);

  gtk_text_buffer_create_tag (buffer, "emphasis",
                              "weight", PANGO_WEIGHT_BOLD,
                              NULL);

  gtk_text_buffer_create_tag (buffer, "paragraph",
                              "pixels-below-lines", 12,
                              NULL);

  gtk_text_buffer_create_tag (buffer, "list-item-ul",
                              "left-margin", 10,
                              "pixels-below-lines", 4,
                              "indent", -12,
                              NULL);

  gtk_text_buffer_create_tag (buffer, "list-item-ol",
                              "left-margin", 10,
                              "pixels-below-lines", 4,
                              "indent", -18,
                              NULL);

  gtk_text_buffer_create_tag (buffer, "list-number",
                              "family", "monospace",
                              "foreground", "gray",
                              NULL);
}

static void
bz_appstream_description_render_init (BzAppstreamDescriptionRender *self)
{
  GtkTextBuffer      *buffer        = NULL;
  GtkGesture         *click         = NULL;
  GtkEventController *motion        = NULL;
  AdwStyleManager    *style_manager = NULL;

  gtk_widget_init_template (GTK_WIDGET (self));

  buffer = gtk_text_view_get_buffer (self->text_view);
  setup_text_tags (buffer);
  gtk_widget_remove_css_class (GTK_WIDGET (self->text_view), "view");

  g_signal_connect (self, "map", G_CALLBACK (on_map), self);

  click = gtk_gesture_click_new ();
  gtk_gesture_single_set_button (GTK_GESTURE_SINGLE (click), GDK_BUTTON_PRIMARY);
  g_signal_connect (click, "released", G_CALLBACK (on_click_released), self->text_view);
  gtk_widget_add_controller (GTK_WIDGET (self->text_view), GTK_EVENT_CONTROLLER (click));

  motion = gtk_event_controller_motion_new ();
  g_signal_connect (motion, "motion", G_CALLBACK (on_motion), self->text_view);
  gtk_widget_add_controller (GTK_WIDGET (self->text_view), motion);

  style_manager = adw_style_manager_get_default ();
  g_signal_connect_object (style_manager, "notify::accent-color",
                           G_CALLBACK (regenerate), self, G_CONNECT_SWAPPED);
}

static const char *
get_link_at_coords (GtkTextView *text_view,
                    double       x,
                    double       y)
{
  GtkTextIter iter        = { 0 };
  int         bx          = 0;
  int         by          = 0;
  g_autoptr (GSList) tags = NULL;
  GSList     *l           = NULL;
  const char *href        = NULL;

  gtk_text_view_window_to_buffer_coords (text_view, GTK_TEXT_WINDOW_WIDGET,
                                         (int) x, (int) y, &bx, &by);
  gtk_text_view_get_iter_at_location (text_view, &iter, bx, by);
  tags = gtk_text_iter_get_tags (&iter);
  for (l = tags; l != NULL; l = l->next)
    {
      href = g_object_get_data (G_OBJECT (l->data), "href");
      if (href != NULL)
        return href;
    }
  return NULL;
}

static void
on_click_released (GtkGestureClick *gesture,
                   int              n_press,
                   double           x,
                   double           y,
                   GtkTextView     *text_view)
{
  const char *href                    = NULL;
  g_autoptr (GtkUriLauncher) launcher = NULL;
  GtkWindow *root                     = NULL;

  href = get_link_at_coords (text_view, x, y);
  if (href == NULL)
    return;

  launcher = gtk_uri_launcher_new (href);
  root     = GTK_WINDOW (gtk_widget_get_root (GTK_WIDGET (text_view)));
  gtk_uri_launcher_launch (launcher, root, NULL, NULL, NULL);
}

static void
on_motion (GtkEventControllerMotion *controller,
           double                    x,
           double                    y,
           GtkTextView              *text_view)
{
  if (get_link_at_coords (text_view, x, y) != NULL)
    gtk_widget_set_cursor_from_name (GTK_WIDGET (text_view), "pointer");
  else
    gtk_widget_set_cursor (GTK_WIDGET (text_view), NULL);
}

static void
text_focus_cb (GtkTextView                  *text_view,
               GParamSpec                   *pspec,
               BzAppstreamDescriptionRender *self)
{
  GtkTextBuffer *buffer = NULL;
  GtkTextIter    start  = { 0 };
  GtkTextIter    end    = { 0 };

  buffer = gtk_text_view_get_buffer (self->text_view);

  gtk_text_buffer_get_bounds (buffer, &start, &end);

  if (gtk_widget_has_focus (GTK_WIDGET (text_view)))
    {
      g_autofree char *full_text = NULL;

      full_text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);

      if (full_text != NULL && *full_text != '\0')
        gtk_accessible_update_property (GTK_ACCESSIBLE (text_view),
                                        GTK_ACCESSIBLE_PROPERTY_LABEL, full_text,
                                        -1);

      gtk_text_buffer_select_range (buffer, &start, &end);
    }
  else
    gtk_text_buffer_place_cursor (buffer, &start);
}

BzAppstreamDescriptionRender *
bz_appstream_description_render_new (void)
{
  return g_object_new (BZ_TYPE_APPSTREAM_DESCRIPTION_RENDER, NULL);
}

const char *
bz_appstream_description_render_get_appstream_description (BzAppstreamDescriptionRender *self)
{
  g_return_val_if_fail (BZ_IS_APPSTREAM_DESCRIPTION_RENDER (self), NULL);
  return self->appstream_description;
}

void
bz_appstream_description_render_set_appstream_description (BzAppstreamDescriptionRender *self,
                                                           const char                   *appstream_description)
{
  g_return_if_fail (BZ_IS_APPSTREAM_DESCRIPTION_RENDER (self));

  g_clear_pointer (&self->appstream_description, g_free);
  if (appstream_description != NULL)
    self->appstream_description = g_strdup (appstream_description);

  regenerate (self);

  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_APPSTREAM_DESCRIPTION]);
}

static void
regenerate (BzAppstreamDescriptionRender *self)
{
  g_autoptr (GError) local_error = NULL;
  g_autoptr (XbSilo) silo        = NULL;
  g_autoptr (XbNode) root        = NULL;
  GtkTextBuffer *buffer          = NULL;
  GtkTextIter    iter            = { 0 };
  int            node_count      = 0;
  gboolean       in_space        = FALSE;
  gboolean       at_start        = TRUE;

  buffer = gtk_text_view_get_buffer (self->text_view);
  gtk_text_buffer_set_text (buffer, "", 0);

  if (self->appstream_description == NULL)
    return;

  silo = xb_silo_new_from_xml (self->appstream_description, &local_error);
  if (silo == NULL)
    {
      g_warning ("Failed to parse appstream description XML: %s", local_error->message);
      return;
    }

  gtk_text_buffer_get_end_iter (buffer, &iter);
  root = xb_silo_get_root (silo);

  for (XbNode *n = g_object_ref (root); n != NULL;)
    {
      XbNode *last = NULL;

      node_count++;

      last = n;
      n    = xb_node_get_next (n);
      g_object_unref (last);
    }

  for (int i = 0; root != NULL; i++)
    {
      g_autoptr (XbNode) next = NULL;
      gboolean is_last        = (i == node_count - 1);

      compile (self, root, buffer, &iter, NO_ELEMENT, i, is_last, &in_space, &at_start);

      next = xb_node_get_next (root);
      g_object_unref (root);
      root = g_steal_pointer (&next);
    }
}

static void
insert_url (BzAppstreamDescriptionRender *self,
            GtkTextBuffer                *buffer,
            GtkTextIter                  *iter,
            const char                   *url)
{
  g_autoptr (GdkRGBA) rgba = NULL;
  GtkTextTag  *tag         = NULL;
  GtkTextMark *mark        = NULL;
  GtkTextIter  start       = { 0 };

  rgba = adw_style_manager_get_accent_color_rgba (adw_style_manager_get_default ());
  tag  = gtk_text_buffer_create_tag (buffer, NULL,
                                     "foreground-rgba", rgba,
                                     "underline", PANGO_UNDERLINE_SINGLE,
                                     NULL);
  g_object_set_data_full (G_OBJECT (tag), "href", g_strdup (url), g_free);
  mark = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);
  gtk_text_buffer_insert (buffer, iter, url, -1);
  gtk_text_buffer_get_iter_at_mark (buffer, &start, mark);
  gtk_text_buffer_apply_tag (buffer, tag, &start, iter);
  gtk_text_buffer_delete_mark (buffer, mark);
}

static void
insert (BzAppstreamDescriptionRender *self,
        GtkTextBuffer                *buffer,
        GtkTextIter                  *iter,
        const char                   *text)
{
  g_auto (GStrv) parts       = NULL;
  const char      *p         = NULL;
  const char      *http      = NULL;
  const char      *https     = NULL;
  const char      *url_start = NULL;
  const char      *url_end   = NULL;
  const char      *chunk     = NULL;
  GtkTextMark     *m         = NULL;
  GtkTextIter      si        = { 0 };
  g_autofree char *url       = NULL;
  int              j         = 0;

  parts = g_strsplit (text, "**", -1);
  for (j = 0; parts[j] != NULL; j++)
    {
      chunk = parts[j];
      if (j % 2 != 0)
        {
          m = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);
          gtk_text_buffer_insert (buffer, iter, chunk, -1);
          gtk_text_buffer_get_iter_at_mark (buffer, &si, m);
          gtk_text_buffer_apply_tag_by_name (buffer, "emphasis", &si, iter);
          gtk_text_buffer_delete_mark (buffer, m);
          continue;
        }

      p = chunk;
      while (*p != '\0')
        {
          http  = strstr (p, "http://");
          https = strstr (p, "https://");

          url_start = (http != NULL && (https == NULL || http < https)) ? http : https;

          if (url_start == NULL)
            {
              gtk_text_buffer_insert (buffer, iter, p, -1);
              break;
            }

          if (url_start > p)
            gtk_text_buffer_insert (buffer, iter, p, (int) (url_start - p));

          url_end = url_start;
          while (*url_end != '\0' && !g_unichar_isspace (g_utf8_get_char (url_end)))
            url_end = g_utf8_next_char (url_end);

          url = g_strndup (url_start, url_end - url_start);
          for (char *end = url + strlen (url) - 1;
               end >= url && strchr (").,!?", *end) != NULL;
               end--)
            {
              *end = '\0';
              url_end--;
            }

          insert_url (self, buffer, iter, url);
          p = url_end;
        }
    }
}

static void
compile (BzAppstreamDescriptionRender *self,
         XbNode                       *node,
         GtkTextBuffer                *buffer,
         GtkTextIter                  *iter,
         int                           parent_kind,
         int                           idx,
         gboolean                      is_last_sibling,
         gboolean                     *in_space,
         gboolean                     *at_start)
{
  const char  *element        = NULL;
  const char  *text           = NULL;
  XbNode      *child          = NULL;
  int          kind           = NO_ELEMENT;
  GtkTextMark *start_mark     = NULL;
  int          child_count    = 0;
  gboolean     local_in_space = FALSE;
  gboolean     local_at_start = TRUE;

  element    = xb_node_get_element (node);
  text       = xb_node_get_text (node);
  child      = xb_node_get_child (node);
  kind       = NO_ELEMENT;
  start_mark = NULL;

  if (element != NULL)
    {
      if (g_strcmp0 (element, "p") == 0)
        {
          kind       = PARAGRAPH;
          start_mark = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);
        }
      else if (g_strcmp0 (element, "ol") == 0)
        kind = ORDERED_LIST;
      else if (g_strcmp0 (element, "ul") == 0)
        kind = UNORDERED_LIST;
      else if (g_strcmp0 (element, "li") == 0)
        {
          kind       = LIST_ITEM;
          start_mark = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);

          if (parent_kind == ORDERED_LIST)
            {
              g_autofree char *prefix = NULL;
              GtkTextMark     *prefix_start_mark;
              GtkTextIter      prefix_start_iter;

              prefix_start_mark = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);
              prefix            = g_strdup_printf ("%d.", idx + 1);
              gtk_text_buffer_insert (buffer, iter, prefix, -1);

              gtk_text_buffer_get_iter_at_mark (buffer, &prefix_start_iter, prefix_start_mark);
              gtk_text_buffer_apply_tag_by_name (buffer, "list-number", &prefix_start_iter, iter);
              gtk_text_buffer_delete_mark (buffer, prefix_start_mark);
            }
          else
            gtk_text_buffer_insert (buffer, iter, "• ", -1);
        }
      else if (g_strcmp0 (element, "code") == 0)
        {
          kind       = CODE;
          start_mark = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);
        }
      else if (g_strcmp0 (element, "em") == 0)
        {
          kind       = EMPHASIS;
          start_mark = gtk_text_buffer_create_mark (buffer, NULL, iter, TRUE);
        }
    }

  if (kind == PARAGRAPH || kind == LIST_ITEM)
    {
      in_space = &local_in_space;
      at_start = &local_at_start;
    }

  if (text != NULL)
    {
      g_autofree char *normalized = NULL;

      normalized = normalize_whitespace (text, in_space, at_start);
      if (normalized != NULL && *normalized != '\0')
        insert (self, buffer, iter, normalized);
    }

  for (int i = 0; child != NULL; i++)
    {
      const char *tail = NULL;
      XbNode     *next = NULL;

      next = xb_node_get_next (child);
      compile (self, child, buffer, iter, kind, i, next == NULL, in_space, at_start);

      tail = xb_node_get_tail (child);
      if (tail != NULL)
        {
          g_autofree char *normalized = NULL;

          normalized = normalize_whitespace (tail, in_space, at_start);
          if (normalized != NULL && *normalized != '\0')
            insert (self, buffer, iter, normalized);
        }

      g_object_unref (child);
      child = next;
      child_count++;
    }

  if (start_mark != NULL)
    {
      GtkTextIter start_iter = { 0 };

      gtk_text_buffer_get_iter_at_mark (buffer, &start_iter, start_mark);

      if (kind == CODE)
        gtk_text_buffer_apply_tag_by_name (buffer, "code", &start_iter, iter);
      else if (kind == EMPHASIS)
        gtk_text_buffer_apply_tag_by_name (buffer, "emphasis", &start_iter, iter);
      else if (kind == PARAGRAPH)
        gtk_text_buffer_apply_tag_by_name (buffer, "paragraph", &start_iter, iter);
      else if (kind == LIST_ITEM)
        {
          const char *tag_name = NULL;

          tag_name = (parent_kind == ORDERED_LIST) ? "list-item-ol" : "list-item-ul";
          gtk_text_buffer_apply_tag_by_name (buffer, tag_name, &start_iter, iter);
          gtk_text_buffer_insert (buffer, iter, "\n", 1);
        }

      gtk_text_buffer_delete_mark (buffer, start_mark);
    }

  if (kind == PARAGRAPH && !is_last_sibling)
    gtk_text_buffer_insert (buffer, iter, "\n", 1);
  else if ((kind == ORDERED_LIST || kind == UNORDERED_LIST) && !is_last_sibling && child_count > 0)
    gtk_text_buffer_insert (buffer, iter, "\n", 1);
}

static void
on_map (GtkWidget                    *widget,
        BzAppstreamDescriptionRender *self)
{
  gtk_widget_queue_resize (GTK_WIDGET (self));
}

static char *
normalize_whitespace (const char *text,
                      gboolean   *in_space,
                      gboolean   *at_start)
{
  g_autoptr (GString) result = NULL;

  if (text == NULL)
    return NULL;

  result = g_string_new (NULL);

  for (const char *p = text;
       p != NULL && *p != '\0';
       p = g_utf8_next_char (p))
    {
      gunichar ch = 0;

      ch = g_utf8_get_char (p);
      if (g_unichar_isspace (ch))
        {
          if (!*at_start)
            *in_space = TRUE;
        }
      else
        {
          if (!*at_start && *in_space)
            g_string_append_c (result, ' ');
          g_string_append_unichar (result, ch);

          *in_space = FALSE;
          *at_start = FALSE;
        }
    }

  return g_string_free (g_steal_pointer (&result), FALSE);
}

/* End of bz-appstream-description-render.c */