From a03ba0a8ce4ee04230784ffe3f98052a9134e868 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Thu, 19 Aug 2021 07:37:42 +0000 Subject: [PATCH 1/8] Pasted namespace changed HyperTextView This also includes the change from TOOLTIP_SECONDARY_TEXT_MARKUP to Granite.TOOLTIP_SECONDARY_TEXT_MARKUP --- src/EventEdition/InfoPanel.vala | 4 +- src/Widgets/HyperTextView.vala | 319 ++++++++++++++++++++++++++++++++ src/meson.build | 1 + 3 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 src/Widgets/HyperTextView.vala diff --git a/src/EventEdition/InfoPanel.vala b/src/EventEdition/InfoPanel.vala index 9bbdaee59..3c264ce82 100644 --- a/src/EventEdition/InfoPanel.vala +++ b/src/EventEdition/InfoPanel.vala @@ -20,7 +20,7 @@ public class Maya.View.EventEdition.InfoPanel : Gtk.Grid { private Gtk.Entry title_entry; - private Gtk.TextView comment_textview; + private Calendar.Widgets.HyperTextView comment_textview; private Granite.Widgets.DatePicker from_date_picker; private Granite.Widgets.DatePicker to_date_picker; private Gtk.Switch allday_switch; @@ -137,7 +137,7 @@ public class Maya.View.EventEdition.InfoPanel : Gtk.Grid { } var comment_label = new Granite.HeaderLabel (_("Comments:")); - comment_textview = new Gtk.TextView (); + comment_textview = new Calendar.Widgets.HyperTextView (); comment_textview.set_wrap_mode (Gtk.WrapMode.WORD_CHAR); comment_textview.accepts_tab = false; comment_textview.set_border_window_size (Gtk.TextWindowType.LEFT, 2); diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala new file mode 100644 index 000000000..72cf5a31c --- /dev/null +++ b/src/Widgets/HyperTextView.vala @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2012–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. + */ + + namespace Calendar.Widgets { + + /** + * This class enables navigatable URLs in Gtk.TextView + */ + public class 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 = "[a-zA-Z0-9_\\/\\-\\+\\.:@\\?&%=#]"; + var email_charset = "[a-zA-Z0-9_\\-\\.]"; + var email_tld_charset = "[a-zA-Z0-9_\\-]"; + + var http_match_str = "https?:\\/\\/" + http_charset + "{1,}"; + var email_match_str = "(mailto:)?" + email_charset + "{1,}@" + email_charset + "{1,}\\." + email_tld_charset + "{1,}"; + + 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.notify["cursor-position"].connect (on_buffer_cursor_position_changed); + buffer.paste_done.connect (on_paste_done); + buffer.changed.connect_after (on_after_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 toplevel window + * if possible enables us to detect when the Control key + * is pressed even when HyperTextView is not focused. + */ + + Gtk.Window? toplevel_window = null; + foreach (unowned var window in Gtk.Window.list_toplevels ()) { + if (window.get_parent_window () == null) { + toplevel_window = window; + break; + } + } + + if (toplevel_window != null) { + 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 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 = 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) { + 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.TEXT); + + if (window != null) { + int x, y; + window_to_buffer_coords (Gtk.TextWindowType.WIDGET, 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; + } + } +} \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index 8d2624b4d..240ae4946 100644 --- a/src/meson.build +++ b/src/meson.build @@ -35,6 +35,7 @@ calendar_files = files( 'Widgets/DynamicSpinner.vala', 'Widgets/EventMenu.vala', 'Widgets/HeaderBar.vala', + 'Widgets/HyperTextView.vala', 'Widgets/SourcePopover.vala', 'Widgets/SourceRow.vala', 'Widgets/ConnectivityInfoBar.vala' From 951ac4e2698b9c831d2d63b97efd85b906af34d2 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Thu, 19 Aug 2021 07:56:42 +0000 Subject: [PATCH 2/8] HyperTextView: Support set another buffer --- src/Widgets/HyperTextView.vala | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index 72cf5a31c..450aaa92b 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -55,9 +55,11 @@ critical ("RegexError while constructing URI regex: %s", e.message); } - 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); + 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); @@ -88,6 +90,12 @@ } } + 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; From e9fe80692abb787ac089c04cd2793b738a00ec73 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Thu, 19 Aug 2021 08:38:58 +0000 Subject: [PATCH 3/8] HyperTextView: key_press/release not fired - why? --- src/Widgets/HyperTextView.vala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index 450aaa92b..642ae4bba 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -217,6 +217,7 @@ } private bool on_key_press_event (Gdk.EventKey event) { + warning ("on_key_press_event"); if (event.keyval == Gdk.Key.Control_L || event.keyval == Gdk.Key.Control_R) { var window = get_window (Gtk.TextWindowType.TEXT); if (window != null) { @@ -237,6 +238,7 @@ } private bool on_key_release_event (Gdk.EventKey event) { + warning ("on_key_release_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) { From d3f6c3d91410888ded13480478fcd8c2176f8225 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Sat, 21 Aug 2021 09:08:46 +0200 Subject: [PATCH 4/8] Binding to all toplevel windows seems to do the trick --- src/Widgets/HyperTextView.vala | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index 642ae4bba..ae8326c65 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -66,24 +66,20 @@ focus_out_event.connect (on_focus_out_event); /** - * Binding key_press/key_release signals to toplevel window - * if possible enables us to detect when the Control key - * is pressed even when HyperTextView is not focused. - */ - - Gtk.Window? toplevel_window = null; - foreach (unowned var window in Gtk.Window.list_toplevels ()) { - if (window.get_parent_window () == null) { - toplevel_window = window; - break; + * 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 Gtk.Window.list_toplevels ()) { + toplevel_window.key_press_event.connect (on_key_press_event); + toplevel_window.key_release_event.connect (on_key_release_event); } - } - if (toplevel_window != null) { - 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."); + warning ("Could not bind key-press events to any 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); @@ -217,7 +213,6 @@ } private bool on_key_press_event (Gdk.EventKey event) { - warning ("on_key_press_event"); if (event.keyval == Gdk.Key.Control_L || event.keyval == Gdk.Key.Control_R) { var window = get_window (Gtk.TextWindowType.TEXT); if (window != null) { @@ -238,7 +233,6 @@ } private bool on_key_release_event (Gdk.EventKey event) { - warning ("on_key_release_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) { From 976326fbca8a1652491c874836fd40c1fd704d06 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Sat, 21 Aug 2021 09:09:01 +0200 Subject: [PATCH 5/8] Fix linting error --- src/Widgets/HyperTextView.vala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index ae8326c65..cb8c1fa38 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -320,4 +320,4 @@ return Gdk.EVENT_PROPAGATE; } } -} \ No newline at end of file +} From 9c61e89235bd7d8a7068b6ba47a32c99b2292b6f Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Sun, 22 Aug 2021 14:08:05 +0200 Subject: [PATCH 6/8] Re-use toplevel_windows variable --- src/Widgets/HyperTextView.vala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index cb8c1fa38..82a5d4e09 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -73,7 +73,7 @@ var toplevel_windows = Gtk.Window.list_toplevels (); if (toplevel_windows.length () != 0) { - foreach (unowned var toplevel_window in Gtk.Window.list_toplevels ()) { + 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); } From e907e6053f5b15f69b99c48c736b22e196843e09 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Mon, 6 Sep 2021 09:05:31 +0200 Subject: [PATCH 7/8] Fixed issue where initialization sometimes failed --- src/Widgets/HyperTextView.vala | 477 +++++++++++++++++---------------- 1 file changed, 239 insertions(+), 238 deletions(-) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index 82a5d4e09..2758943b7 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -17,307 +17,308 @@ * Boston, MA 02110-1301 USA. */ - namespace Calendar.Widgets { +/** +* This class enables navigatable URLs in Gtk.TextView +*/ +public class Calendar.Widgets.HyperTextView : Gtk.TextView { - /** - * This class enables navigatable URLs in Gtk.TextView - */ - public class HyperTextView : Gtk.TextView { + private const int FORCE_FULL_BUFFER_RESCAN_CHANGE_START_OFFSET = -1; - 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 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 GLib.HashTable uri_text_tags; - private Regex uri_regex; + private bool is_control_key_pressed = false; - private bool is_control_key_pressed = false; + construct { + var http_charset = "[a-zA-Z0-9_\\/\\-\\+\\.:@\\?&%=#]"; + var email_charset = "[a-zA-Z0-9_\\-\\.]"; + var email_tld_charset = "[a-zA-Z0-9_\\-]"; - construct { - var http_charset = "[a-zA-Z0-9_\\/\\-\\+\\.:@\\?&%=#]"; - var email_charset = "[a-zA-Z0-9_\\-\\.]"; - var email_tld_charset = "[a-zA-Z0-9_\\-]"; + var http_match_str = "https?:\\/\\/" + http_charset + "{1,}"; + var email_match_str = "(mailto:)?" + email_charset + "{1,}@" + email_charset + "{1,}\\." + email_tld_charset + "{1,}"; - var http_match_str = "https?:\\/\\/" + http_charset + "{1,}"; - var email_match_str = "(mailto:)?" + email_charset + "{1,}@" + email_charset + "{1,}\\." + email_tld_charset + "{1,}"; + var uri_regex_str = "(?:(" + + http_match_str + + ")|(" + + email_match_str + + "))"; - 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); - } + 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); - 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 any 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); + 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); } - } - 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); + } 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 on_buffer_cursor_position_changed () { - if (buffer_cursor_position_when_change_started == 0) { - buffer_cursor_position_when_change_started = buffer.cursor_position; - } - } + 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_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_buffer_cursor_position_changed () { + if (buffer_cursor_position_when_change_started == 0) { + buffer_cursor_position_when_change_started = buffer.cursor_position; } + } - 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; - } + 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; + } - buffer_changed_debounce_timeout_id = GLib.Timeout.add (300, () => { - buffer_changed_debounce_timeout_id = 0; + 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; + } - var change_start_offset = buffer_cursor_position_when_change_started; - var change_end_offset = buffer.cursor_position; + buffer_changed_debounce_timeout_id = GLib.Timeout.add (300, () => { + buffer_changed_debounce_timeout_id = 0; - buffer_cursor_position_when_change_started = 0; + var change_start_offset = buffer_cursor_position_when_change_started; + var change_end_offset = buffer.cursor_position; - if (change_start_offset == FORCE_FULL_BUFFER_RESCAN_CHANGE_START_OFFSET) { - change_start_offset = 0; - change_end_offset = buffer.text.length; - } + buffer_cursor_position_when_change_started = 0; - update_tags_in_buffer_for_range.begin ( - int.min (change_start_offset, change_end_offset), - int.max (change_start_offset, change_end_offset) - ); + 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; + } - return GLib.Source.REMOVE; - }); - } + update_tags_in_buffer_for_range.begin ( + int.min (change_start_offset, change_end_offset), + int.max (change_start_offset, change_end_offset) + ); - private async void update_tags_in_buffer_for_range (int buffer_start_offset, int buffer_end_offset) { - 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 (); + return GLib.Source.REMOVE; + }); + } - 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 (); + 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; + } - // 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)); - } + 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; - } + /* + 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); + // 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); + 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_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 (); + 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; + 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); + 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); + 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); - } + 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); - } + 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"); - } + 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; + 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; + 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 ()); - 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; + 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; } + 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")) - }); + 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); - } else if (uri_hovering_over == null && has_tooltip) { - has_tooltip = false; - } + 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")) + }); - return Gdk.EVENT_PROPAGATE; + } else if (uri_hovering_over == null && has_tooltip) { + has_tooltip = false; } - private string? get_uri_at_location (int location_x, int location_y) { - string? uri = null; - var window = get_window (Gtk.TextWindowType.TEXT); + return Gdk.EVENT_PROPAGATE; + } - if (window != null) { - int x, y; - window_to_buffer_coords (Gtk.TextWindowType.WIDGET, 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; - } + private string? get_uri_at_location (int location_x, int location_y) { + string? uri = null; + var window = get_window (Gtk.TextWindowType.TEXT); + + if (window != null) { + int x, y; + window_to_buffer_coords (Gtk.TextWindowType.WIDGET, 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; } + return uri; + } - private bool on_focus_out_event (Gdk.EventFocus event) { - is_control_key_pressed = false; - return Gdk.EVENT_PROPAGATE; - } + private bool on_focus_out_event (Gdk.EventFocus event) { + is_control_key_pressed = false; + return Gdk.EVENT_PROPAGATE; } } From 05fdfcdd9f7494719683b17128dfd0134ee760f2 Mon Sep 17 00:00:00 2001 From: Marco Betschart Date: Mon, 20 Sep 2021 17:07:21 +0200 Subject: [PATCH 8/8] Added fixes of @mcclurgm and @jeremypw --- src/Widgets/HyperTextView.vala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Widgets/HyperTextView.vala b/src/Widgets/HyperTextView.vala index 2758943b7..5dd71b0aa 100644 --- a/src/Widgets/HyperTextView.vala +++ b/src/Widgets/HyperTextView.vala @@ -33,12 +33,12 @@ public class Calendar.Widgets.HyperTextView : Gtk.TextView { private bool is_control_key_pressed = false; construct { - var http_charset = "[a-zA-Z0-9_\\/\\-\\+\\.:@\\?&%=#]"; - var email_charset = "[a-zA-Z0-9_\\-\\.]"; - var email_tld_charset = "[a-zA-Z0-9_\\-]"; + var http_charset = "[\\w\\/\\-\\+\\.:@\\?&%=#]"; + var email_charset = "[\\w\\-\\.]"; + var email_tld_charset = "[\\w\\-]"; - var http_match_str = "https?:\\/\\/" + http_charset + "{1,}"; - var email_match_str = "(mailto:)?" + email_charset + "{1,}@" + email_charset + "{1,}\\." + email_tld_charset + "{1,}"; + 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 + @@ -296,11 +296,11 @@ public class Calendar.Widgets.HyperTextView : Gtk.TextView { private string? get_uri_at_location (int location_x, int location_y) { string? uri = null; - var window = get_window (Gtk.TextWindowType.TEXT); + var window = get_window (Gtk.TextWindowType.WIDGET); if (window != null) { int x, y; - window_to_buffer_coords (Gtk.TextWindowType.WIDGET, location_x, location_y, out x, out 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)) {