-
-
Notifications
You must be signed in to change notification settings - Fork 62
HyperTextView #507
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
HyperTextView #507
Changes from 60 commits
Commits
Show all changes
69 commits
Select commit
Hold shift + click to select a range
8d8a68e
Granite.HyperTextView
marbetschar d8a7f4b
Make Linter Happy
marbetschar 52354e9
Lint Me Happy
marbetschar cfb3984
Eventually the Linter shuts up
marbetschar d6159c3
Ignore parentheses, support local file paths
marbetschar e28dcc5
Ignore < and >, improves HTML/XML handling.
marbetschar 06024ab
Improved Markdown Support
marbetschar de7f580
Added mailto: prefix check
marbetschar 4025a13
Improved Performance for Large Texts
marbetschar db7a52b
Some text adjustments
marbetschar 0d189a4
Fixed Linter error
marbetschar 7aadf7e
Fixed Linting Error
marbetschar 2d7e7dc
HyperTextView packed into ScrolledWindow
marbetschar ae48a19
Dealing with multibyte characters
marbetschar c5126bc
Fixed whitespace
marbetschar 203c370
Dropped potential harmful max param
marbetschar a80f5c3
Add mailto protocol if needed
marbetschar d19ee18
Control + Click
marbetschar 002fa00
Fixed deprecated window.get_pointer
marbetschar 6132d1d
Bind key_press/key_release to toplevel window
marbetschar eff3a8b
Bind key_press/key_release signals to toplevel window
marbetschar b5180f4
Made toplevel_window signal handling more robust
marbetschar 5e9dfd1
Disconnect from toplevel window seems not necessary
marbetschar e3f0864
Added warning in case toplevel window is missing
marbetschar 59324d7
Make toplevel_window local variable
marbetschar ddf7600
Fixed Linting Errors
marbetschar a17292f
Removed empty lines
marbetschar 39dc366
Merge branch 'master' into hypertextview
marbetschar c9d8f48
Dropped on comment and use single star
marbetschar c00f232
Merge branch 'master' into hypertextview
marbetschar cb8ed55
Require http(s):// but made mailto: optional
marbetschar c4a91df
Linter kills me
marbetschar 69d6cf8
Added missing chars to http charset
marbetschar c7cca7a
Allow plus and hash signs
marbetschar 3a4614a
Merge branch 'master' into hypertextview
marbetschar f8fd0a5
Merge branch 'master' into hypertextview
marbetschar 5136977
Merge branch 'master' into hypertextview
marbetschar 6a0fb0c
Merge branch 'master' into hypertextview
0055516
Changed "Unable" to "Could not"
marbetschar ae41c78
Use magic const to make full buffer rescan logic more obvious
marbetschar f7843eb
Updated header label to make functionality more obvious
marbetschar b92b415
Merge branch 'master' into hypertextview
marbetschar 9e5a9a4
Merge branch 'master' into hypertextview
marbetschar e6c2973
Merge branch 'master' into hypertextview
cassidyjames df91697
Merge branch 'master' into hypertextview
marbetschar 8087eff
Merge branch 'master' into hypertextview
marbetschar 797519f
Use inline namespacing
marbetschar 017c210
Using inline namespace
marbetschar 3b3a10d
Added changes from Calendar tests
marbetschar 5700bff
Merge branch 'master' into hypertextview
marbetschar 050585b
Use Granite prefix
marbetschar 24b13fe
Merge branch 'master' into hypertextview
marbetschar 4356422
Merge branch 'master' into hypertextview
marbetschar edccbed
Addressed naming concerns
marbetschar 828ae63
Fixed issue where initialization sometimes failed
marbetschar dd27f43
Fixed preview of HyperTextView caused by name-clash
marbetschar 74831fb
Merge branch 'master' into hypertextview
marbetschar 304db29
Added suggestions of @mcclurgm
marbetschar be393e1
Added popover fix from @jeremypw
marbetschar cd17ff7
Merge branch 'master' into hypertextview
marbetschar c2488e9
Merge branch 'master' into hypertextview
cassidyjames 684f2d8
Update lib/Widgets/HyperTextView.vala
marbetschar cc2b6e8
Merge branch 'master' into hypertextview
marbetschar 9d11b75
Merge branch 'master' into hypertextview
marbetschar 8c9a957
Merge branch 'master' into hypertextview
marbetschar 0a04662
Merge branch 'master' into hypertextview
cassidyjames 7d667c1
Merge branch 'master' into hypertextview
danirabbit d63967a
Update HyperTextView.vala
danirabbit 87dbe2e
Update granite.appdata.xml.in
danirabbit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 (); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,324 @@ | ||
| /* | ||
| * 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. | ||
| */ | ||
|
|
||
| /** | ||
| * This class enables navigatable URLs in Gtk.TextView | ||
| */ | ||
| 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<string, Gtk.TextTag> 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<string, Gtk.TextTag> (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<string?> ("uri") != null) { | ||
| var uri = tag.get_data<string> ("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<string?> ("uri") != null) { | ||
| uri = tag.get_data<string> ("uri"); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return uri; | ||
| } | ||
|
|
||
| private bool on_focus_out_event (Gdk.EventFocus event) { | ||
| is_control_key_pressed = false; | ||
| return Gdk.EVENT_PROPAGATE; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.