*
* 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;
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);
}