diff --git a/data/granite.appdata.xml.in b/data/granite.appdata.xml.in
index 851ba1d1b..89f322dc4 100644
--- a/data/granite.appdata.xml.in
+++ b/data/granite.appdata.xml.in
@@ -15,6 +15,7 @@
New Features:
- min_length property for Granite.ValidatedEntry
+ - Granite.HyperTextView for navigatable URLs in text views
Improvements:
diff --git a/demo/GraniteDemo.vala b/demo/GraniteDemo.vala
index b254bf8f9..7a4a3f22e 100644
--- a/demo/GraniteDemo.vala
+++ b/demo/GraniteDemo.vala
@@ -18,6 +18,7 @@ public class Granite.Demo : Gtk.Application {
var date_time_picker_view = new DateTimePickerView ();
var dynamic_notebook_view = new DynamicNotebookView ();
var form_view = new FormView ();
+ var hypertext_view = new HyperTextViewGrid ();
var mode_button_view = new ModeButtonView ();
var overlaybar_view = new OverlayBarView ();
var seekbar_view = new SeekBarView ();
@@ -39,6 +40,7 @@ public class Granite.Demo : Gtk.Application {
main_stack.add_titled (date_time_picker_view, "pickers", "Date & Time");
main_stack.add_titled (dynamic_notebook_view, "dynamictab", "DynamicNotebook");
main_stack.add_titled (form_view, "formview", "Forms");
+ main_stack.add_titled (hypertext_view, "hypertextview", "HyperTextView");
main_stack.add_titled (mode_button_view, "selection_controls", "Selection Controls");
main_stack.add_titled (overlaybar_view, "overlaybar", "OverlayBar");
main_stack.add_titled (seekbar_view, "seekbar", "SeekBar");
diff --git a/demo/Views/HyperTextViewGrid.vala b/demo/Views/HyperTextViewGrid.vala
new file mode 100644
index 000000000..af3e246f3
--- /dev/null
+++ b/demo/Views/HyperTextViewGrid.vala
@@ -0,0 +1,42 @@
+/*
+* Copyright 2021 elementary, Inc. (https://elementary.io)
+*
+* This library is free software; you can redistribute it and/or
+* modify it under the terms of the GNU Lesser General Public
+* License as published by the Free Software Foundation; either
+* version 3 of the License, or (at your option) any later version.
+*
+* This library 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
+* Lesser General Public License for more details.
+*
+* You should have received a copy of the GNU Lesser General Public
+* License along with this library; if not, write to the
+* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+* Boston, MA 02110-1301 USA.
+*/
+
+public class HyperTextViewGrid : Gtk.Grid {
+ construct {
+ var hypertext_label = new Granite.HeaderLabel ("Hold Ctrl and click to follow the link");
+ var hypertext_textview = new Granite.HyperTextView ();
+ hypertext_textview.buffer.text = "elementary OS - https://elementary.io/\nThe fast, open and privacy-respecting replacement for Windows and macOS.";
+
+ var hypertext_scrolled_window = new Gtk.ScrolledWindow (null, null) {
+ height_request = 300,
+ width_request = 600
+ };
+ hypertext_scrolled_window.add (hypertext_textview);
+
+ margin = 12;
+ orientation = Gtk.Orientation.VERTICAL;
+ row_spacing = 3;
+ halign = Gtk.Align.CENTER;
+ valign = Gtk.Align.CENTER;
+ vexpand = true;
+ add (hypertext_label);
+ add (hypertext_scrolled_window);
+ show_all ();
+ }
+}
diff --git a/demo/meson.build b/demo/meson.build
index 043c3c3ff..b807bb897 100644
--- a/demo/meson.build
+++ b/demo/meson.build
@@ -12,6 +12,7 @@ executable(
'Views/DialogsView.vala',
'Views/DynamicNotebookView.vala',
'Views/FormView.vala',
+ 'Views/HyperTextViewGrid.vala',
'Views/ModeButtonView.vala',
'Views/OverlayBarView.vala',
'Views/SeekBarView.vala',
diff --git a/lib/Widgets/HyperTextView.vala b/lib/Widgets/HyperTextView.vala
new file mode 100644
index 000000000..e0995e985
--- /dev/null
+++ b/lib/Widgets/HyperTextView.vala
@@ -0,0 +1,325 @@
+/*
+ * Copyright 2021 elementary, Inc.
+ *
+ * This program or library is free software; you can redistribute it
+ * and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General
+ * Public License along with this library; if not, write to the
+ * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ * Boston, MA 02110-1301 USA.
+ */
+
+/**
+* This class enables navigatable URLs in Gtk.TextView
+ * @since 6.1.3
+ */
+public class Granite.HyperTextView : Gtk.TextView {
+
+ private const int FORCE_FULL_BUFFER_RESCAN_CHANGE_START_OFFSET = -1;
+
+ private uint buffer_changed_debounce_timeout_id = 0;
+ private int buffer_cursor_position_when_change_started = 0;
+
+ private GLib.HashTable uri_text_tags;
+ private Regex uri_regex;
+
+ private bool is_control_key_pressed = false;
+
+ construct {
+ var http_charset = "[\\w\\/\\-\\+\\.:@\\?&%=#]";
+ var email_charset = "[\\w\\-\\.]";
+ var email_tld_charset = "[\\w\\-]";
+
+ var http_match_str = @"https?:\\/\\/$(http_charset)+\\.$(http_charset)+";
+ var email_match_str = @"(mailto:)?$(email_charset)+@$(email_charset)+\\.$(email_tld_charset)+";
+
+ var uri_regex_str = "(?:(" +
+ http_match_str +
+ ")|(" +
+ email_match_str +
+ "))";
+
+ uri_text_tags = new GLib.HashTable (str_hash, direct_equal);
+ try {
+ uri_regex = new Regex (uri_regex_str);
+ } catch (GLib.RegexError e) {
+ critical ("RegexError while constructing URI regex: %s", e.message);
+ }
+
+ buffer_connect (buffer);
+ notify["buffer"].connect (() => {
+ buffer_connect (buffer);
+ buffer.changed ();
+ });
+
+ button_release_event.connect (on_button_release_event);
+ motion_notify_event.connect (on_motion_notify_event);
+ focus_out_event.connect (on_focus_out_event);
+
+ /**
+ * Binding key_press/key_release signals to all toplevel
+ * windows possible enables us to detect when the Control
+ * key is pressed even when HyperTextView is not focused.
+ */
+ var toplevel_windows = Gtk.Window.list_toplevels ();
+
+ if (toplevel_windows.length () != 0) {
+ foreach (unowned var toplevel_window in toplevel_windows) {
+ toplevel_window.key_press_event.connect (on_key_press_event);
+ toplevel_window.key_release_event.connect (on_key_release_event);
+ }
+
+ } else {
+ warning ("Could not bind key-press events to top-level window, Control + Click may not always behave correctly.");
+ // bind to this as a fallback
+ key_press_event.connect (on_key_press_event);
+ key_release_event.connect (on_key_release_event);
+ }
+ }
+
+ private void buffer_connect (Gtk.TextBuffer buffer) {
+ buffer.notify["cursor-position"].connect (on_buffer_cursor_position_changed);
+ buffer.paste_done.connect (on_paste_done);
+ buffer.changed.connect_after (on_after_buffer_changed);
+ }
+
+ private void on_buffer_cursor_position_changed () {
+ if (buffer_cursor_position_when_change_started == 0) {
+ buffer_cursor_position_when_change_started = buffer.cursor_position;
+ }
+ }
+
+ private void on_paste_done (Gtk.Clipboard clipboard) {
+ // force rescan of whole buffer:
+ buffer_cursor_position_when_change_started = FORCE_FULL_BUFFER_RESCAN_CHANGE_START_OFFSET;
+ }
+
+ private void on_after_buffer_changed () {
+ if (buffer_changed_debounce_timeout_id != 0) {
+ Source.remove (buffer_changed_debounce_timeout_id);
+ buffer_changed_debounce_timeout_id = 0;
+ }
+
+ buffer_changed_debounce_timeout_id = GLib.Timeout.add (300, () => {
+ buffer_changed_debounce_timeout_id = 0;
+
+ var change_start_offset = buffer_cursor_position_when_change_started;
+ var change_end_offset = buffer.cursor_position;
+
+ buffer_cursor_position_when_change_started = 0;
+
+ if (change_start_offset == FORCE_FULL_BUFFER_RESCAN_CHANGE_START_OFFSET || change_start_offset == change_end_offset) {
+ change_start_offset = 0;
+ change_end_offset = buffer.text.length;
+ }
+
+ update_tags_in_buffer_for_range.begin (
+ int.min (change_start_offset, change_end_offset),
+ int.max (change_start_offset, change_end_offset)
+ );
+
+ return GLib.Source.REMOVE;
+ });
+ }
+
+ private async void update_tags_in_buffer_for_range (int buffer_start_offset, int buffer_end_offset) {
+ if (buffer_start_offset == buffer_end_offset) {
+ return;
+ }
+
+ Gtk.TextIter buffer_start_iter, buffer_end_iter;
+ buffer.get_iter_at_offset (out buffer_start_iter, buffer_start_offset);
+ buffer_start_iter.backward_line ();
+ buffer_start_offset = buffer_start_iter.get_offset ();
+
+ buffer.get_iter_at_offset (out buffer_end_iter, buffer_end_offset);
+ buffer_end_iter.forward_line ();
+ buffer_end_offset = buffer_end_iter.get_offset ();
+
+ // Delete all tags in buffer for range [start_iter.offset,end_iter.offset]
+ lock (uri_text_tags) {
+ foreach (var tag_key in uri_text_tags.get_keys ()) {
+ int tag_start_offset, tag_end_offset;
+ tag_key.scanf ("[%i,%i]", out tag_start_offset, out tag_end_offset);
+
+ if (
+ tag_start_offset > buffer_start_offset && tag_start_offset < buffer_end_offset
+ ||
+ tag_end_offset > buffer_start_offset && tag_end_offset < buffer_end_offset
+ ) {
+ buffer.tag_table.remove (uri_text_tags.take (tag_key));
+ }
+ }
+ }
+
+ /*
+ Character counts are usually referred to as offsets, while byte counts are called indexes.
+ If you confuse these two, things will work fine with ASCII, but as soon as your
+ buffer contains multibyte characters, bad things will happen.
+ https://developer.gnome.org/gtk3/stable/TextWidget.html
+ */
+ var buffer_start_index = buffer.text.index_of_nth_char (buffer_start_offset);
+ var buffer_end_index = buffer.text.index_of_nth_char (buffer_end_offset);
+ var buffer_substring = buffer.text.substring (buffer_start_index, buffer_end_index - buffer_start_index);
+
+ if (buffer_substring.strip () == "") {
+ // if the substring is empty, we do not have anything to do...
+ return;
+ }
+
+ // Add new tags in buffer for range [start_iter.offset,end_iter.offset]
+ GLib.MatchInfo match_info;
+ uri_regex.match (buffer_substring, 0, out match_info);
+
+ while (match_info.matches ()) {
+ string match_text = match_info.fetch (0);
+
+ int match_start_index, match_end_index;
+ match_info.fetch_pos (0, out match_start_index, out match_end_index);
+
+ int match_start_offset, match_end_offset;
+ match_start_offset = buffer_substring.substring (0, match_start_index).char_count ();
+ match_end_offset = buffer_substring.substring (0, match_end_index).char_count ();
+
+ var buffer_match_start_offset = buffer_start_offset + match_start_offset;
+ var buffer_match_end_offset = buffer_start_offset + match_end_offset;
+
+ Gtk.TextIter buffer_match_start_iter, buffer_match_end_iter;
+ buffer.get_iter_at_offset (out buffer_match_start_iter, buffer_match_start_offset);
+ buffer.get_iter_at_offset (out buffer_match_end_iter, buffer_match_end_offset);
+
+ var tag = buffer.create_tag (null, "underline", Pango.Underline.SINGLE);
+ if (!match_text.contains ("://") && match_text.contains ("@") && !match_text.has_prefix ("mailto:")) {
+ match_text = "mailto:" + match_text;
+ }
+ tag.set_data ("uri", match_text);
+ buffer.apply_tag (tag, buffer_match_start_iter, buffer_match_end_iter);
+
+ lock (uri_text_tags) {
+ uri_text_tags.set ("[%i,%i]".printf (buffer_match_start_offset, buffer_match_end_offset), tag);
+ }
+
+ try {
+ match_info.next ();
+ } catch (GLib.RegexError e) {
+ warning ("RegexError while scanning for the next URI match: %s", e.message);
+ }
+ }
+ }
+
+ private bool on_key_press_event (Gdk.EventKey event) {
+ if (event.keyval == Gdk.Key.Control_L || event.keyval == Gdk.Key.Control_R) {
+ var window = get_window (Gtk.TextWindowType.TEXT);
+ if (window != null) {
+ var pointer_device = window.get_display ().get_default_seat ().get_pointer ();
+ if (pointer_device != null) {
+ int pointer_x, pointer_y;
+ window.get_device_position (pointer_device, out pointer_x, out pointer_y, null);
+
+ var uri_hovering_over = get_uri_at_location (pointer_x, pointer_y);
+ if (uri_hovering_over != null) {
+ window.cursor = new Gdk.Cursor.from_name (get_display (), "pointer");
+ }
+ }
+ }
+ is_control_key_pressed = true;
+ }
+ return Gdk.EVENT_PROPAGATE;
+ }
+
+ private bool on_key_release_event (Gdk.EventKey event) {
+ if (event.keyval == Gdk.Key.Control_L || event.keyval == Gdk.Key.Control_R) {
+ var window = get_window (Gtk.TextWindowType.TEXT);
+ if (is_control_key_pressed && window != null) {
+ window.cursor = new Gdk.Cursor.from_name (get_display (), "text");
+ }
+ is_control_key_pressed = false;
+ }
+ return Gdk.EVENT_PROPAGATE;
+ }
+
+ private bool on_button_release_event () {
+ if (!is_control_key_pressed) {
+ return Gdk.EVENT_PROPAGATE;
+ }
+ Gtk.TextIter text_iter;
+ buffer.get_iter_at_mark (out text_iter, buffer.get_insert ());
+
+ var tags = text_iter.get_tags ();
+ foreach (var tag in tags) {
+ if (tag.get_data ("uri") != null) {
+ var uri = tag.get_data ("uri");
+
+ try {
+ GLib.AppInfo.launch_default_for_uri (uri, null);
+ } catch (GLib.Error e) {
+ warning ("Could not open URI '%s': %s", uri, e.message);
+
+ var error_dialog = new Granite.MessageDialog (
+ _("Could not open URI"),
+ e.message,
+ new ThemedIcon ("dialog-error"),
+ Gtk.ButtonsType.CLOSE
+ );
+ error_dialog.run ();
+ error_dialog.destroy ();
+ }
+ break;
+ }
+ }
+ return Gdk.EVENT_PROPAGATE;
+ }
+
+ private bool on_motion_notify_event (Gtk.Widget widget, Gdk.EventMotion event) {
+ var uri_hovering_over = get_uri_at_location ((int) event.x, (int) event.y);
+
+ if (uri_hovering_over != null && !has_tooltip) {
+ has_tooltip = true;
+ tooltip_markup = string.joinv ("\n", {
+ _("Follow Link"),
+ Granite.TOOLTIP_SECONDARY_TEXT_MARKUP.printf (_("Control + Click"))
+ });
+
+ } else if (uri_hovering_over == null && has_tooltip) {
+ has_tooltip = false;
+ }
+
+ return Gdk.EVENT_PROPAGATE;
+ }
+
+ private string? get_uri_at_location (int location_x, int location_y) {
+ string? uri = null;
+ var window = get_window (Gtk.TextWindowType.WIDGET);
+
+ if (window != null) {
+ int x, y;
+ window_to_buffer_coords (Gtk.TextWindowType.TEXT, location_x, location_y, out x, out y);
+
+ Gtk.TextIter text_iter;
+ if (get_iter_at_location (out text_iter, x, y)) {
+ var tags = text_iter.get_tags ();
+
+ foreach (var tag in tags) {
+ if (tag.get_data ("uri") != null) {
+ uri = tag.get_data ("uri");
+ break;
+ }
+ }
+ }
+ }
+ return uri;
+ }
+
+ private bool on_focus_out_event (Gdk.EventFocus event) {
+ is_control_key_pressed = false;
+ return Gdk.EVENT_PROPAGATE;
+ }
+}
diff --git a/lib/meson.build b/lib/meson.build
index 53c68984f..1716cf7ea 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -33,6 +33,7 @@ libgranite_sources = files(
'Widgets/Dialog.vala',
'Widgets/DynamicNotebook.vala',
'Widgets/HeaderLabel.vala',
+ 'Widgets/HyperTextView.vala',
'Widgets/MessageDialog.vala',
'Widgets/ModeButton.vala',
'Widgets/ModeSwitch.vala',